import * as plugins from './plugins.js'; import { DocsWebsiteBuilder } from './classes.docswebsitebuilder.js'; import type { IDocsWebsiteBuildResult, IDocsWebsiteServerOptions, } from './interfaces.js'; const normalizePort = (valueArg: number | undefined): number => { const value = valueArg ?? 3000; if (!Number.isInteger(value) || value < 1 || value > 65535) { throw new Error(`Invalid website port: ${String(valueArg)}. Expected an integer from 1 to 65535.`); } return value; }; type TDocsWebsiteServerState = 'stopped' | 'starting' | 'running' | 'stopping'; type TTypedServerHandle = { start(): Promise; stop(): Promise }; export class DocsWebsiteServer { public readonly port: number; public readonly listenHostname: string; private readonly builder: DocsWebsiteBuilder; private typedServer?: TTypedServerHandle; private currentBuild?: IDocsWebsiteBuildResult; private lifecycleState: TDocsWebsiteServerState = 'stopped'; private startPromise?: Promise; private stopPromise?: Promise; constructor(optionsArg: IDocsWebsiteServerOptions) { this.port = normalizePort(optionsArg.port); this.listenHostname = optionsArg.listenHostname ?? '127.0.0.1'; if (typeof this.listenHostname !== 'string' || !this.listenHostname || /[\s/\x00]/.test(this.listenHostname)) { throw new Error('The website listenHostname must be a non-empty hostname or IP address.'); } this.builder = new DocsWebsiteBuilder(optionsArg.docsDir); } public get buildResult(): IDocsWebsiteBuildResult | undefined { return this.currentBuild; } public get running(): boolean { return this.lifecycleState === 'running'; } public async build(): Promise { this.currentBuild = await this.builder.build(); return this.currentBuild; } public async start(): Promise { if (this.lifecycleState !== 'stopped') { throw new Error(`The docs website server cannot start while it is ${this.lifecycleState}.`); } this.lifecycleState = 'starting'; const startPromise = this.startInternal(); this.startPromise = startPromise; void startPromise.finally(() => { if (this.startPromise === startPromise) this.startPromise = undefined; }).catch(() => undefined); return await startPromise; } private async startInternal(): Promise { let typedServer: TTypedServerHandle | undefined; try { const buildResult = await this.build(); const typedServerModule = await plugins.loadTypedServer(); typedServer = new typedServerModule.TypedServer({ port: this.port, listenHostname: this.listenHostname, cors: false, bundledContent: buildResult.bundledContent, spaFallback: true, compression: true, noCache: true, healthzEndpoint: '/healthz', }); await typedServer.start(); this.typedServer = typedServer; if (this.lifecycleState === 'starting') this.lifecycleState = 'running'; return buildResult; } catch (error) { if (this.typedServer === typedServer) this.typedServer = undefined; if (this.lifecycleState === 'starting') this.lifecycleState = 'stopped'; throw error; } } public stop(): Promise { if (this.lifecycleState === 'stopped') return Promise.resolve(); if (this.lifecycleState === 'stopping') return this.stopPromise ?? Promise.resolve(); this.lifecycleState = 'stopping'; const stopPromise = this.stopInternal(); this.stopPromise = stopPromise; void stopPromise.finally(() => { if (this.stopPromise === stopPromise) this.stopPromise = undefined; }).catch(() => undefined); return stopPromise; } private async stopInternal(): Promise { try { if (this.startPromise) { try { await this.startPromise; } catch { // Startup restores lifecycle state before rejecting. } } const typedServer = this.typedServer; if (typedServer) await typedServer.stop(); if (this.typedServer === typedServer) this.typedServer = undefined; this.lifecycleState = 'stopped'; } catch (error) { this.lifecycleState = this.typedServer ? 'running' : 'stopped'; throw error; } } }