import * as plugins from './plugins.js'; import type { IChallengeAssessRequest, IChallengeProvider, IChallengeRenderRequest, IChallengeVerifyRequest, } from './interfaces.js'; export interface ISmartChallengeServerOptions { provider: IChallengeProvider; host?: string; port?: number; maxBodyBytes?: number; shutdownTimeoutMs?: number; } export class SmartChallengeServer { private server?: plugins.http.Server; private startPromise?: Promise; private endpointUrl?: string; private readonly host: string; private readonly port: number; private readonly maxBodyBytes: number; private readonly shutdownTimeoutMs: number; private readonly provider: IChallengeProvider; private readonly activeSockets = new Set(); constructor(optionsArg: ISmartChallengeServerOptions) { this.provider = optionsArg.provider; this.host = optionsArg.host || '127.0.0.1'; this.port = optionsArg.port ?? 0; this.maxBodyBytes = optionsArg.maxBodyBytes ?? 256 * 1024; this.shutdownTimeoutMs = optionsArg.shutdownTimeoutMs ?? 5_000; } public get url(): string { if (!this.endpointUrl) { throw new Error('SmartChallengeServer has not been started yet'); } return this.endpointUrl; } public async start(): Promise { if (this.server) { return; } if (this.startPromise) { return this.startPromise; } this.startPromise = this.startInternal(); try { await this.startPromise; } finally { this.startPromise = undefined; } } private async startInternal(): Promise { const server = plugins.http.createServer((req, res) => { this.handleRequest(req, res).catch((err) => { this.writeJson(res, 500, { error: (err as Error).message }); }); }); server.on('connection', (socket) => { this.activeSockets.add(socket); socket.once('close', () => this.activeSockets.delete(socket)); }); try { await new Promise((resolve, reject) => { const handleError = (err: Error) => reject(err); server.once('error', handleError); server.listen(this.port, this.host, () => { server.off('error', handleError); const address = server.address(); if (!address || typeof address === 'string') { reject(new Error('Unable to determine SmartChallengeServer address')); return; } this.endpointUrl = `http://${this.host}:${address.port}`; this.server = server; resolve(); }); }); } catch (err) { server.close(); this.endpointUrl = undefined; this.server = undefined; throw err; } } public async stop(): Promise { if (this.startPromise && !this.server) { await this.startPromise.catch(() => undefined); } const server = this.server; if (!server) { return; } this.server = undefined; this.endpointUrl = undefined; const forceCloseTimer = setTimeout(() => { for (const socket of this.activeSockets) { socket.destroy(); } }, this.shutdownTimeoutMs); forceCloseTimer.unref?.(); await new Promise((resolve, reject) => { server.close((err) => err ? reject(err) : resolve()); }).finally(() => { clearTimeout(forceCloseTimer); this.activeSockets.clear(); }); } private async handleRequest( req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse, ): Promise { const method = req.method || 'GET'; const path = new URL(req.url || '/', `http://${req.headers.host || this.host}`).pathname; if (method === 'GET' && path === '/manifest') { this.writeJson(res, 200, await this.provider.getManifest()); return; } if (method !== 'POST') { this.writeJson(res, 405, { error: 'Method not allowed' }); return; } const payload = await this.readJson(req); if (path === '/assess') { this.writeJson(res, 200, await this.provider.assess(payload as IChallengeAssessRequest)); return; } if (path === '/render') { this.writeJson(res, 200, await this.provider.render(payload as IChallengeRenderRequest)); return; } if (path === '/verify') { this.writeJson(res, 200, await this.provider.verify(payload as IChallengeVerifyRequest)); return; } this.writeJson(res, 404, { error: 'Not found' }); } private async readJson(req: plugins.http.IncomingMessage): Promise { const chunks: Buffer[] = []; let totalBytes = 0; for await (const chunk of req) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); totalBytes += buffer.length; if (totalBytes > this.maxBodyBytes) { req.destroy(); throw new Error('Request body exceeds maximum size'); } chunks.push(buffer); } const body = Buffer.concat(chunks).toString('utf8'); return body ? JSON.parse(body) as unknown : {}; } private writeJson( res: plugins.http.ServerResponse, statusCodeArg: number, payloadArg: unknown, ): void { const body = JSON.stringify(payloadArg); res.writeHead(statusCodeArg, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store', }); res.end(body); } }