From 853f67f174822d0d10d1d07958a0c26deb3f7543 Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 19:14:41 -0600 Subject: [PATCH 1/2] fix: call runtime `close` hook on shutdown signals Bridge SIGTERM/SIGINT to `nitroApp.hooks.callHook("close")` so plugins can run async cleanup (flush telemetry, drain connections, stop queues) when the server shuts down. The close hook stopped firing after the srvx migration in #3705 removed the old `setupGracefulShutdown` machinery. srvx handles HTTP-level shutdown (connection draining) but never calls Nitro's application-level close hook. Adds `setupShutdownHooks()` utility following the same pattern as `trapUnhandledErrors()` and wires it into node-server, node-cluster, bun, and deno-server runtime entries. Resolves #4015 Resolves #2735 Resolves #2566 Co-authored-by: Cursor --- docs/1.docs/50.plugins.md | 20 ++++++++-- docs/2.deploy/10.runtimes/1.node.md | 5 +-- src/presets/bun/runtime/bun.ts | 2 + src/presets/deno/runtime/deno-server.ts | 2 + src/presets/node/runtime/node-cluster.ts | 2 + src/presets/node/runtime/node-server.ts | 2 + src/runtime/internal/shutdown.ts | 10 +++++ test/unit/shutdown.test.ts | 49 ++++++++++++++++++++++++ 8 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 src/runtime/internal/shutdown.ts create mode 100644 test/unit/shutdown.test.ts diff --git a/docs/1.docs/50.plugins.md b/docs/1.docs/50.plugins.md index 5b568167e4..135b6b1433 100644 --- a/docs/1.docs/50.plugins.md +++ b/docs/1.docs/50.plugins.md @@ -54,9 +54,10 @@ export default definePlugin((nitro) => { ### Available hooks -- `"request", (event) => {}` -- `"error", (error, { event? }) => {}` -- `"response", (event, { body }) => {}` +- `"request", (event) => {}` - Called when a request is received. +- `"error", (error, { event? }) => {}` - Called when an error is captured. +- `"response", (response, event) => {}` - Called when a response is sent. +- `"close", () => {}` - Called when the server receives a shutdown signal (`SIGTERM` or `SIGINT`). ## Examples @@ -76,7 +77,18 @@ export default definePlugin((nitro) => { ### Graceful shutdown -Server will gracefully shutdown and wait for any background pending tasks initiated by event.waitUntil +When the server receives a shutdown signal (`SIGTERM` or `SIGINT`), the `close` hook is called, allowing plugins to run async cleanup before the process exits. This is useful for flushing telemetry, draining database connections, stopping job queues, and other teardown tasks. + +```ts +import { definePlugin } from "nitro"; + +export default definePlugin((nitro) => { + nitro.hooks.hook("close", async () => { + await flushTelemetry(); + await db.close(); + }); +}) +``` ### Request and response lifecycle diff --git a/docs/2.deploy/10.runtimes/1.node.md b/docs/2.deploy/10.runtimes/1.node.md index 475b4a0eae..b9099a81e0 100644 --- a/docs/2.deploy/10.runtimes/1.node.md +++ b/docs/2.deploy/10.runtimes/1.node.md @@ -33,10 +33,7 @@ You can customize server behavior using following environment variables: - `NITRO_HOST` or `HOST` - `NITRO_UNIX_SOCKET` - if provided (a path to the desired socket file) the service will be served over the provided UNIX socket. - `NITRO_SSL_CERT` and `NITRO_SSL_KEY` - if both are present, this will launch the server in HTTPS mode. In the vast majority of cases, this should not be used other than for testing, and the Nitro server should be run behind a reverse proxy like nginx or Cloudflare which terminates SSL. -- `NITRO_SHUTDOWN_DISABLED` - Disables the graceful shutdown feature when set to `'true'`. If it's set to `'true'`, the graceful shutdown is bypassed to speed up the development process. Defaults to `'false'`. -- `NITRO_SHUTDOWN_SIGNALS` - Allows you to specify which signals should be handled. Each signal should be separated with a space. Defaults to `'SIGINT SIGTERM'`. -- `NITRO_SHUTDOWN_TIMEOUT` - Sets the amount of time (in milliseconds) before a forced shutdown occurs. Defaults to `'30000'` milliseconds. -- `NITRO_SHUTDOWN_FORCE` - When set to true, it triggers `process.exit()` at the end of the shutdown process. If it's set to `'false'`, the process will simply let the event loop clear. Defaults to `'true'`. +- `NITRO_SHUTDOWN_DISABLED` - Disables the graceful shutdown feature when set to `'true'`. Defaults to `'false'`. ## Cluster mode diff --git a/src/presets/bun/runtime/bun.ts b/src/presets/bun/runtime/bun.ts index 7407e9e625..bc90a4c210 100644 --- a/src/presets/bun/runtime/bun.ts +++ b/src/presets/bun/runtime/bun.ts @@ -6,6 +6,7 @@ import wsAdapter from "crossws/adapters/bun"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; +import { setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); @@ -41,6 +42,7 @@ serve({ }); trapUnhandledErrors(); +setupShutdownHooks(); // Scheduled tasks if (import.meta._tasks) { diff --git a/src/presets/deno/runtime/deno-server.ts b/src/presets/deno/runtime/deno-server.ts index 005af67e43..a06cda5b40 100644 --- a/src/presets/deno/runtime/deno-server.ts +++ b/src/presets/deno/runtime/deno-server.ts @@ -6,6 +6,7 @@ import wsAdapter from "crossws/adapters/deno"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; +import { setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); @@ -38,6 +39,7 @@ serve({ }); trapUnhandledErrors(); +setupShutdownHooks(); // Scheduled tasks if (import.meta._tasks) { diff --git a/src/presets/node/runtime/node-cluster.ts b/src/presets/node/runtime/node-cluster.ts index 0687360c44..f24a051268 100644 --- a/src/presets/node/runtime/node-cluster.ts +++ b/src/presets/node/runtime/node-cluster.ts @@ -6,6 +6,7 @@ import wsAdapter from "crossws/adapters/node"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; +import { setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); @@ -46,6 +47,7 @@ if (import.meta._websocket) { } trapUnhandledErrors(); +setupShutdownHooks(); // Scheduled tasks if (import.meta._tasks) { diff --git a/src/presets/node/runtime/node-server.ts b/src/presets/node/runtime/node-server.ts index 6356f4dc42..d19e9de2a5 100644 --- a/src/presets/node/runtime/node-server.ts +++ b/src/presets/node/runtime/node-server.ts @@ -5,6 +5,7 @@ import wsAdapter from "crossws/adapters/node"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; +import { setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); @@ -38,6 +39,7 @@ if (import.meta._websocket) { } trapUnhandledErrors(); +setupShutdownHooks(); // Scheduled tasks if (import.meta._tasks) { diff --git a/src/runtime/internal/shutdown.ts b/src/runtime/internal/shutdown.ts new file mode 100644 index 0000000000..81e37b7fe6 --- /dev/null +++ b/src/runtime/internal/shutdown.ts @@ -0,0 +1,10 @@ +import { useNitroApp } from "../app.ts"; + +export function setupShutdownHooks() { + const handler = () => { + useNitroApp().hooks?.callHook("close"); + }; + for (const sig of ["SIGTERM", "SIGINT"] as const) { + process.on(sig, handler); + } +} diff --git a/test/unit/shutdown.test.ts b/test/unit/shutdown.test.ts new file mode 100644 index 0000000000..aefbe4f6c4 --- /dev/null +++ b/test/unit/shutdown.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const callHook = vi.fn(); + +vi.mock("../../src/runtime/internal/app.ts", () => ({ + useNitroApp: () => ({ + hooks: { callHook }, + }), +})); + +import { setupShutdownHooks } from "../../src/runtime/internal/shutdown.ts"; + +describe("setupShutdownHooks", () => { + let savedSIGTERM: Function[]; + let savedSIGINT: Function[]; + + beforeEach(() => { + savedSIGTERM = process.listeners("SIGTERM").slice(); + savedSIGINT = process.listeners("SIGINT").slice(); + callHook.mockClear(); + }); + + afterEach(() => { + process.removeAllListeners("SIGTERM"); + process.removeAllListeners("SIGINT"); + for (const fn of savedSIGTERM) process.on("SIGTERM", fn as NodeJS.SignalsListener); + for (const fn of savedSIGINT) process.on("SIGINT", fn as NodeJS.SignalsListener); + }); + + it("registers SIGTERM and SIGINT handlers", () => { + const beforeTERM = process.listenerCount("SIGTERM"); + const beforeINT = process.listenerCount("SIGINT"); + setupShutdownHooks(); + expect(process.listenerCount("SIGTERM")).toBe(beforeTERM + 1); + expect(process.listenerCount("SIGINT")).toBe(beforeINT + 1); + }); + + it("calls close hook on SIGTERM", () => { + setupShutdownHooks(); + process.emit("SIGTERM", "SIGTERM"); + expect(callHook).toHaveBeenCalledWith("close"); + }); + + it("calls close hook on SIGINT", () => { + setupShutdownHooks(); + process.emit("SIGINT", "SIGINT"); + expect(callHook).toHaveBeenCalledWith("close"); + }); +}); From ea4235a3cb72531ee417e6fe7d7f534c727d5509 Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 19:26:02 -0600 Subject: [PATCH 2/2] docs: clarify close hook availability across preset types Note that the close hook only fires on long-running server presets (node, bun, deno) and not in serverless/edge environments. Guide plugin authors toward the response hook or waitUntil for per-request cleanup that works across all presets. Co-authored-by: Cursor --- docs/1.docs/50.plugins.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/1.docs/50.plugins.md b/docs/1.docs/50.plugins.md index 135b6b1433..e206c86aff 100644 --- a/docs/1.docs/50.plugins.md +++ b/docs/1.docs/50.plugins.md @@ -54,10 +54,10 @@ export default definePlugin((nitro) => { ### Available hooks -- `"request", (event) => {}` - Called when a request is received. -- `"error", (error, { event? }) => {}` - Called when an error is captured. -- `"response", (response, event) => {}` - Called when a response is sent. -- `"close", () => {}` - Called when the server receives a shutdown signal (`SIGTERM` or `SIGINT`). +- `"request", (event) => {}` - Called when a request is received. Available in all presets. +- `"error", (error, { event? }) => {}` - Called when an error is captured. Available in all presets. +- `"response", (response, event) => {}` - Called when a response is sent. Available in all presets. +- `"close", () => {}` - Called when the server receives a shutdown signal (`SIGTERM` or `SIGINT`). Only available in long-running server presets (Node.js, Bun, Deno). Not called in serverless or edge environments. ## Examples @@ -77,7 +77,7 @@ export default definePlugin((nitro) => { ### Graceful shutdown -When the server receives a shutdown signal (`SIGTERM` or `SIGINT`), the `close` hook is called, allowing plugins to run async cleanup before the process exits. This is useful for flushing telemetry, draining database connections, stopping job queues, and other teardown tasks. +On long-running server presets (`node-server`, `node-cluster`, `bun`, `deno-server`), the `close` hook fires when the process receives `SIGTERM` or `SIGINT`, allowing plugins to run async cleanup before exit. ```ts import { definePlugin } from "nitro"; @@ -90,6 +90,20 @@ export default definePlugin((nitro) => { }) ``` +Serverless and edge runtimes (Cloudflare Workers, AWS Lambda, Vercel, Netlify, Deno Deploy) do not have a shutdown signal–the platform terminates the execution context without notice. The `close` hook will not fire in these environments. + +For per-request cleanup that works across all presets, use the `"response"` hook or `request.waitUntil()` instead: + +```ts +import { definePlugin } from "nitro"; + +export default definePlugin((nitro) => { + nitro.hooks.hook("response", async (response, event) => { + await flushRequestTelemetry(event); + }); +}) +``` + ### Request and response lifecycle You can use plugins to register a hook that can run on request lifecycle: