diff --git a/meet-ce/backend/package.json b/meet-ce/backend/package.json index 7395ca59..f12ee9ff 100644 --- a/meet-ce/backend/package.json +++ b/meet-ce/backend/package.json @@ -27,7 +27,7 @@ "package.json" ], "scripts": { - "build": "tsc -p tsconfig.prod.json", + "build": "tsc -p tsconfig.prod.json && pnpm run doc:api", "build:watch": "tsc -p tsconfig.prod.json --watch", "doc:api": "mkdir -p public/openapi && cd openapi && openapi-generate-html -i openvidu-meet-api.yaml --ui=stoplight --theme=light --title 'OpenVidu Meet REST API' --description 'OpenVidu Meet REST API' -o ../public/openapi/public.html", "doc:internal-api": "mkdir -p public/openapi && cd openapi && openapi-generate-html -i openvidu-meet-internal-api.yaml --ui=stoplight --theme=dark --title 'OpenVidu Meet Internal REST API' --description 'OpenVidu Meet Internal REST API' -o ../public/openapi/internal.html", diff --git a/meet-ce/backend/src/repositories/room.repository.ts b/meet-ce/backend/src/repositories/room.repository.ts index f756147a..9cc2e72f 100644 --- a/meet-ce/backend/src/repositories/room.repository.ts +++ b/meet-ce/backend/src/repositories/room.repository.ts @@ -2,7 +2,7 @@ import { MeetRoom, MeetRoomField, MeetRoomFilters, MeetRoomStatus } from '@openv import { inject, injectable } from 'inversify'; import { MeetRoomDocument, MeetRoomModel } from '../models/mongoose-schemas/room.schema.js'; import { LoggerService } from '../services/logger.service.js'; -import { getBasePath } from '../utils/html-injection.utils.js'; +import { getBasePath } from '../utils/html-dynamic-base-path.utils.js'; import { getBaseUrl } from '../utils/url.utils.js'; import { BaseRepository } from './base.repository.js'; diff --git a/meet-ce/backend/src/server.ts b/meet-ce/backend/src/server.ts index a3a62f31..5e6f2da5 100644 --- a/meet-ce/backend/src/server.ts +++ b/meet-ce/backend/src/server.ts @@ -17,7 +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 { getBasePath, getHtmlWithBasePath, getOpenApiHtmlWithBasePath } from './utils/html-dynamic-base-path.utils.js'; import { frontendDirectoryPath, frontendHtmlPath, @@ -76,7 +76,7 @@ const createApp = () => { // Public API routes appRouter.use(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/docs`, (_req: Request, res: Response) => - res.sendFile(publicApiHtmlFilePath) + res.type('html').send(getOpenApiHtmlWithBasePath(publicApiHtmlFilePath, INTERNAL_CONFIG.API_BASE_PATH_V1)) ); appRouter.use(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/rooms`, /*mediaTypeValidatorMiddleware,*/ roomRouter); appRouter.use(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings`, /*mediaTypeValidatorMiddleware,*/ recordingRouter); @@ -85,7 +85,7 @@ const createApp = () => { if (process.env.NODE_ENV === 'development') { // Serve internal API docs only in development mode appRouter.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/docs`, (_req: Request, res: Response) => - res.sendFile(internalApiHtmlFilePath) + res.type('html').send(getOpenApiHtmlWithBasePath(internalApiHtmlFilePath, INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1)) ); } @@ -104,7 +104,7 @@ const createApp = () => { 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)); + res.type('html').send(getHtmlWithBasePath(frontendHtmlPath)); }); // Catch all other routes and return 404 appRouter.use((_req: Request, res: Response) => diff --git a/meet-ce/backend/src/services/recording.service.ts b/meet-ce/backend/src/services/recording.service.ts index 522681bf..ff7e6516 100644 --- a/meet-ce/backend/src/services/recording.service.ts +++ b/meet-ce/backend/src/services/recording.service.ts @@ -97,19 +97,21 @@ export class RecordingService { status: MeetRecordingStatus.STARTING }); + // Promise that rejects after timeout const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { if (isOperationCompleted) return; isOperationCompleted = true; - //Clean up the event listener and timeout + // Clean up the event listener and timeout this.systemEventService.off(DistributedEventType.RECORDING_ACTIVE, eventListener); this.handleRecordingTimeout(recordingId, roomId).catch(() => {}); reject(errorRecordingStartTimeout(roomId)); }, ms(INTERNAL_CONFIG.RECORDING_STARTED_TIMEOUT)); }); + // Promise that resolves when RECORDING_ACTIVE event is received const activeEgressEventPromise = new Promise((resolve) => { eventListener = (info: Record) => { // Process the event only if it belongs to the current room. @@ -126,6 +128,7 @@ export class RecordingService { this.systemEventService.on(DistributedEventType.RECORDING_ACTIVE, eventListener); }); + // Promise that starts the recording process const startRecordingPromise = (async (): Promise => { try { const options = this.generateCompositeOptionsFromRequest(room.config, configOverride); @@ -156,6 +159,16 @@ export class RecordingService { } catch (error) { if (isOperationCompleted) { this.logger.warn(`startRoomComposite failed after timeout: ${error}`); + + // Manually send the recording FAILED signal to OpenVidu Components for avoiding missing event + await this.frontendEventService.sendRecordingSignalToOpenViduComponents(roomId, { + recordingId, + roomId, + roomName: roomId, + status: MeetRecordingStatus.FAILED, + error: (error as Error).message + }); + throw errorRecordingStartTimeout(roomId); } diff --git a/meet-ce/backend/src/utils/html-injection.utils.ts b/meet-ce/backend/src/utils/html-dynamic-base-path.utils.ts similarity index 67% rename from meet-ce/backend/src/utils/html-injection.utils.ts rename to meet-ce/backend/src/utils/html-dynamic-base-path.utils.ts index 4bfd9089..f4308820 100644 --- a/meet-ce/backend/src/utils/html-injection.utils.ts +++ b/meet-ce/backend/src/utils/html-dynamic-base-path.utils.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import { MEET_ENV } from '../environment.js'; let cachedHtml: string | null = null; +const cachedOpenApiHtml = new Map(); let configValidated = false; /** @@ -82,14 +83,14 @@ export function getBasePath(): string { } /** - * Injects runtime configuration into the index.html + * Applies runtime base path configuration to the index.html * - Replaces the tag with the configured base path - * - Injects a script with window.__OPENVIDU_MEET_CONFIG__ for frontend access + * - Adds 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 { +export function getHtmlWithBasePath(htmlPath: string): string { // In production, cache the result for performance if (process.env.NODE_ENV === 'production' && cachedHtml) { return cachedHtml; @@ -112,9 +113,44 @@ export function getInjectedHtml(htmlPath: string): string { return html; } +/** + * Applies the runtime base path to the OpenAPI documentation HTML. + * Replaces the servers URL in the embedded OpenAPI spec so that "Try It" requests + * use the correct path when deployed under a base path (e.g. /meet/api/v1). + * + * @param htmlPath Path to the OpenAPI HTML file + * @param apiBasePath The API base path (e.g. /api/v1 or /internal-api/v1) + * @returns The modified HTML content + */ +export function getOpenApiHtmlWithBasePath(htmlPath: string, apiBasePath: string): string { + if (process.env.NODE_ENV === 'production' && cachedOpenApiHtml.has(htmlPath)) { + return cachedOpenApiHtml.get(htmlPath)!; + } + + const basePath = getBasePath(); + // Build full server URL: strip trailing slash from basePath to avoid double slashes + const fullServerUrl = basePath.replace(/\/$/, '') + apiBasePath; + + let html = fs.readFileSync(htmlPath, 'utf-8'); + + // Replace the servers URL in the embedded OpenAPI JSON + // Matches "servers":[{"url":"" and replaces the URL with the full path + html = html.replace( + /("servers":\[\{"url":")[^"]*(")/, + `$1${fullServerUrl}$2` + ); + + if (process.env.NODE_ENV === 'production') { + cachedOpenApiHtml.set(htmlPath, html); + } + + return html; +} + /** * Clears the cached HTML (useful for testing or config changes) */ export function clearHtmlCache(): void { cachedHtml = null; + cachedOpenApiHtml.clear(); } diff --git a/meet-ce/backend/src/utils/url.utils.ts b/meet-ce/backend/src/utils/url.utils.ts index 1d0ac9cb..86ac3d25 100644 --- a/meet-ce/backend/src/utils/url.utils.ts +++ b/meet-ce/backend/src/utils/url.utils.ts @@ -1,7 +1,7 @@ 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'; +import { getBasePath } from './html-dynamic-base-path.utils.js'; /** * Returns the base URL for the application, including the configured base path. diff --git a/meet-ce/backend/tests/helpers/request-helpers.ts b/meet-ce/backend/tests/helpers/request-helpers.ts index 76900b3d..12f14ce3 100644 --- a/meet-ce/backend/tests/helpers/request-helpers.ts +++ b/meet-ce/backend/tests/helpers/request-helpers.ts @@ -33,7 +33,7 @@ import { ApiKeyService } from '../../src/services/api-key.service.js'; import { GlobalConfigService } from '../../src/services/global-config.service.js'; import { RecordingService } from '../../src/services/recording.service.js'; import { RoomScheduledTasksService } from '../../src/services/room-scheduled-tasks.service.js'; -import { getBasePath } from '../../src/utils/html-injection.utils.js'; +import { getBasePath } from '../../src/utils/html-dynamic-base-path.utils.js'; /** * Constructs the full API path by prepending the base path. diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/services/navigation.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/services/navigation.service.ts index aa852b3c..dfdb7d3c 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/services/navigation.service.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/services/navigation.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { Params, Router, UrlTree } from '@angular/router'; import { NavigationErrorReason } from '../models/navigation.model'; import { AppContextService } from './app-context.service'; +import { RuntimeConfigService } from './runtime-config.service'; import { SessionStorageService } from './session-storage.service'; @Injectable({ @@ -13,7 +14,8 @@ export class NavigationService { constructor( private router: Router, private sessionStorageService: SessionStorageService, - private appCtxService: AppContextService + private appCtxService: AppContextService, + private runtimeConfigService: RuntimeConfigService ) {} /** @@ -164,6 +166,13 @@ export class NavigationService { */ async redirectTo(url: string): Promise { try { + // Strip basePath prefix if present, since Angular router operates relative to + const basePath = this.runtimeConfigService.basePath; + const basePathPrefix = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath; + if (basePathPrefix && url.startsWith(basePathPrefix)) { + url = url.slice(basePathPrefix.length) || '/'; + } + let urlTree = this.router.parseUrl(url); await this.router.navigateByUrl(urlTree, { replaceUrl: true }); } catch (error) {