diff --git a/src/presets/node/runtime/node-cluster.ts b/src/presets/node/runtime/node-cluster.ts index e9bc592c21..818a174c3f 100644 --- a/src/presets/node/runtime/node-cluster.ts +++ b/src/presets/node/runtime/node-cluster.ts @@ -3,7 +3,7 @@ import cluster from "node:cluster"; import { NodeRequest, serve } from "srvx/node"; import wsAdapter from "crossws/adapters/node"; -import { useNitroApp } from "nitro/app"; +import { useNitroApp, useNitroHooks } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; @@ -32,6 +32,22 @@ const server = serve({ fetch: nitroApp.fetch, }); +// Run `close` hooks on server shutdown (srvx closes the server on `SIGINT`/`SIGTERM`) +const closeServer = server.close.bind(server); +let closeHooksCalled = false; +server.close = async (closeActiveConnections?: boolean) => { + try { + await closeServer(closeActiveConnections); + } finally { + if (!closeHooksCalled) { + closeHooksCalled = true; + await useNitroHooks() + .callHook("close") + ?.catch((error) => console.error("[close]", error)); + } + } +}; + if (import.meta._websocket) { const { handleUpgrade } = wsAdapter({ resolve: resolveWebsocketHooks }); server.node!.server!.on("upgrade", (req, socket, head) => { diff --git a/src/presets/node/runtime/node-server.ts b/src/presets/node/runtime/node-server.ts index 1d513d65a4..06f9b0299f 100644 --- a/src/presets/node/runtime/node-server.ts +++ b/src/presets/node/runtime/node-server.ts @@ -2,7 +2,7 @@ import "#nitro/virtual/polyfills"; import { NodeRequest, serve } from "srvx/node"; import wsAdapter from "crossws/adapters/node"; -import { useNitroApp } from "nitro/app"; +import { useNitroApp, useNitroHooks } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; @@ -26,6 +26,22 @@ const server = serve({ plugins: [...tracingSrvxPlugins], }); +// Run `close` hooks on server shutdown (srvx closes the server on `SIGINT`/`SIGTERM`) +const closeServer = server.close.bind(server); +let closeHooksCalled = false; +server.close = async (closeActiveConnections?: boolean) => { + try { + await closeServer(closeActiveConnections); + } finally { + if (!closeHooksCalled) { + closeHooksCalled = true; + await useNitroHooks() + .callHook("close") + ?.catch((error) => console.error("[close]", error)); + } + } +}; + if (import.meta._websocket) { const { handleUpgrade } = wsAdapter({ resolve: resolveWebsocketHooks }); server.node!.server!.on("upgrade", (req, socket, head) => { diff --git a/test/fixture/server/plugins/close.ts b/test/fixture/server/plugins/close.ts new file mode 100644 index 0000000000..dccfd7176f --- /dev/null +++ b/test/fixture/server/plugins/close.ts @@ -0,0 +1,12 @@ +import { definePlugin } from "nitro"; + +export default definePlugin((nitroApp) => { + nitroApp.hooks.hook("close", async () => { + if (globalThis.process?.env?.NITRO_TEST_CLOSE_HOOK) { + // Deliberately async: the shutdown test asserts the marker is printed + // before the process exits, which only holds when `close` hooks are awaited + await new Promise((resolve) => setTimeout(resolve, 250)); + console.log("[fixture] close hook called"); + } + }); +}); diff --git a/test/presets/node.test.ts b/test/presets/node.test.ts index e6d7d68c5a..0e4f0b048c 100644 --- a/test/presets/node.test.ts +++ b/test/presets/node.test.ts @@ -1,6 +1,8 @@ import { existsSync } from "node:fs"; import { resolve } from "pathe"; -// import { isWindows } from "std-env"; +import { isWindows } from "std-env"; +import { execa } from "execa"; +import { getRandomPort, waitForPort } from "get-port-please"; import { describe, expect, it } from "vitest"; import { setupTest, startServer, testNitro } from "../tests.ts"; @@ -38,3 +40,56 @@ describe("nitro:preset:node-middleware", async () => { expect(existsSync(resolve(serverNodeModules, "@fixture/nitro-utils/extra.mjs"))).toBe(true); }); }); + +describe("nitro:preset:node-server", async () => { + const ctx = await setupTest("node-server"); + + it.skipIf(isWindows)( + "calls the `close` hook on shutdown", + async () => { + const port = await getRandomPort(); + const entryPath = resolve(ctx.outDir, "server/index.mjs"); + // srvx graceful shutdown is disabled when the CI/TEST env vars are set + const env: Record = { + ...process.env, + NITRO_PORT: String(port), + NITRO_HOST: "127.0.0.1", + NITRO_TEST_CLOSE_HOOK: "true", + }; + delete env.CI; + delete env.TEST; + const child = execa(process.execPath, [entryPath], { env, extendEnv: false, reject: false }); + + let output = ""; + child.stdout!.on("data", (data) => (output += data)); + child.stderr!.on("data", (data) => (output += data)); + + try { + await waitForPort(port, { delay: 1000, retries: 20, host: "127.0.0.1" }); + + child.kill("SIGTERM"); + // Wait for the process to actually close before cleanup, so SIGKILL + // cannot cut graceful shutdown short and the marker assertion below + // runs after closure. The fixture task scheduler can keep the event + // loop alive after the server closed, so a missing close event falls + // through after 10s. + await new Promise((resolve) => { + const done = () => { + clearTimeout(timeout); + child.nodeChildProcess.off("close", done); + resolve(); + }; + const timeout = setTimeout(done, 10_000); + child.nodeChildProcess.once("close", done); + }); + + expect(output).toContain("[fixture] close hook called"); + expect(output).not.toContain("unhandledRejection"); + } finally { + child.kill("SIGKILL"); + await child; + } + }, + 40_000 + ); +});