UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

215 lines (180 loc) 6.11 kB
import { createServer, type IncomingMessage, type Server, type ServerResponse, } from "node:http"; import type { Socket } from "node:net"; import { $env, $hook, $inject, Alepha, type Static, z } from "alepha"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { ServerProvider } from "./ServerProvider.ts"; import { ServerRouterProvider } from "./ServerRouterProvider.ts"; const envSchema = z.object({ SERVER_PORT: z .integer() .meta({ min: 0, max: 65535 }) .describe("Set 0 to listen on a random port.") .default(3000), SERVER_HOST: z.text({ default: "localhost", description: "Set 0.0.0.0 to listen on all interfaces.", }), }); declare module "alepha" { interface Env extends Partial<Static<typeof envSchema>> {} } export class NodeHttpServerProvider extends ServerProvider { protected readonly alepha = $inject(Alepha); protected readonly dateTimeProvider = $inject(DateTimeProvider); protected readonly log = $logger(); protected readonly env = $env(envSchema); protected readonly router = $inject(ServerRouterProvider); /** * Track active connections for fast shutdown. */ protected readonly connections = new Set<Socket>(); /** * Get number of active connections. */ public getConnectionsCount(): number { return this.connections.size; } /** * Server options. */ public readonly options = { /** * Graceful shutdown timeout in ms. * After this, remaining connections are forcefully closed. * @default 30000 */ shutdownTimeout: 10000, }; public get hostname(): string { // sometimes hostname is called before .star(), so server may not be created yet (nor listening) if (this.server?.listening) { const address = this.server.address(); if (typeof address === "object" && address !== null) { return `http://${this.env.SERVER_HOST}:${address.port}`; } } return `http://${this.env.SERVER_HOST}:${this.env.SERVER_PORT}`; } // Pre-bound error handler to avoid function allocation per request protected readonly handleRequestError = (res: ServerResponse, err: Error) => { this.log.error("Error handling request", err); res.statusCode = 500; res.end("Internal Server Error"); }; public server!: Server; public readonly configure = $hook({ on: "configure", handler: async () => { this.server = this.createHttpServer(); }, }); public readonly start = $hook({ on: "start", handler: async () => { await this.listen(); }, }); protected requestListener = (req: IncomingMessage, res: ServerResponse) => { const promise = this.handleNodeRequest({ req, res }); promise.catch((err) => this.handleRequestError(res, err)); }; protected connectionListener = (socket: Socket) => { this.connections.add(socket); socket.on("close", () => this.connections.delete(socket)); }; protected createHttpServer(): Server { let server: Server; const existing = this.alepha.store.get("alepha.node.server"); if (this.alepha.isViteDev() && existing) { server = existing; server.removeAllListeners("request"); // --> server.removeAllListeners("connection"); } else { server = createServer({ // nov 25 - keep connections alive for better performance, cuz we http/1.1 by default keepAlive: this.alepha.isProduction(), }); } server.on("request", this.requestListener); // Track connections for fast shutdown server.on("connection", this.connectionListener); return server; } protected readonly stop = $hook({ on: "stop", handler: async () => { await this.close(); }, }); protected async listen() { if (this.alepha.store.get("alepha.node.server")) { return; } let port = this.env.SERVER_PORT; // for testing, use a random port if port is 3000 (default) if (this.alepha.isTest() && port === 3000) { port = 0; } await new Promise<void>((resolve, reject) => { this.server?.listen(port, this.env.SERVER_HOST, () => { this.log.info(`Server listening on ${this.hostname}/`); resolve(); }); this.server?.on("error", (err) => { reject(err); }); }); this.alepha.store.set("alepha.node.server", this.server); } protected async close() { if (this.alepha.isViteDev()) { this.server.removeListener("request", this.requestListener); this.server.removeListener("connection", this.connectionListener); return; } // Dev/Test: instant shutdown (destroy connections immediately) // Production: graceful shutdown (wait for requests to complete, then close) if (!this.alepha.isProduction()) { this.destroyAllConnections(); } // Stop accepting new connections const closePromise = new Promise<void>((resolve, reject) => { this.server?.close((err) => (err ? reject(err) : resolve())); }); if (this.alepha.isProduction() && this.connections.size > 0) { // In production, wait for connections with timeout const timeout = this.options.shutdownTimeout; // Set up timeout to force-close connections const timeoutId = setTimeout(() => { if (this.connections.size > 0) { this.log.warn( `Shutdown timeout (${timeout}ms) reached, forcing ${this.connections.size} connections to close`, ); // Destroy sockets - this triggers 'close' events which eventually resolves closePromise for (const socket of this.connections) { socket.destroy(); } } }, timeout); // Wait for server to fully close (all connections closed) await closePromise; clearTimeout(timeoutId); this.connections.clear(); } else { await closePromise; } this.log.info("Server closed"); } protected destroyAllConnections() { for (const socket of this.connections) { socket.destroy(); } this.connections.clear(); } }