alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
215 lines (180 loc) • 6.11 kB
text/typescript
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();
}
}