refactor(server): replace AppContext undefined sentinels with Feature<T>

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e5550-4435-7118-8393-cdcc97042178
This commit is contained in:
Aarnav Tale
2026-05-23 16:32:22 -04:00
parent fb4b0b1404
commit d4eee702e9
25 changed files with 253 additions and 124 deletions
+20 -4
View File
@@ -172,6 +172,13 @@ export interface StartOptions {
listener: RequestListener;
tls?: HttpsServerOptions;
logger?: Logger;
/**
* Optional async hook invoked on SIGINT/SIGTERM after the HTTP
* server stops accepting new connections but before the process
* exits. Use this to dispose long-lived resources (timers,
* subprocesses, DB handles).
*/
onShutdown?: () => Promise<void> | void;
}
/**
@@ -189,15 +196,24 @@ export function startHttpServer(opts: StartOptions): Server {
log.info("Listening on %s://%s:%s", proto, opts.host, opts.port);
});
const shutdown = (signal: string) => {
const shutdown = async (signal: string) => {
log.info("Received %s, shutting down...", signal);
server.close(() => process.exit(0));
server.close(async () => {
if (opts.onShutdown) {
try {
await opts.onShutdown();
} catch (err) {
log.error("Error during shutdown hook: %o", err);
}
}
process.exit(0);
});
// Force exit if connections don't drain in time.
setTimeout(() => process.exit(0), 5_000).unref();
};
process.once("SIGINT", () => shutdown("SIGINT"));
process.once("SIGTERM", () => shutdown("SIGTERM"));
process.once("SIGINT", () => void shutdown("SIGINT"));
process.once("SIGTERM", () => void shutdown("SIGTERM"));
return server;
}
+20 -3
View File
@@ -18,6 +18,11 @@ export interface DevServerOptions {
publicDir: string;
}
interface AppModule {
default: RequestListener;
dispose?: () => Promise<void> | void;
}
export function headplaneDevServer(options: DevServerOptions): Plugin {
return {
name: "headplane:dev-server",
@@ -30,6 +35,12 @@ export function headplaneDevServer(options: DevServerOptions): Plugin {
res.end("Server entry not loaded yet");
};
// Track the last-loaded module identity so we can call its
// `dispose` when Vite swaps in a new one on HMR. Without this
// the LiveStore and other long-lived services leak intervals
// and subprocesses on every reload.
let currentModule: AppModule | null = null;
const composed = composeListener({
basename: options.basename,
staticRoot: options.publicDir,
@@ -43,9 +54,15 @@ export function headplaneDevServer(options: DevServerOptions): Plugin {
return () => {
server.middlewares.use(async (req, res, next) => {
try {
const mod = (await server.ssrLoadModule(options.entry)) as {
default: RequestListener;
};
const mod = (await server.ssrLoadModule(options.entry)) as AppModule;
if (currentModule && currentModule !== mod) {
try {
await currentModule.dispose?.();
} catch (err) {
server.config.logger.warn(`[headplane:dev-server] dispose failed: ${String(err)}`);
}
}
currentModule = mod;
appListener = mod.default;
composed(req, res);
} catch (err) {