From e76867009a4fe4f0a81ac942716fca07bfd24ea8 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 25 Apr 2025 15:42:20 +0200 Subject: [PATCH 1/2] feat: Enable server-side instrumentation via `instrument.server.ts` file --- packages/adapter-node/index.js | 17 ++-- packages/adapter-node/rollup.config.js | 9 ++ packages/adapter-node/src/index.js | 110 +++---------------------- packages/adapter-node/src/start.js | 105 +++++++++++++++++++++++ packages/kit/src/exports/vite/index.js | 5 ++ playgrounds/basic/svelte.config.js | 4 +- 6 files changed, 144 insertions(+), 106 deletions(-) create mode 100644 packages/adapter-node/src/start.js diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 9b0b3158ab82..9f61929f7765 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { rollup } from 'rollup'; import { nodeResolve } from '@rollup/plugin-node-resolve'; @@ -48,14 +48,21 @@ export default function (opts = {}) { const pkg = JSON.parse(readFileSync('package.json', 'utf8')); + /** @type {Record} */ + const input = { + index: `${tmp}/index.js`, + manifest: `${tmp}/manifest.js` + }; + + if (existsSync(`${tmp}/instrument.server.js`)) { + input['instrument.server'] = `${tmp}/instrument.server.js`; + } + // we bundle the Vite output so that deployments only need // their production dependencies. Anything in devDependencies // will get included in the bundled code const bundle = await rollup({ - input: { - index: `${tmp}/index.js`, - manifest: `${tmp}/manifest.js` - }, + input, external: [ // dependencies could have deep exports, so we need a regex ...Object.keys(pkg.dependencies || {}).map((d) => new RegExp(`^${d}(\\/.*)?$`)) diff --git a/packages/adapter-node/rollup.config.js b/packages/adapter-node/rollup.config.js index fb9d113b5965..8b9b74ba7959 100644 --- a/packages/adapter-node/rollup.config.js +++ b/packages/adapter-node/rollup.config.js @@ -21,6 +21,15 @@ export default [ format: 'esm' }, plugins: [nodeResolve({ preferBuiltins: true }), commonjs(), json(), prefixBuiltinModules()], + external: ['./start.js'] + }, + { + input: 'src/start.js', + output: { + file: 'files/start.js', + format: 'esm' + }, + plugins: [nodeResolve({ preferBuiltins: true }), commonjs(), json(), prefixBuiltinModules()], external: ['ENV', 'HANDLER'] }, { diff --git a/packages/adapter-node/src/index.js b/packages/adapter-node/src/index.js index ef1ab701a2a3..1b1d34e5de46 100644 --- a/packages/adapter-node/src/index.js +++ b/packages/adapter-node/src/index.js @@ -1,105 +1,17 @@ -import process from 'node:process'; -import { handler } from 'HANDLER'; -import { env } from 'ENV'; -import polka from 'polka'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; -export const path = env('SOCKET_PATH', false); -export const host = env('HOST', '0.0.0.0'); -export const port = env('PORT', !path && '3000'); +const dirname = new URL('.', import.meta.url).pathname; -const shutdown_timeout = parseInt(env('SHUTDOWN_TIMEOUT', '30')); -const idle_timeout = parseInt(env('IDLE_TIMEOUT', '0')); -const listen_pid = parseInt(env('LISTEN_PID', '0')); -const listen_fds = parseInt(env('LISTEN_FDS', '0')); -// https://www.freedesktop.org/software/systemd/man/latest/sd_listen_fds.html -const SD_LISTEN_FDS_START = 3; +const instrumentFile = join(dirname, 'server', 'instrument.server.js'); -if (listen_pid !== 0 && listen_pid !== process.pid) { - throw new Error(`received LISTEN_PID ${listen_pid} but current process id is ${process.pid}`); -} -if (listen_fds > 1) { - throw new Error( - `only one socket is allowed for socket activation, but LISTEN_FDS was set to ${listen_fds}` - ); -} - -const socket_activation = listen_pid === process.pid && listen_fds === 1; - -let requests = 0; -/** @type {NodeJS.Timeout | void} */ -let shutdown_timeout_id; -/** @type {NodeJS.Timeout | void} */ -let idle_timeout_id; - -const server = polka().use(handler); - -if (socket_activation) { - server.listen({ fd: SD_LISTEN_FDS_START }, () => { - console.log(`Listening on file descriptor ${SD_LISTEN_FDS_START}`); - }); -} else { - server.listen({ path, host, port }, () => { - console.log(`Listening on ${path || `http://${host}:${port}`}`); - }); -} - -/** @param {'SIGINT' | 'SIGTERM' | 'IDLE'} reason */ -function graceful_shutdown(reason) { - if (shutdown_timeout_id) return; - - // 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(); - - server.server.close((error) => { - // occurs if the server is already closed - if (error) return; - - if (shutdown_timeout_id) { - clearTimeout(shutdown_timeout_id); - } - if (idle_timeout_id) { - clearTimeout(idle_timeout_id); +if (existsSync(instrumentFile)) { + import(instrumentFile).then((hooks) => { + if (hooks?.instrument && typeof hooks.instrument === 'function') { + hooks.instrument(); } - - // @ts-expect-error custom events cannot be typed - process.emit('sveltekit:shutdown', reason); + import('./start.js'); }); - - 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 - ); +} else { + import('c'); } - -server.server.on( - 'request', - /** @param {import('node:http').IncomingMessage} req */ - (req) => { - requests++; - - if (socket_activation && idle_timeout_id) { - idle_timeout_id = clearTimeout(idle_timeout_id); - } - - req.on('close', () => { - requests--; - - 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(); - } - if (requests === 0 && socket_activation && idle_timeout) { - idle_timeout_id = setTimeout(() => graceful_shutdown('IDLE'), idle_timeout * 1000); - } - }); - } -); - -process.on('SIGTERM', graceful_shutdown); -process.on('SIGINT', graceful_shutdown); - -export { server }; diff --git a/packages/adapter-node/src/start.js b/packages/adapter-node/src/start.js new file mode 100644 index 000000000000..ef1ab701a2a3 --- /dev/null +++ b/packages/adapter-node/src/start.js @@ -0,0 +1,105 @@ +import process from 'node:process'; +import { handler } from 'HANDLER'; +import { env } from 'ENV'; +import polka from 'polka'; + +export const path = env('SOCKET_PATH', false); +export const host = env('HOST', '0.0.0.0'); +export const port = env('PORT', !path && '3000'); + +const shutdown_timeout = parseInt(env('SHUTDOWN_TIMEOUT', '30')); +const idle_timeout = parseInt(env('IDLE_TIMEOUT', '0')); +const listen_pid = parseInt(env('LISTEN_PID', '0')); +const listen_fds = parseInt(env('LISTEN_FDS', '0')); +// https://www.freedesktop.org/software/systemd/man/latest/sd_listen_fds.html +const SD_LISTEN_FDS_START = 3; + +if (listen_pid !== 0 && listen_pid !== process.pid) { + throw new Error(`received LISTEN_PID ${listen_pid} but current process id is ${process.pid}`); +} +if (listen_fds > 1) { + throw new Error( + `only one socket is allowed for socket activation, but LISTEN_FDS was set to ${listen_fds}` + ); +} + +const socket_activation = listen_pid === process.pid && listen_fds === 1; + +let requests = 0; +/** @type {NodeJS.Timeout | void} */ +let shutdown_timeout_id; +/** @type {NodeJS.Timeout | void} */ +let idle_timeout_id; + +const server = polka().use(handler); + +if (socket_activation) { + server.listen({ fd: SD_LISTEN_FDS_START }, () => { + console.log(`Listening on file descriptor ${SD_LISTEN_FDS_START}`); + }); +} else { + server.listen({ path, host, port }, () => { + console.log(`Listening on ${path || `http://${host}:${port}`}`); + }); +} + +/** @param {'SIGINT' | 'SIGTERM' | 'IDLE'} reason */ +function graceful_shutdown(reason) { + if (shutdown_timeout_id) return; + + // 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(); + + server.server.close((error) => { + // occurs if the server is already closed + if (error) return; + + if (shutdown_timeout_id) { + clearTimeout(shutdown_timeout_id); + } + if (idle_timeout_id) { + clearTimeout(idle_timeout_id); + } + + // @ts-expect-error custom events cannot be typed + 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 + ); +} + +server.server.on( + 'request', + /** @param {import('node:http').IncomingMessage} req */ + (req) => { + requests++; + + if (socket_activation && idle_timeout_id) { + idle_timeout_id = clearTimeout(idle_timeout_id); + } + + req.on('close', () => { + requests--; + + 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(); + } + if (requests === 0 && socket_activation && idle_timeout) { + idle_timeout_id = setTimeout(() => graceful_shutdown('IDLE'), idle_timeout * 1000); + } + }); + } +); + +process.on('SIGTERM', graceful_shutdown); +process.on('SIGINT', graceful_shutdown); + +export { server }; diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index c2e445e865d2..319c477c4dc8 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -631,6 +631,11 @@ Tips: const name = posixify(path.join('entries/matchers', key)); input[name] = path.resolve(file); }); + + const instrument_server = resolve_entry('src/instrument.server'); + if (instrument_server) { + input['instrument.server'] = instrument_server; + } } else if (svelte_config.kit.output.bundleStrategy !== 'split') { input['bundle'] = `${runtime_directory}/client/bundle.js`; } else { diff --git a/playgrounds/basic/svelte.config.js b/playgrounds/basic/svelte.config.js index 301e785eb88c..e47d780f0b04 100644 --- a/playgrounds/basic/svelte.config.js +++ b/playgrounds/basic/svelte.config.js @@ -1,9 +1,9 @@ -import adapter from '@sveltejs/adapter-auto'; +import adapter from '@sveltejs/adapter-node'; /** @type {import('@sveltejs/kit').Config} */ const config = { kit: { - adapter: adapter() + adapter: adapter({}) } }; From 09f27746dfda72ad8436ec6c6d1a4c55b3d4764b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 25 Apr 2025 15:53:04 +0200 Subject: [PATCH 2/2] fix lint errors, better error handling in ada-node --- packages/adapter-node/src/index.js | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/adapter-node/src/index.js b/packages/adapter-node/src/index.js index 1b1d34e5de46..79f3ef7b937f 100644 --- a/packages/adapter-node/src/index.js +++ b/packages/adapter-node/src/index.js @@ -6,12 +6,19 @@ const dirname = new URL('.', import.meta.url).pathname; const instrumentFile = join(dirname, 'server', 'instrument.server.js'); if (existsSync(instrumentFile)) { - import(instrumentFile).then((hooks) => { - if (hooks?.instrument && typeof hooks.instrument === 'function') { - hooks.instrument(); - } - import('./start.js'); - }); + import(instrumentFile) + .catch((err) => { + console.error('Failed to import instrument.server.js', err); + }) + .finally(() => { + tryImportStart(); + }); } else { - import('c'); + tryImportStart(); +} + +function tryImportStart() { + import('./start.js').catch((err) => { + console.error('Failed to import server (start.js)', err); + }); }