Introduce base path configuration and update related services

This commit is contained in:
cruizba 2026-02-02 19:14:20 +01:00
parent ba7600bfc5
commit b0c7dcbc9a
14 changed files with 262 additions and 47 deletions

View File

@ -23,6 +23,7 @@ export const MEET_ENV = {
LOG_LEVEL: process.env.MEET_LOG_LEVEL || 'info',
NAME_ID: process.env.MEET_NAME_ID || 'openviduMeet',
BASE_URL: process.env.MEET_BASE_URL ?? '',
BASE_PATH: process.env.MEET_BASE_PATH || '/meet',
EDITION: process.env.MEET_EDITION || 'CE',
// Authentication configuration

View File

@ -1,7 +1,7 @@
import chalk from 'chalk';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import express, { Express, Request, Response } from 'express';
import express, { Express, Request, Response, Router } from 'express';
import { initializeEagerServices, registerDependencies } from './config/dependency-injector.config.js';
import { INTERNAL_CONFIG } from './config/internal-config.js';
import { MEET_ENV, logEnvVars } from './environment.js';
@ -17,6 +17,7 @@ import { internalMeetingRouter } from './routes/meeting.routes.js';
import { recordingRouter } from './routes/recording.routes.js';
import { internalRoomRouter, roomRouter } from './routes/room.routes.js';
import { userRouter } from './routes/user.routes.js';
import { getBasePath, getInjectedHtml } from './utils/html-injection.utils.js';
import {
frontendDirectoryPath,
frontendHtmlPath,
@ -27,6 +28,7 @@ import {
const createApp = () => {
const app: Express = express();
const basePath = getBasePath();
// Enable CORS support
if (MEET_ENV.SERVER_CORS_ORIGIN) {
@ -38,9 +40,6 @@ const createApp = () => {
);
}
// Serve static files
app.use(express.static(frontendDirectoryPath));
// Configure trust proxy based on deployment topology
// This is important for rate limiting and getting the real client IP
// Can be: true, false, a number (hops), or a custom function/string
@ -69,55 +68,74 @@ const createApp = () => {
app.use(setBaseUrlFromRequest);
}
// Create a router for all app routes (to be mounted under base path)
const appRouter: Router = express.Router();
// Serve static files (disable automatic index.html serving so our catch-all can inject config)
appRouter.use(express.static(frontendDirectoryPath, { index: false }));
// Public API routes
app.use(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/docs`, (_req: Request, res: Response) =>
appRouter.use(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/docs`, (_req: Request, res: Response) =>
res.sendFile(publicApiHtmlFilePath)
);
app.use(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/rooms`, /*mediaTypeValidatorMiddleware,*/ roomRouter);
app.use(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings`, /*mediaTypeValidatorMiddleware,*/ recordingRouter);
appRouter.use(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/rooms`, /*mediaTypeValidatorMiddleware,*/ roomRouter);
appRouter.use(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings`, /*mediaTypeValidatorMiddleware,*/ recordingRouter);
// Internal API routes
if (process.env.NODE_ENV === 'development') {
// Serve internal API docs only in development mode
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/docs`, (_req: Request, res: Response) =>
appRouter.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/docs`, (_req: Request, res: Response) =>
res.sendFile(internalApiHtmlFilePath)
);
}
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/auth`, authRouter);
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/api-keys`, apiKeyRouter);
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/users`, userRouter);
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/rooms`, internalRoomRouter);
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/meetings`, internalMeetingRouter);
// app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/recordings`, internalRecordingRouter);
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/config`, configRouter);
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/analytics`, analyticsRouter);
appRouter.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/auth`, authRouter);
appRouter.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/api-keys`, apiKeyRouter);
appRouter.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/users`, userRouter);
appRouter.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/rooms`, internalRoomRouter);
appRouter.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/meetings`, internalMeetingRouter);
// appRouter.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/recordings`, internalRecordingRouter);
appRouter.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/config`, configRouter);
appRouter.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/analytics`, analyticsRouter);
app.use('/health', (_req: Request, res: Response) => res.status(200).send('OK'));
appRouter.use('/health', (_req: Request, res: Response) => res.status(200).send('OK'));
// LiveKit Webhook route
app.use('/livekit/webhook', livekitWebhookRouter);
appRouter.use('/livekit/webhook', livekitWebhookRouter);
// Serve OpenVidu Meet webcomponent bundle file
app.get('/v1/openvidu-meet.js', (_req: Request, res: Response) => res.sendFile(webcomponentBundlePath));
// Serve OpenVidu Meet index.html file for all non-API routes
app.get(/^(?!.*\/(api|internal-api)\/).*$/, (_req: Request, res: Response) => res.sendFile(frontendHtmlPath));
appRouter.get('/v1/openvidu-meet.js', (_req: Request, res: Response) => res.sendFile(webcomponentBundlePath));
// Serve OpenVidu Meet index.html file for all non-API routes (with dynamic base path injection)
appRouter.get(/^(?!.*\/(api|internal-api)\/).*$/, (_req: Request, res: Response) => {
res.type('html').send(getInjectedHtml(frontendHtmlPath));
});
// Catch all other routes and return 404
app.use((_req: Request, res: Response) =>
appRouter.use((_req: Request, res: Response) =>
res.status(404).json({ error: 'Path Not Found', message: 'API path not implemented' })
);
// Mount all routes under the configured base path
app.use(basePath, appRouter);
return app;
};
const startServer = (app: express.Application) => {
const basePath = getBasePath();
const basePathDisplay = basePath === '/' ? '' : basePath.slice(0, -1);
app.listen(MEET_ENV.SERVER_PORT, async () => {
console.log(' ');
console.log('---------------------------------------------------------');
console.log(' ');
console.log(`OpenVidu Meet ${MEET_ENV.EDITION} is listening on port`, chalk.cyanBright(MEET_ENV.SERVER_PORT));
if (basePath !== '/') {
console.log('Base Path:', chalk.cyanBright(basePath));
}
console.log(
'REST API Docs: ',
chalk.cyanBright(`http://localhost:${MEET_ENV.SERVER_PORT}${INTERNAL_CONFIG.API_BASE_PATH_V1}/docs`)
chalk.cyanBright(`http://localhost:${MEET_ENV.SERVER_PORT}${basePathDisplay}${INTERNAL_CONFIG.API_BASE_PATH_V1}/docs`)
);
logEnvVars();
});

View File

@ -0,0 +1,111 @@
import chalk from 'chalk';
import fs from 'fs';
import { MEET_ENV } from '../environment.js';
let cachedHtml: string | null = null;
let configValidated = false;
/**
* Normalizes the base path to ensure it starts and ends with /
* @param basePath The base path to normalize
* @returns Normalized base path (e.g., '/', '/meet/', '/app/path/')
*/
export function normalizeBasePath(basePath: string): string {
let normalized = basePath.trim();
// Handle empty string
if (!normalized) {
return '/';
}
// Ensure it starts with /
if (!normalized.startsWith('/')) {
normalized = '/' + normalized;
}
// Ensure it ends with /
if (!normalized.endsWith('/')) {
normalized = normalized + '/';
}
return normalized;
}
/**
* Validates the BASE_URL and BASE_PATH configuration and warns about potential issues.
* Only runs once per process.
*/
function validateBasePathConfig(): void {
if (configValidated) return;
configValidated = true;
const baseUrl = MEET_ENV.BASE_URL;
const basePath = MEET_ENV.BASE_PATH;
if (baseUrl) {
try {
const url = new URL(baseUrl);
// Check if BASE_URL contains a path (other than just /)
if (url.pathname && url.pathname !== '/') {
console.warn(chalk.yellow('⚠️ WARNING: MEET_BASE_URL contains a path segment:'), chalk.cyan(url.pathname));
console.warn(chalk.yellow(' MEET_BASE_URL should only contain https protocol and host (e.g., https://example.com)'));
console.warn(chalk.yellow(' Use MEET_BASE_PATH for the deployment path (e.g., /meet/)'));
if (basePath && basePath !== '/') {
console.warn(chalk.red(` This may cause issues: BASE_URL path "${url.pathname}" + BASE_PATH "${basePath}"`));
}
}
} catch {
console.warn(chalk.yellow('⚠️ WARNING: MEET_BASE_URL is not a valid URL:'), chalk.cyan(baseUrl));
}
}
}
/**
* Gets the configured base path, normalized
* @returns The normalized base path from MEET_BASE_PATH environment variable
*/
export function getBasePath(): string {
validateBasePathConfig();
return normalizeBasePath(MEET_ENV.BASE_PATH);
}
/**
* Injects runtime configuration into the index.html
* - Replaces the <base href="/"> tag with the configured base path
* - Injects a script with window.__OPENVIDU_MEET_CONFIG__ for frontend access
*
* @param htmlPath Path to the index.html file
* @returns The modified HTML content
*/
export function getInjectedHtml(htmlPath: string): string {
// In production, cache the result for performance
if (process.env.NODE_ENV === 'production' && cachedHtml) {
return cachedHtml;
}
const basePath = getBasePath();
let html = fs.readFileSync(htmlPath, 'utf-8');
// Replace the base href - handle both possible formats
html = html.replace(/<base href="[^"]*"\s*\/?>/i, `<base href="${basePath}">`);
// Inject runtime configuration script before the closing </head> tag
const configScript = `<script>window.__OPENVIDU_MEET_CONFIG__={basePath:"${basePath}"};</script>`;
html = html.replace('</head>', `${configScript}\n</head>`);
if (process.env.NODE_ENV === 'production') {
cachedHtml = html;
}
return html;
}
/**
* Clears the cached HTML (useful for testing or config changes)
*/
export function clearHtmlCache(): void {
cachedHtml = null;
}

View File

@ -1,33 +1,46 @@
import { container } from '../config/dependency-injector.config.js';
import { MEET_ENV } from '../environment.js';
import { BaseUrlService } from '../services/base-url.service.js';
import { getBasePath } from './html-injection.utils.js';
/**
* Returns the base URL for the application.
* Returns the base URL for the application, including the configured base path.
*
* If the global `BASE_URL` variable is defined, it returns its value,
* ensuring there is no trailing slash and removing default ports (443 for HTTPS, 80 for HTTP).
* Otherwise, it retrieves the base URL from the `HttpContextService` instance.
*
* @returns {string} The base URL as a string.
* The configured BASE_PATH is appended to the URL (without trailing slash).
*
* @returns {string} The base URL as a string (e.g., 'https://example.com/meet').
*/
export const getBaseUrl = (): string => {
let hostUrl: string;
if (MEET_ENV.BASE_URL) {
let baseUrl = MEET_ENV.BASE_URL.endsWith('/') ? MEET_ENV.BASE_URL.slice(0, -1) : MEET_ENV.BASE_URL;
hostUrl = MEET_ENV.BASE_URL.endsWith('/') ? MEET_ENV.BASE_URL.slice(0, -1) : MEET_ENV.BASE_URL;
// Remove default port 443 for HTTPS URLs
if (baseUrl.startsWith('https://') && baseUrl.includes(':443')) {
baseUrl = baseUrl.replace(':443', '');
if (hostUrl.startsWith('https://') && hostUrl.includes(':443')) {
hostUrl = hostUrl.replace(':443', '');
}
// Remove default port 80 for HTTP URLs
if (baseUrl.startsWith('http://') && baseUrl.includes(':80')) {
baseUrl = baseUrl.replace(':80', '');
if (hostUrl.startsWith('http://') && hostUrl.includes(':80')) {
hostUrl = hostUrl.replace(':80', '');
}
return baseUrl;
}
} else {
const baseUrlService = container.get(BaseUrlService);
return baseUrlService.getBaseUrl();
hostUrl = baseUrlService.getBaseUrl();
}
// Append the base path (without trailing slash)
const basePath = getBasePath();
if (basePath === '/') {
return hostUrl;
}
// Remove trailing slash from base path for the final URL
return `${hostUrl}${basePath.slice(0, -1)}`;
};

View File

@ -12,6 +12,7 @@ import {
} from 'openvidu-components-angular';
import { Subject } from 'rxjs';
import { ApplicationFeatures } from '../../../../shared/models/app.model';
import { AppConfigService } from '../../../../shared/services/app-config.service';
import { FeatureConfigurationService } from '../../../../shared/services/feature-configuration.service';
import { GlobalConfigService } from '../../../../shared/services/global-config.service';
import { NotificationService } from '../../../../shared/services/notification.service';
@ -72,6 +73,7 @@ export class MeetingComponent implements OnInit {
protected eventHandlerService = inject(MeetingEventHandlerService);
protected captionsService = inject(MeetingCaptionsService);
protected soundService = inject(SoundService);
protected appConfigService = inject(AppConfigService);
protected destroy$ = new Subject<void>();
// === LOBBY PHASE COMPUTED SIGNALS (when showLobby = true) ===
@ -208,7 +210,9 @@ export class MeetingComponent implements OnInit {
// }
async onViewRecordingsClicked() {
window.open(`/room/${this.roomId()}/recordings?secret=${this.roomSecret()}`, '_blank');
const basePath = this.appConfigService.basePath;
const basePathForUrl = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
window.open(`${basePathForUrl}/room/${this.roomId()}/recordings?secret=${this.roomSecret()}`, '_blank');
}
onParticipantConnected(event: any): void {

View File

@ -1,6 +1,7 @@
import { computed, effect, inject, Injectable, signal } from '@angular/core';
import { MeetRoom } from 'node_modules/@openvidu-meet/typings/dist/room';
import { ParticipantService, Room, ViewportService } from 'openvidu-components-angular';
import { AppConfigService } from '../../../shared/services/app-config.service';
import { FeatureConfigurationService } from '../../../shared/services/feature-configuration.service';
import { SessionStorageService } from '../../../shared/services/session-storage.service';
import { CustomParticipantModel } from '../models';
@ -18,6 +19,7 @@ export class MeetingContextService {
private readonly featureConfigService = inject(FeatureConfigurationService);
private readonly viewportService = inject(ViewportService);
private readonly sessionStorageService = inject(SessionStorageService);
private readonly appConfigService = inject(AppConfigService);
private readonly _meetRoom = signal<MeetRoom | undefined>(undefined);
private readonly _lkRoom = signal<Room | undefined>(undefined);
@ -166,7 +168,10 @@ export class MeetingContextService {
*/
private setMeetingUrl(roomId: string): void {
const hostname = window.location.origin.replace('http://', '').replace('https://', '');
const meetingUrl = roomId ? `${hostname}/room/${roomId}` : '';
const basePath = this.appConfigService.basePath;
// Remove trailing slash from base path for URL construction
const basePathForUrl = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
const meetingUrl = roomId ? `${hostname}${basePathForUrl}/room/${roomId}` : '';
this._meetingUrl.set(meetingUrl);
}

View File

@ -0,0 +1,59 @@
import { Injectable } from '@angular/core';
declare global {
interface Window {
__OPENVIDU_MEET_CONFIG__?: {
basePath: string;
};
}
}
/**
* Service that provides access to runtime application configuration.
* Reads configuration injected by the server at runtime.
*/
@Injectable({
providedIn: 'root'
})
export class AppConfigService {
private _basePath: string;
constructor() {
// Read from injected config, fallback to document base element, then to '/'
this._basePath = window.__OPENVIDU_MEET_CONFIG__?.basePath || this.getBasePathFromDocument() || '/';
}
/**
* Gets the configured base path (e.g., '/', '/meet/', '/app/path/')
*/
get basePath(): string {
return this._basePath;
}
/**
* Resolves an asset path relative to the base path.
* Use this for assets that need absolute paths (e.g., Audio elements).
*
* @param assetPath The asset path starting with 'assets/' (no leading slash)
* @returns The full path including the base path (e.g., '/meet/assets/sounds/file.mp3')
*/
resolveAssetPath(assetPath: string): string {
// Remove leading slash if present
const cleanPath = assetPath.startsWith('/') ? assetPath.slice(1) : assetPath;
// Combine with base path
return `${this._basePath}${cleanPath}`;
}
private getBasePathFromDocument(): string | null {
try {
const baseElement = document.querySelector('base');
if (baseElement) {
return baseElement.getAttribute('href') || null;
}
} catch (e) {
console.warn('Could not read base element:', e);
}
return null;
}
}

View File

@ -1,5 +1,6 @@
export * from './analytics.service';
export * from './api-key.service';
export * from './app-config.service';
export * from './app-data.service';
export * from './feature-configuration.service';
export * from './global-config.service';

View File

@ -1,17 +1,20 @@
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { AppConfigService } from './app-config.service';
/**
* Service responsible for managing sound effects within the application.
*/
@Injectable()
export class SoundService {
private appConfig = inject(AppConfigService);
constructor() {}
/**
* Plays a sound to indicate that a participant has joined the meeting.
*/
playParticipantJoinedSound(): void {
const audio = new Audio('/assets/sounds/participant-joined.mp3');
const audio = new Audio(this.appConfig.resolveAssetPath('assets/sounds/participant-joined.mp3'));
audio.volume = 0.4;
audio.play();
}
@ -20,7 +23,7 @@ export class SoundService {
* Plays a sound to indicate that a participant's role has been upgraded.
*/
playParticipantRoleUpgradedSound(): void {
const audio = new Audio('/assets/sounds/role-upgraded.wav');
const audio = new Audio(this.appConfig.resolveAssetPath('assets/sounds/role-upgraded.wav'));
audio.volume = 0.4;
audio.play();
}
@ -30,7 +33,7 @@ export class SoundService {
* Plays a sound to indicate that a participant's role has been downgraded.
*/
playParticipantRoleDowngradedSound(): void {
const audio = new Audio('/assets/sounds/role-downgraded.wav');
const audio = new Audio(this.appConfig.resolveAssetPath('assets/sounds/role-downgraded.wav'));
audio.volume = 0.4;
audio.play();
}

View File

@ -343,7 +343,7 @@ pnpm test:e2e-recording-access # Recording access control tests
Tests require the following configuration (defined in `tests/config.ts`):
```typescript
MEET_API_URL # Backend API URL (default: http://localhost:6080)
MEET_API_URL # Backend API URL (default: http://localhost:6080/meet)
MEET_API_KEY # API key for backend (default: meet-api-key)
MEET_TESTAPP_URL # Test application URL (default: http://localhost:5080)
RUN_MODE # Execution mode: 'CI' or 'development'

View File

@ -1,4 +1,4 @@
export const RUN_MODE = process.env['RUN_MODE'] || 'development';
export const MEET_API_URL = process.env['MEET_API_URL'] || 'http://localhost:6080';
export const MEET_API_URL = process.env['MEET_API_URL'] || 'http://localhost:6080/meet';
export const MEET_API_KEY = process.env['MEET_API_KEY'] || 'meet-api-key';
export const MEET_TESTAPP_URL = process.env['MEET_TESTAPP_URL'] || 'http://localhost:5080';

View File

@ -1,4 +1,4 @@
SERVER_PORT=5080
MEET_API_URL=http://localhost:6080/api/v1
MEET_API_URL=http://localhost:6080/meet/api/v1
MEET_API_KEY=meet-api-key
MEET_WEBCOMPONENT_SRC=http://localhost:6080/v1/openvidu-meet.js
MEET_WEBCOMPONENT_SRC=http://localhost:6080/meet/v1/openvidu-meet.js

View File

@ -23,7 +23,7 @@ export const getHome = async (_req: Request, res: Response) => {
console.error('Error details:', {
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : 'No stack trace',
apiUrl: process.env.MEET_API_URL || 'http://localhost:6080/api/v1',
apiUrl: process.env.MEET_API_URL || 'http://localhost:6080/meet/api/v1',
apiKey: process.env.MEET_API_KEY || 'meet-api-key'
});
res.status(500).send(

View File

@ -10,9 +10,9 @@ export class ConfigService {
constructor() {
this.serverPort = process.env.SERVER_PORT ? parseInt(process.env.SERVER_PORT, 10) : 5080;
this.meetApiUrl = process.env.MEET_API_URL || 'http://localhost:6080/api/v1';
this.meetApiUrl = process.env.MEET_API_URL || 'http://localhost:6080/meet/api/v1';
this.meetApiKey = process.env.MEET_API_KEY || 'meet-api-key';
this.meetWebhookSrc = process.env.MEET_WEBCOMPONENT_SRC || 'http://localhost:6080/v1/openvidu-meet.js';
this.meetWebhookSrc = process.env.MEET_WEBCOMPONENT_SRC || 'http://localhost:6080/meet/v1/openvidu-meet.js';
}
}