openvidu-dashboard: the backend container is built in di/container.ts

buildContainer() leaves lifecycle/startup.ts, which keeps the startup sequence; it takes the edition, version and
docs switch instead of the startup options.
pull/924/head
juancarmore 2026-10-26 15:03:39 +01:00
parent e34946d44d
commit 7e7e71bfbc
3 changed files with 256 additions and 229 deletions

View File

@ -6,9 +6,11 @@ Behaviour is specified in `openspec/specs/*-api`, `api-conventions`, `auth`, `st
## Structure ## Structure
- `src/lifecycle/startup.ts`: `startDashboard(options)` reads the configuration, connects to MongoDB, applies the - `src/lifecycle/startup.ts`: `startDashboard(options)` reads the configuration, connects to MongoDB, applies the
writer schema gate, builds the container, calls `configure` and the startup hooks, then listens. `TOKENS` writer schema gate, builds the container, calls `configure` and the startup hooks, then listens. Exit codes go
(`src/di/tokens.ts`) are bound with `toResolvedValue` factories, without decorators, so esbuild and Vitest need no through `Shutdown.run(code)`.
metadata. Exit codes go through `Shutdown.run(code)`. - `src/di/container.ts`: `buildContainer()` binds the CE services and extension tokens and registers the CE routes.
`TOKENS` (`src/di/tokens.ts`) are bound with `toResolvedValue` factories, without decorators, so esbuild and Vitest
need no metadata.
- Routes are declared once in the `RouteRegistry` (`src/api/route-registry.ts`): method, path under `/api/v1`, edition, - Routes are declared once in the `RouteRegistry` (`src/api/route-registry.ts`): method, path under `/api/v1`, edition,
`feature` of a PRO route, security (`public`, `bearer`, `optional-bearer`), Zod schemas of params, query, body and `feature` of a PRO route, security (`public`, `bearer`, `optional-bearer`), Zod schemas of params, query, body and
responses, and the handler. It drives validation, authentication and the OpenAPI document (only when `NODE_ENV` is responses, and the handler. It drives validation, authentication and the OpenAPI document (only when `NODE_ENV` is

View File

@ -0,0 +1,232 @@
import { Container } from 'inversify';
import { type Db, type MongoClient } from 'mongodb';
import { type Edition, type FeatureKey, type InfoLicense } from '@openvidu-dashboard/typings';
import { AnalyticsCache } from '../analytics/analytics-cache.js';
import { type AnalyticsCatalogue, CE_ANALYTICS_CATALOGUE } from '../analytics/analytics-catalogue.js';
import { AnalyticsFeatureGate } from '../analytics/analytics-gate.js';
import { registerAnalyticsRoutes } from '../analytics/analytics-routes.js';
import { AnalyticsService } from '../analytics/analytics-service.js';
import { registerProbes } from '../api/probes.js';
import { rankedSortLimits } from '../api/ranked-limits.js';
import { RouteRegistry } from '../api/route-registry.js';
import { registerAuthRoutes } from '../auth/auth-routes.js';
import { AuthService } from '../auth/auth-service.js';
import { SessionRepository } from '../auth/session-repository.js';
import { type DashboardConfig } from '../config/variables.js';
import { registerEgressRoutes } from '../egress/egress-routes.js';
import { EgressService } from '../egress/egress-service.js';
import { registerInfoRoute } from '../info/info-routes.js';
import { InfoService } from '../info/info-service.js';
import { registerIngressRoutes } from '../ingress/ingress-routes.js';
import { IngressService } from '../ingress/ingress-service.js';
import { type Shutdown } from '../lifecycle/shutdown.js';
import { type Logger } from '../logging/logger.js';
import { registerOverviewRoutes } from '../overview/overview-routes.js';
import { type OverviewExtension, OverviewService } from '../overview/overview-service.js';
import { registerParticipantsRoutes } from '../participants/participants-routes.js';
import { ParticipantsService } from '../participants/participants-service.js';
import { registerRoomsRoutes } from '../rooms/rooms-routes.js';
import { RoomsService } from '../rooms/rooms-service.js';
import { StatsModeReader } from '../stats/stats-mode.js';
import { WriterFreshness } from '../status/freshness.js';
import { registerStatusRoutes } from '../status/status-routes.js';
import { StatusService } from '../status/status-service.js';
import { registerTracksRoutes } from '../tracks/tracks-routes.js';
import { TOKENS } from './tokens.js';
export interface ContainerInputs {
edition: Edition;
version: string;
/** Whether the OpenAPI document and its page are served. */
docsEnabled: boolean;
config: DashboardConfig;
logger: Logger;
client: MongoClient;
db: Db;
shutdown: Shutdown;
}
/**
* Binds the CE services and extension tokens and registers the CE routes. An extending edition rebinds them in its
* `configure`, before the startup hooks.
*/
export function buildContainer(inputs: ContainerInputs): Container {
const { edition, version, config, logger, client, db, shutdown } = inputs;
const container = new Container();
container.bind(TOKENS.Config).toConstantValue(config);
container.bind(TOKENS.Logger).toConstantValue(logger);
container.bind(TOKENS.MongoClient).toConstantValue(client);
container.bind(TOKENS.Database).toConstantValue(db);
container.bind(TOKENS.Shutdown).toConstantValue(shutdown);
container.bind<() => Date>(TOKENS.Clock).toConstantValue(() => new Date());
container.bind<FeatureKey[]>(TOKENS.Features).toConstantValue([]);
container.bind<() => InfoLicense | undefined>(TOKENS.LicenseInfo).toConstantValue(() => undefined);
container.bind(TOKENS.RouteRegistry).toConstantValue(new RouteRegistry(edition));
container.bind<AnalyticsCatalogue>(TOKENS.AnalyticsCatalogue).toConstantValue(CE_ANALYTICS_CATALOGUE);
container
.bind(TOKENS.SessionRepository)
.toResolvedValue((database: Db) => new SessionRepository(database), [TOKENS.Database])
.inSingletonScope();
container
.bind(TOKENS.StatsModeReader)
.toResolvedValue(
(database: Db, clock: () => Date) => new StatsModeReader(database, clock),
[TOKENS.Database, TOKENS.Clock]
)
.inSingletonScope();
container
.bind(TOKENS.InfoService)
.toResolvedValue(
(database: Db, statsMode: StatsModeReader) =>
new InfoService(database, {
edition: edition,
version: version,
features: () => container.get<FeatureKey[]>(TOKENS.Features),
limits: rankedSortLimits(config.lists.totalMaxRangeMs),
license: () => container.get<() => InfoLicense | undefined>(TOKENS.LicenseInfo)(),
openapiDocsEnabled: inputs.docsEnabled,
statsMode
}),
[TOKENS.Database, TOKENS.StatsModeReader]
)
.inSingletonScope();
container
.bind(TOKENS.AuthService)
.toResolvedValue(
(key: Uint8Array, sessions: SessionRepository, clock: () => Date) =>
new AuthService({
username: config.admin.username,
password: config.admin.password,
edition: edition,
key,
refreshTokenTtlSeconds: config.session.refreshTokenTtlSeconds,
sessions,
logger,
now: clock
}),
[TOKENS.SigningKey, TOKENS.SessionRepository, TOKENS.Clock]
)
.inSingletonScope();
container
.bind(TOKENS.WriterFreshness)
.toResolvedValue(
(database: Db, clock: () => Date) => new WriterFreshness(database, clock),
[TOKENS.Database, TOKENS.Clock]
)
.inSingletonScope();
container
.bind(TOKENS.RoomsService)
.toResolvedValue(
(database: Db, clock: () => Date, freshness: WriterFreshness) =>
new RoomsService(database, {
totalMaxRangeMs: config.lists.totalMaxRangeMs,
freshness,
now: clock
}),
[TOKENS.Database, TOKENS.Clock, TOKENS.WriterFreshness]
)
.inSingletonScope();
container
.bind(TOKENS.ParticipantsService)
.toResolvedValue(
(database: Db, clock: () => Date, freshness: WriterFreshness) =>
new ParticipantsService(database, {
totalMaxRangeMs: config.lists.totalMaxRangeMs,
freshness,
now: clock
}),
[TOKENS.Database, TOKENS.Clock, TOKENS.WriterFreshness]
)
.inSingletonScope();
container
.bind(TOKENS.EgressService)
.toResolvedValue(
(database: Db, clock: () => Date) =>
new EgressService(database, { totalMaxRangeMs: config.lists.totalMaxRangeMs, now: clock }),
[TOKENS.Database, TOKENS.Clock]
)
.inSingletonScope();
container
.bind(TOKENS.IngressService)
.toResolvedValue(
(database: Db, clock: () => Date) =>
new IngressService(database, { totalMaxRangeMs: config.lists.totalMaxRangeMs, now: clock }),
[TOKENS.Database, TOKENS.Clock]
)
.inSingletonScope();
container
.bind(TOKENS.AnalyticsFeatureGate)
.toResolvedValue(() => new AnalyticsFeatureGate(() => container.get<FeatureKey[]>(TOKENS.Features)))
.inSingletonScope();
container
.bind(TOKENS.AnalyticsService)
.toResolvedValue(
(database: Db, clock: () => Date, gate: AnalyticsFeatureGate, catalogue: AnalyticsCatalogue) =>
new AnalyticsService(database, {
now: clock,
gate,
catalogue,
cache: new AnalyticsCache(config.analytics.cacheTtlSeconds * 1000, clock)
}),
[TOKENS.Database, TOKENS.Clock, TOKENS.AnalyticsFeatureGate, TOKENS.AnalyticsCatalogue]
)
.inSingletonScope();
container.bind<OverviewExtension>(TOKENS.OverviewExtension).toConstantValue(() => ({}));
container
.bind(TOKENS.OverviewService)
.toResolvedValue(
(database: Db, clock: () => Date, extension: OverviewExtension) =>
new OverviewService(database, { now: clock, extension }),
[TOKENS.Database, TOKENS.Clock, TOKENS.OverviewExtension]
)
.inSingletonScope();
container
.bind(TOKENS.StatusService)
.toResolvedValue(
(database: Db, clock: () => Date, info: InfoService) => new StatusService(database, { info, now: clock }),
[TOKENS.Database, TOKENS.Clock, TOKENS.InfoService]
)
.inSingletonScope();
logger.info(
config.analytics.cacheTtlSeconds > 0
? `Analytics cache: completed buckets kept for ${String(config.analytics.cacheTtlSeconds)} s`
: 'Analytics cache disabled (DASHBOARD_ANALYTICS_CACHE_TTL=0)'
);
const registry = container.get<RouteRegistry>(TOKENS.RouteRegistry);
const now = () => container.get<() => Date>(TOKENS.Clock)();
registerProbes(registry, version, db);
registerAuthRoutes(
registry,
() => container.get<AuthService>(TOKENS.AuthService),
() => ({
basePath: container.get<string>(TOKENS.BasePath),
ttlSeconds: config.session.refreshTokenTtlSeconds,
secure: config.session.cookieSecure
})
);
registerInfoRoute(registry, () => container.get<InfoService>(TOKENS.InfoService));
registerRoomsRoutes(registry, () => container.get<RoomsService>(TOKENS.RoomsService), now);
registerParticipantsRoutes(registry, () => container.get<ParticipantsService>(TOKENS.ParticipantsService), now);
registerTracksRoutes(registry);
registerEgressRoutes(registry, () => container.get<EgressService>(TOKENS.EgressService), now);
registerIngressRoutes(registry, () => container.get<IngressService>(TOKENS.IngressService), now);
registerAnalyticsRoutes(registry, () => container.get<AnalyticsService>(TOKENS.AnalyticsService), now);
registerOverviewRoutes(registry, () => container.get<OverviewService>(TOKENS.OverviewService));
registerStatusRoutes(registry, () => container.get<StatusService>(TOKENS.StatusService));
return container;
}

View File

@ -1,55 +1,25 @@
import type { Server } from 'node:http'; import type { Server } from 'node:http';
import { Container } from 'inversify'; import { type Container } from 'inversify';
import { type Db, MongoClient } from 'mongodb'; import { type Db, MongoClient } from 'mongodb';
import { import { type Edition, type InfoSchema, WriterSchemaStatus } from '@openvidu-dashboard/typings';
type Edition,
type FeatureKey,
type InfoLicense,
type InfoSchema,
WriterSchemaStatus
} from '@openvidu-dashboard/typings';
import { docsRouter, openApiDocument } from '../api/openapi.js';
import { type RouteRegistry } from '../api/route-registry.js';
import { type AuthService } from '../auth/auth-service.js';
import { bearerAuthenticator } from '../auth/bearer.js';
import { type SessionRepository } from '../auth/session-repository.js';
import { provisionSigningKey } from '../auth/signing-key.js';
import { loadConfigFile } from '../config/config-file.js'; import { loadConfigFile } from '../config/config-file.js';
import { type DashboardConfig, parseConfig } from '../config/variables.js'; import { type DashboardConfig, parseConfig } from '../config/variables.js';
import { buildContainer } from '../di/container.js';
import { TOKENS } from '../di/tokens.js'; import { TOKENS } from '../di/tokens.js';
import { AnalyticsCache } from '../analytics/analytics-cache.js';
import { type AnalyticsCatalogue, CE_ANALYTICS_CATALOGUE } from '../analytics/analytics-catalogue.js';
import { AnalyticsFeatureGate } from '../analytics/analytics-gate.js';
import { registerAnalyticsRoutes } from '../analytics/analytics-routes.js';
import { AnalyticsService } from '../analytics/analytics-service.js';
import { docsRouter, openApiDocument } from '../api/openapi.js';
import { registerProbes } from '../api/probes.js';
import { rankedSortLimits } from '../api/ranked-limits.js';
import { RouteRegistry } from '../api/route-registry.js';
import { registerAuthRoutes } from '../auth/auth-routes.js';
import { AuthService } from '../auth/auth-service.js';
import { bearerAuthenticator } from '../auth/bearer.js';
import { SessionRepository } from '../auth/session-repository.js';
import { provisionSigningKey } from '../auth/signing-key.js';
import { registerEgressRoutes } from '../egress/egress-routes.js';
import { EgressService } from '../egress/egress-service.js';
import { createHttpApp } from '../http/app.js'; import { createHttpApp } from '../http/app.js';
import { resolveBasePath } from '../http/base-path.js'; import { resolveBasePath } from '../http/base-path.js';
import { InfoService } from '../info/info-service.js';
import { registerIngressRoutes } from '../ingress/ingress-routes.js';
import { IngressService } from '../ingress/ingress-service.js';
import { registerInfoRoute } from '../info/info-routes.js';
import { createLogger, type Logger } from '../logging/logger.js'; import { createLogger, type Logger } from '../logging/logger.js';
import { mongoClientOptions, resolveMongoUrl } from '../mongo/mongo-client.js'; import { mongoClientOptions, resolveMongoUrl } from '../mongo/mongo-client.js';
import { registerOverviewRoutes } from '../overview/overview-routes.js';
import { type OverviewExtension, OverviewService } from '../overview/overview-service.js';
import { registerParticipantsRoutes } from '../participants/participants-routes.js';
import { ParticipantsService } from '../participants/participants-service.js';
import { registerRoomsRoutes } from '../rooms/rooms-routes.js';
import { RoomsService } from '../rooms/rooms-service.js';
import { olderSchemaMessage, readSchemaStatus } from '../schema/schema-status.js'; import { olderSchemaMessage, readSchemaStatus } from '../schema/schema-status.js';
import { StatsModeReader } from '../stats/stats-mode.js';
import { registerStatusRoutes } from '../status/status-routes.js';
import { WriterFreshness } from '../status/freshness.js';
import { StatusService } from '../status/status-service.js';
import { registerTracksRoutes } from '../tracks/tracks-routes.js';
import { type Exit, Shutdown } from './shutdown.js'; import { type Exit, Shutdown } from './shutdown.js';
/** Process exit codes of the dashboard; an extending edition's startup hooks may exit with codes of their own. */ /** Process exit codes of the dashboard; an extending edition's startup hooks may exit with codes of their own. */
@ -203,7 +173,16 @@ export async function startDashboard(options: DashboardOptions): Promise<Running
logSchema(logger, schema); logSchema(logger, schema);
const container = buildContainer(options, config, logger, client, db, shutdown); const container = buildContainer({
edition: options.edition,
version: options.version,
docsEnabled: docsEnabled(env),
config,
logger,
client,
db,
shutdown
});
try { try {
options.configure?.(container); options.configure?.(container);
@ -296,189 +275,3 @@ function logSchema(logger: Logger, schema: InfoSchema): void {
logger.info('The OpenVidu server has not initialized the analytics database yet'); logger.info('The OpenVidu server has not initialized the analytics database yet');
} }
} }
function buildContainer(
options: DashboardOptions,
config: DashboardConfig,
logger: Logger,
client: MongoClient,
db: Db,
shutdown: Shutdown
): Container {
const env = options.env ?? process.env;
const container = new Container();
container.bind(TOKENS.Config).toConstantValue(config);
container.bind(TOKENS.Logger).toConstantValue(logger);
container.bind(TOKENS.MongoClient).toConstantValue(client);
container.bind(TOKENS.Database).toConstantValue(db);
container.bind(TOKENS.Shutdown).toConstantValue(shutdown);
container.bind<() => Date>(TOKENS.Clock).toConstantValue(() => new Date());
container.bind<FeatureKey[]>(TOKENS.Features).toConstantValue([]);
container.bind<() => InfoLicense | undefined>(TOKENS.LicenseInfo).toConstantValue(() => undefined);
container.bind(TOKENS.RouteRegistry).toConstantValue(new RouteRegistry(options.edition));
container.bind<AnalyticsCatalogue>(TOKENS.AnalyticsCatalogue).toConstantValue(CE_ANALYTICS_CATALOGUE);
container
.bind(TOKENS.SessionRepository)
.toResolvedValue((database: Db) => new SessionRepository(database), [TOKENS.Database])
.inSingletonScope();
container
.bind(TOKENS.StatsModeReader)
.toResolvedValue(
(database: Db, clock: () => Date) => new StatsModeReader(database, clock),
[TOKENS.Database, TOKENS.Clock]
)
.inSingletonScope();
container
.bind(TOKENS.InfoService)
.toResolvedValue(
(database: Db, statsMode: StatsModeReader) =>
new InfoService(database, {
edition: options.edition,
version: options.version,
features: () => container.get<FeatureKey[]>(TOKENS.Features),
limits: rankedSortLimits(config.lists.totalMaxRangeMs),
license: () => container.get<() => InfoLicense | undefined>(TOKENS.LicenseInfo)(),
openapiDocsEnabled: docsEnabled(env),
statsMode
}),
[TOKENS.Database, TOKENS.StatsModeReader]
)
.inSingletonScope();
container
.bind(TOKENS.AuthService)
.toResolvedValue(
(key: Uint8Array, sessions: SessionRepository, clock: () => Date) =>
new AuthService({
username: config.admin.username,
password: config.admin.password,
edition: options.edition,
key,
refreshTokenTtlSeconds: config.session.refreshTokenTtlSeconds,
sessions,
logger,
now: clock
}),
[TOKENS.SigningKey, TOKENS.SessionRepository, TOKENS.Clock]
)
.inSingletonScope();
container
.bind(TOKENS.WriterFreshness)
.toResolvedValue(
(database: Db, clock: () => Date) => new WriterFreshness(database, clock),
[TOKENS.Database, TOKENS.Clock]
)
.inSingletonScope();
container
.bind(TOKENS.RoomsService)
.toResolvedValue(
(database: Db, clock: () => Date, freshness: WriterFreshness) =>
new RoomsService(database, {
totalMaxRangeMs: config.lists.totalMaxRangeMs,
freshness,
now: clock
}),
[TOKENS.Database, TOKENS.Clock, TOKENS.WriterFreshness]
)
.inSingletonScope();
container
.bind(TOKENS.ParticipantsService)
.toResolvedValue(
(database: Db, clock: () => Date, freshness: WriterFreshness) =>
new ParticipantsService(database, {
totalMaxRangeMs: config.lists.totalMaxRangeMs,
freshness,
now: clock
}),
[TOKENS.Database, TOKENS.Clock, TOKENS.WriterFreshness]
)
.inSingletonScope();
container
.bind(TOKENS.EgressService)
.toResolvedValue(
(database: Db, clock: () => Date) =>
new EgressService(database, { totalMaxRangeMs: config.lists.totalMaxRangeMs, now: clock }),
[TOKENS.Database, TOKENS.Clock]
)
.inSingletonScope();
container
.bind(TOKENS.IngressService)
.toResolvedValue(
(database: Db, clock: () => Date) =>
new IngressService(database, { totalMaxRangeMs: config.lists.totalMaxRangeMs, now: clock }),
[TOKENS.Database, TOKENS.Clock]
)
.inSingletonScope();
container
.bind(TOKENS.AnalyticsFeatureGate)
.toResolvedValue(() => new AnalyticsFeatureGate(() => container.get<FeatureKey[]>(TOKENS.Features)))
.inSingletonScope();
container
.bind(TOKENS.AnalyticsService)
.toResolvedValue(
(database: Db, clock: () => Date, gate: AnalyticsFeatureGate, catalogue: AnalyticsCatalogue) =>
new AnalyticsService(database, {
now: clock,
gate,
catalogue,
cache: new AnalyticsCache(config.analytics.cacheTtlSeconds * 1000, clock)
}),
[TOKENS.Database, TOKENS.Clock, TOKENS.AnalyticsFeatureGate, TOKENS.AnalyticsCatalogue]
)
.inSingletonScope();
container.bind<OverviewExtension>(TOKENS.OverviewExtension).toConstantValue(() => ({}));
container
.bind(TOKENS.OverviewService)
.toResolvedValue(
(database: Db, clock: () => Date, extension: OverviewExtension) =>
new OverviewService(database, { now: clock, extension }),
[TOKENS.Database, TOKENS.Clock, TOKENS.OverviewExtension]
)
.inSingletonScope();
container
.bind(TOKENS.StatusService)
.toResolvedValue(
(database: Db, clock: () => Date, info: InfoService) => new StatusService(database, { info, now: clock }),
[TOKENS.Database, TOKENS.Clock, TOKENS.InfoService]
)
.inSingletonScope();
logger.info(
config.analytics.cacheTtlSeconds > 0
? `Analytics cache: completed buckets kept for ${String(config.analytics.cacheTtlSeconds)} s`
: 'Analytics cache disabled (DASHBOARD_ANALYTICS_CACHE_TTL=0)'
);
const registry = container.get<RouteRegistry>(TOKENS.RouteRegistry);
const now = () => container.get<() => Date>(TOKENS.Clock)();
registerProbes(registry, options.version, db);
registerAuthRoutes(
registry,
() => container.get<AuthService>(TOKENS.AuthService),
() => ({
basePath: container.get<string>(TOKENS.BasePath),
ttlSeconds: config.session.refreshTokenTtlSeconds,
secure: config.session.cookieSecure
})
);
registerInfoRoute(registry, () => container.get<InfoService>(TOKENS.InfoService));
registerRoomsRoutes(registry, () => container.get<RoomsService>(TOKENS.RoomsService), now);
registerParticipantsRoutes(registry, () => container.get<ParticipantsService>(TOKENS.ParticipantsService), now);
registerTracksRoutes(registry);
registerEgressRoutes(registry, () => container.get<EgressService>(TOKENS.EgressService), now);
registerIngressRoutes(registry, () => container.get<IngressService>(TOKENS.IngressService), now);
registerAnalyticsRoutes(registry, () => container.get<AnalyticsService>(TOKENS.AnalyticsService), now);
registerOverviewRoutes(registry, () => container.get<OverviewService>(TOKENS.OverviewService));
registerStatusRoutes(registry, () => container.get<StatusService>(TOKENS.StatusService));
return container;
}