Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cd7850a
feat: implement NITRO_UNIX_SOCKET and graceful shutdown env vars
wadefletch Feb 9, 2026
4218d87
docs: add env var documentation to bun and deno runtime pages
wadefletch Feb 9, 2026
3482064
docs(node): fix handler preset name and export
wadefletch Feb 10, 2026
853f67f
fix: call runtime `close` hook on shutdown signals
wadefletch Feb 10, 2026
ea4235a
docs: clarify close hook availability across preset types
wadefletch Feb 10, 2026
63e2ebd
feat: combine graceful shutdown and env var changes
wadefletch Feb 10, 2026
77f90e4
revert: remove unrelated node middleware handler docs change
wadefletch Feb 10, 2026
70627b2
fix: make shutdown hook handler async with error handling
wadefletch Feb 10, 2026
6261cd6
refactor: extract resolveGracefulShutdownConfig helper
wadefletch Feb 10, 2026
8e1f996
chore: apply automated updates
autofix-ci[bot] Feb 10, 2026
5ebc5f9
refactor: clean up shutdown module and format preset entries
wadefletch Feb 10, 2026
ad08c19
chore: apply automated updates
autofix-ci[bot] Feb 10, 2026
b25ed2f
chore: apply project formatting and fix lint warning
wadefletch Feb 10, 2026
8769db6
fix: guard process.on behind runtime check, add await regression test
wadefletch Feb 10, 2026
2127dc3
fix: use strict === "true" check for NITRO_SHUTDOWN_DISABLED
wadefletch Feb 10, 2026
4b68824
chore: apply automated updates
autofix-ci[bot] Feb 10, 2026
8fc90dd
test: combine NITRO_SHUTDOWN_DISABLED tests into it.each
wadefletch Feb 10, 2026
12a6cac
fix: add idempotency guard to shutdown handler
wadefletch Feb 10, 2026
9c5aee1
refactor: simplify setupShutdownHooks to match trapUnhandledErrors
wadefletch Feb 10, 2026
6330afe
refactor: clean up shutdown tests with it.each and signal helpers
wadefletch Feb 10, 2026
0a7dc65
Merge branch 'main' into wade/graceful-shutdown
wadefletch Feb 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions docs/1.docs/50.plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
8 changes: 3 additions & 5 deletions docs/2.deploy/10.runtimes/1.node.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions docs/2.deploy/10.runtimes/bun.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,14 @@ bun run ./.output/server/index.mjs
```

:read-more{to="https://bun.sh"}

### Environment Variables
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

Heading level skips from h1 to h3.

There's no h2 between # Bun (line 5) and ### Environment Variables (line 21). This should be ## to comply with Markdown heading hierarchy.

πŸ“ Suggested fix
-### Environment Variables
+## Environment Variables
πŸ€– Prompt for AI Agents
In `@docs/2.deploy/10.runtimes/bun.md` at line 21, The "### Environment Variables"
heading is one level too deep relative to the top-level "# Bun" header; change
the "### Environment Variables" heading text to "## Environment Variables" so
the document uses an h2 under "# Bun" and preserves Markdown heading hierarchy
(locate the string "### Environment Variables" in the file and replace it with
"## 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.
10 changes: 10 additions & 0 deletions docs/2.deploy/10.runtimes/deno.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

Same heading-level skip as bun.md β€” should be ## not ###.

The file uses # Deno (h1) at line 5 and jumps to ### Environment Variables (h3) at line 21, skipping h2.

πŸ“ Suggested fix
-### Environment Variables
+## Environment Variables
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Environment Variables
## Environment Variables
🧰 Tools
πŸͺ› markdownlint-cli2 (0.20.0)

[warning] 21-21: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3

(MD001, heading-increment)

πŸ€– Prompt for AI Agents
In `@docs/2.deploy/10.runtimes/deno.md` at line 21, The "Environment Variables"
heading currently uses h3 (###) but should be h2 (##) to follow the document's
heading hierarchy under the top-level "# Deno" heading; update the "Environment
Variables" heading to use "## Environment Variables" so it directly follows "#
Deno" and restores correct heading-level progression.


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"}
7 changes: 6 additions & 1 deletion src/presets/bun/runtime/bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ 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 ?? "");
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();

Expand All @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion src/presets/deno/runtime/deno-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "");
Expand All @@ -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();

Expand All @@ -35,9 +36,11 @@ serve({
hostname: host,
tls: cert && key ? { cert, key } : undefined,
fetch: _fetch,
gracefulShutdown,
});

trapUnhandledErrors();
setupShutdownHooks();

// Scheduled tasks
if (import.meta._tasks) {
Expand Down
2 changes: 2 additions & 0 deletions src/presets/node/runtime/node-cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "");
Expand Down Expand Up @@ -46,6 +47,7 @@ if (import.meta._websocket) {
}

trapUnhandledErrors();
setupShutdownHooks();

// Scheduled tasks
if (import.meta._tasks) {
Expand Down
7 changes: 6 additions & 1 deletion src/presets/node/runtime/node-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "");
Expand All @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could introduce this also in srvx directly via an env without NITRO_.

const gracefulShutdown = resolveGracefulShutdownConfig();

const nitroApp = useNitroApp();

Expand All @@ -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) {
Expand All @@ -38,6 +42,7 @@ if (import.meta._websocket) {
}

trapUnhandledErrors();
setupShutdownHooks();

// Scheduled tasks
if (import.meta._tasks) {
Expand Down
26 changes: 26 additions & 0 deletions src/runtime/internal/shutdown.ts
Original file line number Diff line number Diff line change
@@ -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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is best that we don't introduce new NITRO_ env vars where srvx already supports SERVER_SHUTDOWN_TIMEOUT


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);
}
93 changes: 93 additions & 0 deletions test/unit/shutdown.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, ((...args: any) => 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();
});
});
Loading