diff --git a/docs/1.docs/50.plugins.md b/docs/1.docs/50.plugins.md index 4ae6c79cd4..113b8712d8 100644 --- a/docs/1.docs/50.plugins.md +++ b/docs/1.docs/50.plugins.md @@ -54,10 +54,11 @@ export default definePlugin((nitro) => { ### Available hooks -- `"close", () => {}` -- `"request", (event) => {}` -- `"error", (error, { event? }) => {}` -- `"response", (res, event) => {}` +- `"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 +78,32 @@ export default definePlugin((nitro) => { ### Graceful shutdown -Server will gracefully shutdown and wait for any background pending tasks initiated by event.waitUntil +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"; + +export default definePlugin((nitro) => { + nitro.hooks.hook("close", async () => { + await flushTelemetry(); + await db.close(); + }); +}) +``` + +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 diff --git a/docs/2.deploy/10.runtimes/1.node.md b/docs/2.deploy/10.runtimes/1.node.md index 475b4a0eae..4b18658b4e 100644 --- a/docs/2.deploy/10.runtimes/1.node.md +++ b/docs/2.deploy/10.runtimes/1.node.md @@ -31,12 +31,10 @@ You can customize server behavior using following environment variables: - `NITRO_PORT` or `PORT` (defaults to `3000`) - `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_UNIX_SOCKET` - if provided (a path to the desired socket file) the service will be served over the provided UNIX socket (Node.js and Bun only). - `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'`. +- `NITRO_SHUTDOWN_TIMEOUT` - Sets the amount of time (in milliseconds) before a forced shutdown occurs. Defaults to `5000` milliseconds. ## Cluster mode diff --git a/docs/2.deploy/10.runtimes/bun.md b/docs/2.deploy/10.runtimes/bun.md index e619cdadeb..b383f69ebc 100644 --- a/docs/2.deploy/10.runtimes/bun.md +++ b/docs/2.deploy/10.runtimes/bun.md @@ -17,3 +17,14 @@ bun run ./.output/server/index.mjs ``` :read-more{to="https://bun.sh"} + +### Environment Variables + +You can customize server behavior using following environment variables: + +- `NITRO_PORT` or `PORT` (defaults to `3000`) +- `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'`. Defaults to `'false'`. +- `NITRO_SHUTDOWN_TIMEOUT` - Sets the amount of time (in milliseconds) before a forced shutdown occurs. Defaults to `5000` milliseconds. diff --git a/docs/2.deploy/10.runtimes/deno.md b/docs/2.deploy/10.runtimes/deno.md index cc8a0cfdf5..0280506f8b 100644 --- a/docs/2.deploy/10.runtimes/deno.md +++ b/docs/2.deploy/10.runtimes/deno.md @@ -18,6 +18,16 @@ NITRO_PRESET=deno_server npm run build deno run --unstable --allow-net --allow-read --allow-env .output/server/index.ts ``` +### Environment Variables + +You can customize server behavior using following environment variables: + +- `NITRO_PORT` or `PORT` (defaults to `3000`) +- `NITRO_HOST` or `HOST` +- `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'`. Defaults to `'false'`. +- `NITRO_SHUTDOWN_TIMEOUT` - Sets the amount of time (in milliseconds) before a forced shutdown occurs. Defaults to `5000` milliseconds. + ## Deno Deploy :read-more{to="/deploy/providers/deno-deploy"} diff --git a/src/presets/bun/runtime/bun.ts b/src/presets/bun/runtime/bun.ts index 7407e9e625..3aad3a3c67 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 { resolveGracefulShutdownConfig, setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); @@ -13,7 +14,8 @@ const port = Number.isNaN(_parsedPort) ? 3000 : _parsedPort; const host = process.env.NITRO_HOST || process.env.HOST; const cert = process.env.NITRO_SSL_CERT; const key = process.env.NITRO_SSL_KEY; -// const socketPath = process.env.NITRO_UNIX_SOCKET; // TODO +const socketPath = process.env.NITRO_UNIX_SOCKET; +const gracefulShutdown = resolveGracefulShutdownConfig(); const nitroApp = useNitroApp(); @@ -35,12 +37,15 @@ serve({ hostname: host, tls: cert && key ? { cert, key } : undefined, fetch: _fetch, + gracefulShutdown, bun: { + unix: socketPath, websocket: import.meta._websocket ? ws?.websocket : undefined, }, }); 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..ce29f93657 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 { resolveGracefulShutdownConfig, setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); @@ -14,7 +15,7 @@ const port = Number.isNaN(_parsedPort) ? 3000 : _parsedPort; const host = process.env.NITRO_HOST || process.env.HOST; const cert = process.env.NITRO_SSL_CERT; const key = process.env.NITRO_SSL_KEY; -// const socketPath = process.env.NITRO_UNIX_SOCKET; // TODO +const gracefulShutdown = resolveGracefulShutdownConfig(); const nitroApp = useNitroApp(); @@ -35,9 +36,11 @@ serve({ hostname: host, tls: cert && key ? { cert, key } : undefined, fetch: _fetch, + gracefulShutdown, }); 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..e58ce06244 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 { resolveGracefulShutdownConfig, setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); @@ -13,7 +14,8 @@ const port = Number.isNaN(_parsedPort) ? 3000 : _parsedPort; const host = process.env.NITRO_HOST || process.env.HOST; const cert = process.env.NITRO_SSL_CERT; const key = process.env.NITRO_SSL_KEY; -// const socketPath = process.env.NITRO_UNIX_SOCKET; // TODO +const socketPath = process.env.NITRO_UNIX_SOCKET; +const gracefulShutdown = resolveGracefulShutdownConfig(); const nitroApp = useNitroApp(); @@ -22,6 +24,8 @@ const server = serve({ hostname: host, tls: cert && key ? { cert, key } : undefined, fetch: nitroApp.fetch, + gracefulShutdown, + node: socketPath ? { path: socketPath } : undefined, }); if (import.meta._websocket) { @@ -38,6 +42,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..cbbdc4854c --- /dev/null +++ b/src/runtime/internal/shutdown.ts @@ -0,0 +1,26 @@ +import { useNitroApp } from "../app.ts"; +import type { ServerOptions } from "srvx"; + +export function resolveGracefulShutdownConfig(): ServerOptions["gracefulShutdown"] { + if (process.env.NITRO_SHUTDOWN_DISABLED === "true") { + return false; + } + + const timeoutMs = Number.parseInt(process.env.NITRO_SHUTDOWN_TIMEOUT ?? "", 10); + + if (timeoutMs > 0) { + // srvx expects timeout in seconds + return { gracefulTimeout: timeoutMs / 1000 }; + } + + return undefined; +} + +function _onShutdownSignal() { + useNitroApp().hooks?.callHook("close"); +} + +export function setupShutdownHooks() { + process.on("SIGTERM", _onShutdownSignal); + process.on("SIGINT", _onShutdownSignal); +} diff --git a/test/unit/shutdown.test.ts b/test/unit/shutdown.test.ts new file mode 100644 index 0000000000..e42a8e5496 --- /dev/null +++ b/test/unit/shutdown.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const callHook = vi.fn().mockResolvedValue(undefined); + +vi.mock("../../src/runtime/internal/app.ts", () => ({ + useNitroApp: () => ({ + hooks: { callHook }, + }), +})); + +import { + resolveGracefulShutdownConfig, + setupShutdownHooks, +} from "../../src/runtime/internal/shutdown.ts"; +import type { ProcessEventMap } from "node:process"; + +describe("resolveGracefulShutdownConfig", () => { + const env = process.env; + + afterEach(() => { + process.env = env; + }); + + it("returns undefined by default", () => { + process.env = { ...env }; + delete process.env.NITRO_SHUTDOWN_DISABLED; + delete process.env.NITRO_SHUTDOWN_TIMEOUT; + expect(resolveGracefulShutdownConfig()).toBeUndefined(); + }); + + it.each([ + { value: "true", expected: false }, + { value: "false", expected: undefined }, + { value: "", expected: undefined }, + { value: "1", expected: undefined }, + { value: "yes", expected: undefined }, + ])("NITRO_SHUTDOWN_DISABLED=$value returns $expected", ({ value, expected }) => { + process.env = { ...env, NITRO_SHUTDOWN_DISABLED: value }; + delete process.env.NITRO_SHUTDOWN_TIMEOUT; + expect(resolveGracefulShutdownConfig()).toBe(expected); + }); + + it("returns gracefulTimeout in seconds from NITRO_SHUTDOWN_TIMEOUT ms", () => { + process.env = { ...env, NITRO_SHUTDOWN_TIMEOUT: "10000" }; + delete process.env.NITRO_SHUTDOWN_DISABLED; + expect(resolveGracefulShutdownConfig()).toEqual({ gracefulTimeout: 10 }); + }); + + it("disabled takes priority over timeout", () => { + process.env = { + ...env, + NITRO_SHUTDOWN_DISABLED: "true", + NITRO_SHUTDOWN_TIMEOUT: "10000", + }; + expect(resolveGracefulShutdownConfig()).toBe(false); + }); + + it("ignores non-numeric timeout", () => { + process.env = { ...env, NITRO_SHUTDOWN_TIMEOUT: "abc" }; + delete process.env.NITRO_SHUTDOWN_DISABLED; + expect(resolveGracefulShutdownConfig()).toBeUndefined(); + }); +}); + +describe("setupShutdownHooks", () => { + let signals: (keyof ProcessEventMap)[] = ["SIGTERM", "SIGINT"]; + let priors: Record void)[]> = Object.fromEntries( + signals.map((s) => [s, []]) + ); + + beforeEach(() => { + callHook.mockClear(); + + for (const signal of signals) { + priors[signal] = process.listeners(signal).slice(); + } + }); + + afterEach(() => { + for (const signal of signals) { + process.removeAllListeners(signal); + for (const fn of priors[signal]) { + process.on(signal, fn); + } + } + }); + + it.each(["SIGTERM", "SIGINT"])("calls close hook on %s", (signal) => { + setupShutdownHooks(); + process.emit(signal, true); + expect(callHook).toHaveBeenCalledOnce(); + }); +});