Rename src to source
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
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,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user