Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/odd-lamps-follow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/adapter-node': minor
---

feat: add env vars for keepAliveTimeout and headersTimeout
4 changes: 4 additions & 0 deletions documentation/docs/25-build-and-deploy/40-adapter-node.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions packages/adapter-node/internal.d.ts
Original file line number Diff line number Diff line change
@@ -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' {
Expand Down
51 changes: 50 additions & 1 deletion packages/adapter-node/src/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand All @@ -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;
}
38 changes: 25 additions & 13 deletions packages/adapter-node/src/index.js
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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 }, () => {
Expand All @@ -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;

Expand All @@ -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) => {
Expand All @@ -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);
Expand Down
52 changes: 52 additions & 0 deletions packages/adapter-node/tests/env.spec.ts
Original file line number Diff line number Diff line change
@@ -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)'
);
});
});
21 changes: 0 additions & 21 deletions packages/adapter-node/tests/utils.spec.js

This file was deleted.

16 changes: 16 additions & 0 deletions packages/adapter-node/tests/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
9 changes: 8 additions & 1 deletion packages/adapter-node/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
"@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"]
}
Loading