import { Hono, type MiddlewareHandler } from 'hono'; import { serve } from '@hono/node-server'; import { cors } from 'hono/cors'; import { compress } from 'hono/compress'; import { bodyLimit } from 'hono/body-limit'; import type { Logger } from '../utils/logger.ts'; import type { Config } from './config.ts'; import type { TransportRouter, UpgradeTransportRouter, AppEnv } from './transport/transport-router.ts'; /** * Owns the Hono server and installs transport adapters around shared middleware. * * Application routes are deliberately absent from this layer. */ export class ServerHost { private readonly debug: Logger; private server: ReturnType | undefined; private stopPromise: Promise | undefined; private readonly app: Hono; /** * @param config - Server listen address, CORS, and related settings. * @param debug - Root logger extended with a server-host namespace. * @param transports - Protocol adapters registered onto the Hono app. */ constructor( private readonly config: Config, debug: Logger, private readonly transports: TransportRouter[], ) { this.debug = debug.extend('server-host'); this.app = new Hono(); } /** Configure middleware, register adapters, and begin listening. */ async start(): Promise { const { port, host, cors: corsConfig, maxRequestBodyBytes } = this.config.server; this.debug(`Starting on http://${host}:${port}`); const corsMiddleware = cors({ origin: corsConfig.origin ?? '*', allowMethods: corsConfig.methods ?? [ 'POST', 'OPTIONS' ], allowHeaders: corsConfig.allowedHeaders ?? [ 'Content-Type', 'Accept' ], }); this.app.use('*', corsMiddleware); // Reject oversized request bodies before any decoder or route reads them. // Hono checks Content-Length when it can and counts streamed chunks when it // cannot, covering both fixed-length and chunked HTTP requests. this.app.use('*', ServerHost.limitBodySizeMiddleware(maxRequestBodyBytes, this.debug)); const compression = compress(); // Compression can buffer event streams, so never apply it to requested SSE. this.app.use('*', async (c, next) => { if (c.req.header('accept')?.includes('text/event-stream')) { await next(); return; } return compression(c, next); }); this.app.get('/health', (c) => c.json({ status: 'ok' })); // Each transport adapter registers its own wire endpoints. for (const transport of this.transports) { await transport.register(this.app); } // The Node Hono server accepts one WebSocketServer instance per listener. const upgradeTransports = this.transports.filter((transport): transport is UpgradeTransportRouter => 'websocketServer' in transport); if (upgradeTransports.length > 1) { throw new Error('ServerHost supports only one WebSocket upgrade server'); } const [ upgradeTransport ] = upgradeTransports; this.server = serve({ fetch: this.app.fetch, port, hostname: host, ...(upgradeTransport ? { websocket: { server: upgradeTransport.websocketServer } } : {}), }); // Wait for the server to start listening or timeout after 10 seconds. await Promise.race([ // Wait for the server to start listening. new Promise((resolve) => { this.server?.once('listening', resolve); }), // Timeout after 10 seconds. new Promise((_resolve, reject) => { setTimeout(reject, 10000); }), ]); this.debug(`Started on http://${host}:${port}`); } /** Stop accepting traffic and close all transport-owned connections. */ async stop(): Promise { // If we are already shutting down, return the existing promise if (this.stopPromise) { return this.stopPromise; } // If the server hasn't been started yet or already shut down, return early const server = this.server; if (!server) { return; } // Create a promise that resolves when the server is closed this.debug('stopping server'); const closeServer = new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); // Close the transports const closeTransports = this.transports.map((transport) => Promise.resolve(transport.stop?.())); // Create a promise that resolves when the server and transports are closed this.stopPromise = Promise.all([ closeServer, ...closeTransports ]).then(() => { this.stopPromise = undefined; }); // Return the promise return this.stopPromise; } /** * Build the server-wide Hono request-body limit policy. * * Keeping this policy in ServerHost ensures it runs before every HTTP adapter * without making application routes or connection streams aware of HTTP. * Hono preserves an accepted streamed body for downstream middleware, allowing * the Extended JSON decoder to read it normally after validation. * * @param maxRequestBodyBytes - Maximum encoded HTTP request body size in bytes. * @param debug - Logger used to record rejected requests and configured limits. * @returns Hono middleware which returns the shared public 413 error contract. */ static limitBodySizeMiddleware(maxRequestBodyBytes: number, debug: Logger): MiddlewareHandler { return bodyLimit({ // Hono interprets maxSize as encoded request bytes, not decoded JSON size. maxSize: maxRequestBodyBytes, onError: (context) => { // Do not parse the rejected payload: Hono stopped consuming it at the cap. debug('request body exceeded configured %d byte limit: %s %s', maxRequestBodyBytes, context.req.method, context.req.path); // Limiting occurs before dispatch, so a normal HTTP 413 remains available. return context.json( { statusCode: 413, error: `Request body exceeds the ${maxRequestBodyBytes} byte limit`, }, 413, ); }, }); } }