feat: add env vars for keepAliveTimeout and headersTimeout (#15125)
authorAndrew Dailey <andrew@shallowbrooksoftware.com>
Fri, 9 Jan 2026 20:34:14 +0000 (14:34 -0600)
committerGitHub <noreply@github.com>
Fri, 9 Jan 2026 20:34:14 +0000 (13:34 -0700)
* Check for and apply timeouts for keepAlive and headers

* Update docs with new timeout env vars

* Add changeset for new timeouts

* Run the formatter

* Tweak the docs wording a bit

* Check for empty vars before parsing and validating

* Update documentation/docs/25-build-and-deploy/40-adapter-node.md

Co-authored-by: Tee Ming <chewteeming01@gmail.com>
* Add a timeout_env helper with corresponding tests

* fix: convert adapter-node tests to TypeScript for project service compatibility

The tsconfig include pattern with brace expansion {js,ts} was not being
recognized by TypeScript's project service used by ESLint. Split into
separate patterns and converted test files to TypeScript.

* format

---------

Co-authored-by: Tee Ming <chewteeming01@gmail.com>
Co-authored-by: Elliott Johnson <hello@ell.iott.dev>
.changeset/odd-lamps-follow.md [new file with mode: 0644]
documentation/docs/25-build-and-deploy/40-adapter-node.md
packages/adapter-node/internal.d.ts
packages/adapter-node/src/env.js
packages/adapter-node/src/index.js
packages/adapter-node/tests/env.spec.ts [new file with mode: 0644]
packages/adapter-node/tests/utils.spec.js [deleted file]
packages/adapter-node/tests/utils.spec.ts [new file with mode: 0644]
packages/adapter-node/tsconfig.json

diff --git a/.changeset/odd-lamps-follow.md b/.changeset/odd-lamps-follow.md
new file mode 100644 (file)
index 0000000..eb6a14c
--- /dev/null
@@ -0,0 +1,5 @@
+---
+'@sveltejs/adapter-node': minor
+---
+
+feat: add env vars for keepAliveTimeout and headersTimeout
index 36b7c01b949d5080683f46913ad0de5038ccfa41..3473969047903f20ade84b8978c44e607b6c22fb 100644 (file)
@@ -140,6 +140,10 @@ The number of seconds to wait before forcefully closing any remaining connection
 
 When using systemd socket activation, `IDLE_TIMEOUT` specifies the number of seconds after which the app is automatically put to sleep when receiving no requests. If not set, the app runs continuously. See [Socket activation](#Socket-activation) for more details.
 
+### `KEEP_ALIVE_TIMEOUT` and `HEADERS_TIMEOUT`
+
+The number of seconds for [`keepAliveTimeout`](https://nodejs.org/api/http.html#serverkeepalivetimeout) and [`headersTimeout`](https://nodejs.org/api/http.html#serverheaderstimeout).
+
 ## Options
 
 The adapter can be configured with various options:
index fed0584d1851b1384838ca6e6c919dfa35959cdb..8387855f78937d46bf0a4ec2baca8048fe1043df 100644 (file)
@@ -1,5 +1,6 @@
 declare module 'ENV' {
        export function env(key: string, fallback?: any): string;
+       export function timeout_env(key: string, fallback?: any): number | undefined;
 }
 
 declare module 'HANDLER' {
index b8e44ee0c7f5797673cd1106e9b23814431f5e7c..05251f8658b28154caf0b0893f21b28842393111 100644 (file)
@@ -13,7 +13,9 @@ const expected = new Set([
        'PORT_HEADER',
        'BODY_SIZE_LIMIT',
        'SHUTDOWN_TIMEOUT',
-       'IDLE_TIMEOUT'
+       'IDLE_TIMEOUT',
+       'KEEP_ALIVE_TIMEOUT',
+       'HEADERS_TIMEOUT'
 ]);
 
 const expected_unprefixed = new Set(['LISTEN_PID', 'LISTEN_FDS']);
@@ -40,3 +42,50 @@ export function env(name, fallback) {
        const prefixed = prefix + name;
        return prefixed in process.env ? process.env[prefixed] : fallback;
 }
+
+const integer_regexp = /^\d+$/;
+
+/**
+ * Throw a consistently-structured parsing error for environment variables.
+ * @param {string} name
+ * @param {any} value
+ * @param {string} description
+ * @returns {never}
+ */
+function parsing_error(name, value, description) {
+       throw new Error(
+               `Invalid value for environment variable ${name}: ${JSON.stringify(value)} (${description})`
+       );
+}
+
+/**
+ * Check the environment for a timeout value (non-negative integer) in seconds.
+ * @param {string} name
+ * @param {number} [fallback]
+ * @returns {number | undefined}
+ */
+export function timeout_env(name, fallback) {
+       const raw = env(name, fallback);
+       if (!raw) {
+               return fallback;
+       }
+
+       if (!integer_regexp.test(raw)) {
+               parsing_error(name, raw, 'should be a non-negative integer');
+       }
+
+       const parsed = Number.parseInt(raw, 10);
+
+       // We don't technically need to check `Number.isNaN` because the value already passed the regexp test.
+       // However, just in case there's some new codepath introduced somewhere down the line, it's probably good
+       // to stick this in here.
+       if (Number.isNaN(parsed)) {
+               parsing_error(name, raw, 'should be a non-negative integer');
+       }
+
+       if (parsed < 0) {
+               parsing_error(name, raw, 'should be a non-negative integer');
+       }
+
+       return parsed;
+}
index ef1ab701a2a396802c20babe175773ba5ff0f385..a7a5c96b51849816195c552f924636b6342e9c93 100644 (file)
@@ -1,6 +1,7 @@
+import http from 'node:http';
 import process from 'node:process';
 import { handler } from 'HANDLER';
-import { env } from 'ENV';
+import { env, timeout_env } from 'ENV';
 import polka from 'polka';
 
 export const path = env('SOCKET_PATH', false);
@@ -31,7 +32,24 @@ let shutdown_timeout_id;
 /** @type {NodeJS.Timeout | void} */
 let idle_timeout_id;
 
-const server = polka().use(handler);
+// Initialize the HTTP server here so that we can set properties before starting to listen.
+// Otherwise, polka delays creating the server until listen() is called. Settings these
+// properties after the server has started listening could lead to race conditions.
+const httpServer = http.createServer();
+
+const keep_alive_timeout = timeout_env('KEEP_ALIVE_TIMEOUT');
+if (keep_alive_timeout !== undefined) {
+       // Convert the keep-alive timeout from seconds to milliseconds (the unit Node.js expects).
+       httpServer.keepAliveTimeout = keep_alive_timeout * 1000;
+}
+
+const headers_timeout = timeout_env('HEADERS_TIMEOUT');
+if (headers_timeout !== undefined) {
+       // Convert the headers timeout from seconds to milliseconds (the unit Node.js expects).
+       httpServer.headersTimeout = headers_timeout * 1000;
+}
+
+const server = polka({ server: httpServer }).use(handler);
 
 if (socket_activation) {
        server.listen({ fd: SD_LISTEN_FDS_START }, () => {
@@ -49,10 +67,9 @@ function graceful_shutdown(reason) {
 
        // If a connection was opened with a keep-alive header close() will wait for the connection to
        // time out rather than close it even if it is not handling any requests, so call this first
-       // @ts-expect-error this was added in 18.2.0 but is not reflected in the types
-       server.server.closeIdleConnections();
+       httpServer.closeIdleConnections();
 
-       server.server.close((error) => {
+       httpServer.close((error) => {
                // occurs if the server is already closed
                if (error) return;
 
@@ -67,14 +84,10 @@ function graceful_shutdown(reason) {
                process.emit('sveltekit:shutdown', reason);
        });
 
-       shutdown_timeout_id = setTimeout(
-               // @ts-expect-error this was added in 18.2.0 but is not reflected in the types
-               () => server.server.closeAllConnections(),
-               shutdown_timeout * 1000
-       );
+       shutdown_timeout_id = setTimeout(() => httpServer.closeAllConnections(), shutdown_timeout * 1000);
 }
 
-server.server.on(
+httpServer.on(
        'request',
        /** @param {import('node:http').IncomingMessage} req */
        (req) => {
@@ -89,8 +102,7 @@ server.server.on(
 
                        if (shutdown_timeout_id) {
                                // close connections as soon as they become idle, so they don't accept new requests
-                               // @ts-expect-error this was added in 18.2.0 but is not reflected in the types
-                               server.server.closeIdleConnections();
+                               httpServer.closeIdleConnections();
                        }
                        if (requests === 0 && socket_activation && idle_timeout) {
                                idle_timeout_id = setTimeout(() => graceful_shutdown('IDLE'), idle_timeout * 1000);
diff --git a/packages/adapter-node/tests/env.spec.ts b/packages/adapter-node/tests/env.spec.ts
new file mode 100644 (file)
index 0000000..20fc23a
--- /dev/null
@@ -0,0 +1,52 @@
+import { afterEach, expect, test, describe, vi } from 'vitest';
+import { timeout_env } from '../src/env.js';
+
+vi.hoisted(() => {
+       vi.stubGlobal('ENV_PREFIX', '');
+});
+
+describe('timeout_env', () => {
+       afterEach(() => {
+               vi.unstubAllEnvs();
+       });
+
+       test('parses zero correctly', () => {
+               vi.stubEnv('TIMEOUT', '0');
+
+               const timeout = timeout_env('TIMEOUT');
+               expect(timeout).toBe(0);
+       });
+
+       test('parses positive integers correctly', () => {
+               vi.stubEnv('TIMEOUT', '60');
+
+               const timeout = timeout_env('TIMEOUT');
+               expect(timeout).toBe(60);
+       });
+
+       test('returns the fallback when variable is not set', () => {
+               const timeout = timeout_env('TIMEOUT', 30);
+               expect(timeout).toBe(30);
+       });
+
+       test('returns undefined when variable is not set and no fallback is provided', () => {
+               const timeout = timeout_env('TIMEOUT');
+               expect(timeout).toBeUndefined();
+       });
+
+       test('throws an error for negative integers', () => {
+               vi.stubEnv('TIMEOUT', '-10');
+
+               expect(() => timeout_env('TIMEOUT')).toThrow(
+                       'Invalid value for environment variable TIMEOUT: "-10" (should be a non-negative integer)'
+               );
+       });
+
+       test('throws an error for non-integer values', () => {
+               vi.stubEnv('TIMEOUT', 'abc');
+
+               expect(() => timeout_env('TIMEOUT')).toThrow(
+                       'Invalid value for environment variable TIMEOUT: "abc" (should be a non-negative integer)'
+               );
+       });
+});
diff --git a/packages/adapter-node/tests/utils.spec.js b/packages/adapter-node/tests/utils.spec.js
deleted file mode 100644 (file)
index 06a495f..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-import { expect, test, describe } from 'vitest';
-import { parse_as_bytes } from '../utils.js';
-
-describe('parse_as_bytes', () => {
-       test('parses correctly', () => {
-               const testData = {
-                       200: 200,
-                       '512K': 512 * 1024,
-                       '200M': 200 * 1024 * 1024,
-                       '1G': 1024 * 1024 * 1024,
-                       Infinity,
-                       asdf: NaN
-               };
-
-               Object.keys(testData).forEach((input) => {
-                       const expected = testData[/** @type {keyof typeof testData} */ (input)];
-                       const actual = parse_as_bytes(input);
-                       expect(actual, `Testing input '${input}'`).toBe(expected);
-               });
-       });
-});
diff --git a/packages/adapter-node/tests/utils.spec.ts b/packages/adapter-node/tests/utils.spec.ts
new file mode 100644 (file)
index 0000000..75d0f27
--- /dev/null
@@ -0,0 +1,16 @@
+import { expect, test, describe } from 'vitest';
+import { parse_as_bytes } from '../utils.js';
+
+describe('parse_as_bytes', () => {
+       test.each([
+               ['200', 200],
+               ['512K', 512 * 1024],
+               ['200M', 200 * 1024 * 1024],
+               ['1G', 1024 * 1024 * 1024],
+               ['Infinity', Infinity],
+               ['asdf', NaN]
+       ] as const)('parses correctly', (input, expected) => {
+               const actual = parse_as_bytes(input);
+               expect(actual, `Testing input '${input}'`).toBe(expected);
+       });
+});
index 8d5143329387172fa1be813264620a039ff71fff..4234750243cc0a708ba9b0d67efc6fc4f08eb525 100644 (file)
                        "@sveltejs/kit": ["../kit/types/index"]
                }
        },
-       "include": ["index.js", "src/**/*.js", "tests/**/*.js", "internal.d.ts", "utils.js"],
+       "include": [
+               "index.js",
+               "src/**/*.js",
+               "tests/**/*.js",
+               "tests/**/*.ts",
+               "internal.d.ts",
+               "utils.js"
+       ],
        "exclude": ["tests/smoke.spec_disabled.js"]
 }