167 lines
6.5 KiB
TypeScript
167 lines
6.5 KiB
TypeScript
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<typeof serve> | undefined;
|
|
private stopPromise: Promise<void> | undefined;
|
|
private readonly app: Hono<AppEnv>;
|
|
|
|
/**
|
|
* @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<AppEnv>();
|
|
}
|
|
|
|
/** Configure middleware, register adapters, and begin listening. */
|
|
async start(): Promise<void> {
|
|
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<void> {
|
|
// 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<void>((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<AppEnv> {
|
|
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,
|
|
);
|
|
},
|
|
});
|
|
}
|
|
}
|