From d0570915249fc4f4af4c1a0405dd427e97ed2b3f Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:35:03 -0400 Subject: [PATCH 01/23] configure the server runtime in one place, and replace the Server class with init and respond --- packages/kit/src/core/adapt/builder.js | 4 +- packages/kit/src/core/postbuild/analyse.js | 24 +- packages/kit/src/core/postbuild/fallback.js | 12 +- packages/kit/src/core/postbuild/prerender.js | 31 +-- packages/kit/src/core/sync/write_server.js | 36 +++ packages/kit/src/exports/vite/dev/index.js | 20 +- .../kit/src/exports/vite/preview/index.js | 19 +- packages/kit/src/runtime/server/index.js | 258 ++++++++---------- packages/kit/src/types/ambient-private.d.ts | 3 +- packages/kit/src/types/internal.d.ts | 23 +- packages/kit/test/apps/basics/vite.config.js | 2 +- 11 files changed, 218 insertions(+), 214 deletions(-) diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index 28973a91c79f..9d2d0a4d30f0 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -229,7 +229,7 @@ export function create_builder({ write( dest, dedent` - import { Server } from '${relative}/index.js'; + import { create_server } from '${relative}/index.js'; const manifest = ${generate_manifest({ build_data, prerendered: prerendered.paths, @@ -240,7 +240,7 @@ export function create_builder({ remotes, root: vite_config.root })}; - export const server = new Server(manifest); + export const server = create_server(manifest); ` ); }, diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index b28d8efe42de..abb8cfca60c1 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -42,21 +42,15 @@ async function analyse({ const server_root = join(config.outDir, 'output'); /** @type {import('types').ServerInternalModule} */ - const internal = await import(pathToFileURL(`${server_root}/server/internal.js`).href); - - // configure `import { building } from '$app/env'` — - // essential we do this before analysing the code - internal.set_building(); - - // set `read` and `manifest`, in case they're used in initialisation - internal.set_manifest(manifest); - internal.set_read_implementation((file) => createReadableStream(`${server_root}/server/${file}`)); - - // `set_env` lives in a separate module that imports the user's `src/env` config. We import it - // *after* `set_building()` so that `building`-dependent expressions resolve correctly - /** @type {typeof import('/env/config.js')} */ - const { set_env } = await import(pathToFileURL(`${server_root}/server/env.js`).href); - set_env(env); + const { configure } = await import(pathToFileURL(`${server_root}/server/internal.js`).href); + + // everything user modules may read at their top level, before any of them are analysed + await configure({ + building: true, + manifest, + env, + read: (file) => createReadableStream(`${server_root}/server/${file}`) + }); /** @type {import('types').ServerMetadata} */ const metadata = { diff --git a/packages/kit/src/core/postbuild/fallback.js b/packages/kit/src/core/postbuild/fallback.js index 335a72bfd049..e488710d97b6 100644 --- a/packages/kit/src/core/postbuild/fallback.js +++ b/packages/kit/src/core/postbuild/fallback.js @@ -19,20 +19,18 @@ async function generate_fallback({ manifest_path, env, out_dir, origin, assets } const server_root = join(out_dir, 'output'); /** @type {import('types').ServerInternalModule} */ - const { set_building } = await import(pathToFileURL(`${server_root}/server/internal.js`).href); + const { configure } = await import(pathToFileURL(`${server_root}/server/internal.js`).href); + await configure({ building: true }); /** @type {import('types').ServerModule} */ - const { Server } = await import(pathToFileURL(`${server_root}/server/index.js`).href); + const { init, respond } = await import(pathToFileURL(`${server_root}/server/index.js`).href); /** @type {import('types').SSRManifest} */ const manifest = (await import(pathToFileURL(manifest_path).href)).manifest; - set_building(); + await init({ manifest, env }); - const server = new Server(manifest); - await server.init({ env }); - - const response = await server.respond(new Request(origin + '/[fallback]'), { + const response = await respond(new Request(origin + '/[fallback]'), { getClientAddress: () => { throw new Error('Cannot read clientAddress during prerendering'); }, diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index 2d07b4df5e17..3a7c14ab9f6b 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -52,22 +52,15 @@ async function prerender({ const manifest = (await import(pathToFileURL(manifest_path).href)).manifest; /** @type {import('types').ServerInternalModule} */ - const { set_building, set_prerendering, set_manifest, set_read_implementation, format_response } = - await import(pathToFileURL(`${out}/server/internal.js`).href); - - // configure `import { building } from `$app/env` — - // essential we do this before analysing the code - set_building(); - set_prerendering(); + const { configure, format_response } = await import( + pathToFileURL(`${out}/server/internal.js`).href + ); - // `set_env` and `Server` live in modules that import the user's `src/env` config. We import them - // *after* `set_building()` so that `building`-dependent expressions resolve correctly - /** @type {typeof import('/env/config.js')} */ - const { set_env } = await import(pathToFileURL(`${out}/server/env.js`).href); - set_env(env); + // `building` and `prerendering` have to be set before the server module evaluates the user's env config + await configure({ building: true, prerendering: true, env }); /** @type {import('types').ServerModule} */ - const { Server } = await import(pathToFileURL(`${out}/server/index.js`).href); + const { init, respond } = await import(pathToFileURL(`${out}/server/index.js`).href); const throw_handled = () => { throw new Error('__handled__'); @@ -384,7 +377,7 @@ async function prerender({ const request = new Request(prerender_origin + encoded); - const response = await server.respond(request, { + const response = await respond(request, { getClientAddress() { throw new Error('Cannot read clientAddress during prerendering'); }, @@ -653,8 +646,8 @@ async function prerender({ // the user's remote function modules may reference `read` or the `manifest` at the top-level // so we need to set them before evaluating those modules to avoid potential runtime errors - set_manifest(manifest); - set_read_implementation((file) => createReadableStream(`${out}/server/${file}`)); + const read = (/** @type {string} */ file) => createReadableStream(`${out}/server/${file}`); + await configure({ manifest, read }); /** @type {Array} */ const prerender_functions = []; @@ -676,11 +669,7 @@ async function prerender({ // only run the server after the `should_prerender` check so that we // don't run the user's init hook unnecessarily - const server = new Server(manifest); - await server.init({ - env, - read: (file) => createReadableStream(`${config.outDir}/output/server/${file}`) - }); + await init({ manifest, env, read }); log.info('Prerendering'); diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index 57ff9e345cbd..31f8d6b2e4c7 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -26,6 +26,7 @@ const server_template = ({ import { set_building, set_prerendering } from '$app/env/server'; import { set_assets } from '$app/paths/internal/server'; import { set_fix_stack_trace, set_manifest, set_read_implementation, format_response } from '${runtime_directory}/server/internal.js'; +import { stream_from_iterable } from '${runtime_directory}/utils.js'; import error from './shared/error-template.js'; export const options = { @@ -69,6 +70,41 @@ export async function get_hooks() { }; } +/** + * Sets the module-level state the server runtime reads, in the order it has to happen: + * \`building\` and \`prerendering\` before the env module evaluates the user's \`src/env\` config, + * which may read them, and everything else before user modules run + * @param {import('types').ServerConfigureOptions} opts + */ +export async function configure({ building, prerendering, env, manifest, read, assets, fix_stack_trace }) { + if (building) set_building(); + if (prerendering) set_prerendering(); + + if (env) { + const { set_env } = await import('/env/config.js'); + set_env(env); + } + + if (manifest) set_manifest(manifest); + if (assets !== undefined) set_assets(assets); + if (fix_stack_trace) set_fix_stack_trace(fix_stack_trace); + + if (read) { + // the public \`read\` may return a promise, the runtime expects a stream + set_read_implementation((file) => { + const result = read(file); + if (result instanceof ReadableStream) return result; + + return stream_from_iterable( + (async function* () { + const stream = await result; + if (stream) yield* stream; + })() + ); + }); + } +} + export { set_assets, set_building, set_fix_stack_trace, set_manifest, set_prerendering, set_read_implementation, format_response }; `; diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 9139c960e92b..4924079e869c 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -376,24 +376,20 @@ export async function dev( await runner.import(resolved_instrumentation); } - // we have to import `Server` before calling `set_assets` - const { Server } = /** @type {ServerModule} */ ( + const { init, respond } = /** @type {ServerModule} */ ( await runner.import(`${get_runtime_base(root)}/server/index.js`) ); - const { set_fix_stack_trace, format_response } = await runner.import( + const { format_response } = await runner.import( `${get_runtime_base(root)}/server/internal.js` ); - set_fix_stack_trace(fix_stack_trace); - const { set_assets } = await runner.import('$app/paths/internal/server'); - set_assets(assets); - - const server = new Server(manifest); - - await server.init({ + await init({ + manifest, env, - read: (file) => createReadableStream(from_fs(file)) + read: (file) => createReadableStream(from_fs(file)), + assets, + fix_stack_trace }); const request = (svelte_config.adapter?.vite?.getRequest ?? getRequest)({ @@ -424,7 +420,7 @@ export async function dev( return; } - const rendered = await server.respond(request, { + const rendered = await respond(request, { getClientAddress: () => { const { remoteAddress } = req.socket; if (remoteAddress) return remoteAddress; diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index efc60889a758..d64253751345 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -1,6 +1,6 @@ /** @import { NextHandleFunction } from 'connect' */ /** @import { PreviewServer } from 'vite' */ -/** @import { ValidatedConfig, ServerInternalModule, ServerModule } from 'types' */ +/** @import { ValidatedConfig, ServerModule } from 'types' */ import fs from 'node:fs'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -37,23 +37,18 @@ export async function preview(vite, svelte_config) { await import(pathToFileURL(instrumentation).href); } - /** @type {ServerInternalModule} */ - const { set_assets } = await import(pathToFileURL(join(dir, 'internal.js')).href); - /** @type {ServerModule} */ - const { Server } = await import(pathToFileURL(join(dir, 'index.js')).href); + const { init, respond } = await import(pathToFileURL(join(dir, 'index.js')).href); /** @type {{ manifest: import('types').SSRManifest }} */ const { manifest } = await import(pathToFileURL(join(dir, 'manifest.js')).href); - set_assets(assets); - - const server = new Server(manifest); - try { - await server.init({ + await init({ + manifest, env: loadEnv(vite.config.mode, svelte_config.env.dir, ''), - read: (file) => createReadableStream(`${dir}/${file}`) + read: (file) => createReadableStream(`${dir}/${file}`), + assets }); } catch (error) { // Vite erases the error message when starting the preview server so we store @@ -213,7 +208,7 @@ export async function preview(vite, svelte_config) { (svelte_config.adapter?.vite?.setResponse ?? setResponse)( res, - await server.respond(request, { + await respond(request, { getClientAddress: () => { const { remoteAddress } = req.socket; if (remoteAddress) return remoteAddress; diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index e07e97112090..71d97273e471 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -1,30 +1,19 @@ import { noop } from '../../utils/functions.js'; -import { stream_from_iterable } from '../utils.js'; import { IN_WEBCONTAINER, REROUTED_URL_HEADER } from '../../constants.js'; -import { respond } from './respond.js'; +import { respond as handle } from './respond.js'; import { create_request_state } from './state.js'; -import { options, get_hooks } from '/server.js'; -import { - set_read_implementation, - set_manifest, - set_options, - set_hooks, - fix_stack_trace -} from './internal.js'; -import { set_env } from '/env/config.js'; +import { configure, options, get_hooks } from '/server.js'; +import { set_manifest, set_options, set_hooks, fix_stack_trace } from './internal.js'; import { init_tracing } from '@sveltejs/kit/internal/server'; import { DEV } from 'esm-env'; import { init_transport } from '#app/internal/transport'; -// set at module scope because prerendering evaluates user modules before constructing a `Server` +// set at module scope because prerendering evaluates user modules before `init` runs set_options(options); /** @type {Promise} */ let init_promise; -/** @type {Promise | null} */ -let current = null; - /** * Responses that were created with our monkey-patched `fetch`, which may need * to have their `content-encoding` and `content-length` headers removed @@ -57,142 +46,135 @@ if (DEV) { }; } -export class Server { - /** @param {import('types').SSRManifest} manifest */ - constructor(manifest) { - // Since AsyncLocalStorage is not working in webcontainers, we don't reset `sync_store` - // in `src/exports/internal/server/event.js` and handle only one request at a time. - if (IN_WEBCONTAINER) { - const respond = this.respond.bind(this); - - /** @type {typeof respond} */ - this.respond = async (...args) => { - const { promise, resolve } = /** @type {PromiseWithResolvers} */ ( - Promise.withResolvers() - ); - - const previous = current; - current = promise; - - await previous; - return respond(...args).finally(resolve); - }; - } - - set_manifest(manifest); - } +/** + * Configures the runtime and loads the user's hooks. Adapters call this at startup, some of them + * again per request to refresh `env`, so only the hooks are guarded against running twice + * @param {import('types').ServerConfigureOptions} opts + */ +export async function init(opts) { + if (__SVELTEKIT_SERVER_TRACING_ENABLED__) init_tracing(import('@opentelemetry/api')); + + await configure(opts); + + await (init_promise ??= (async () => { + try { + const module = await get_hooks(); + + set_hooks({ + handle: module.handle || (({ event, resolve }) => resolve(event)), + handleError: + module.handleError || + (({ kind, error, issues }) => { + if (kind === 'validation') { + console.error('Remote function schema validation failed:', issues); + return; + } + + if (kind !== 'unknown') { + // don't log stack traces for 404s etc, it's all internal gubbins + return; + } + + let e = error; + while (e instanceof Error) { + if (e.stack) { + console.error(e.stack); + } + e = e.cause; + } - /** - * @param {import('@sveltejs/kit').ServerInitOptions} opts - */ - async init({ env, read }) { - // Take care: Some adapters may have to call `Server.init` per-request to set env vars, - // so anything that shouldn't be rerun should be wrapped in an `if` block to make sure it hasn't - // been done already. - - if (__SVELTEKIT_SERVER_TRACING_ENABLED__) init_tracing(import('@opentelemetry/api')); - - // set env, in case it's used in initialisation - set_env(env); - - if (read) { - // Wrap the read function to handle MaybePromise - // and ensure the public API stays synchronous - /** @param {string} file */ - const wrapped_read = (file) => { - const result = read(file); - if (result instanceof ReadableStream) { - return result; - } - - return stream_from_iterable( - (async function* () { - const stream = await result; - if (stream) yield* stream; - })() - ); - }; - - set_read_implementation(wrapped_read); - } + if (e) { + console.error(String(e)); + } + }), + handleFetch: module.handleFetch || (({ request, fetch }) => fetch(request)), + reroute: module.reroute || noop + }); - // During dev and for some adapters this function might be called in quick succession, - // so we need to make sure we're not invoking this logic (most notably the init hook) multiple times - await (init_promise ??= (async () => { - try { - const module = await get_hooks(); + init_transport(module.transport ?? {}); + if (module.init) { + await module.init(); + } + } catch (e) { + if (__SVELTEKIT_DEV__) { set_hooks({ - handle: module.handle || (({ event, resolve }) => resolve(event)), - handleError: - module.handleError || - (({ kind, error, issues }) => { - if (kind === 'validation') { - console.error('Remote function schema validation failed:', issues); - return; - } + handle: () => { + throw e; + }, + handleError: ({ error }) => console.error(error), + handleFetch: ({ request, fetch }) => fetch(request), + reroute: noop + }); + } else { + throw e; + } + } + })()); +} - if (kind !== 'unknown') { - // don't log stack traces for 404s etc, it's all internal gubbins - return; - } +/** + * @param {Request} request + * @param {import('types').InternalRequestOptions} options + */ +async function respond_to(request, options) { + const request_state = create_request_state(options); - let e = error; - while (e instanceof Error) { - if (e.stack) { - console.error(e.stack); - } - e = e.cause; - } + const response = await handle(request, request_state); - if (e) { - console.error(String(e)); - } - }), - handleFetch: module.handleFetch || (({ request, fetch }) => fetch(request)), - reroute: module.reroute || noop - }); + if (DEV) { + const error = decoded_responses.get(response); + if (error) console.error(fix_stack_trace(error)); + } - init_transport(module.transport ?? {}); - - if (module.init) { - await module.init(); - } - } catch (e) { - if (__SVELTEKIT_DEV__) { - set_hooks({ - handle: () => { - throw e; - }, - handleError: ({ error }) => console.error(error), - handleFetch: ({ request, fetch }) => fetch(request), - reroute: noop - }); - } else { - throw e; - } - } - })()); + if (request_state.rerouted_url) { + response.headers.set(REROUTED_URL_HEADER, request_state.rerouted_url); } - /** - * @param {Request} request - * @param {import('types').InternalRequestOptions} options - */ - async respond(request, options) { - const request_state = create_request_state(options); + return response; +} - const response = await respond(request, request_state); +/** + * AsyncLocalStorage does not work in webcontainers, so there `sync_store` is never reset + * (see `src/exports/internal/server/event.js`) and requests are handled one at a time + * @param {typeof respond_to} fn + */ +function serialise(fn) { + /** @type {Promise | null} */ + let current = null; - if (DEV) { - const error = decoded_responses.get(response); - if (error) console.error(fix_stack_trace(error)); - } + /** @type {typeof respond_to} */ + return async (...args) => { + const { promise, resolve } = /** @type {PromiseWithResolvers} */ ( + Promise.withResolvers() + ); - if (request_state.rerouted_url) { - response.headers.set(REROUTED_URL_HEADER, request_state.rerouted_url); - } + const previous = current; + current = promise; - return response; + await previous; + return fn(...args).finally(resolve); + }; +} + +export const respond = IN_WEBCONTAINER ? serialise(respond_to) : respond_to; + +/** + * The `server` object adapters receive from `builder.generateServerInstance` + * @param {import('types').SSRManifest} manifest + * @returns {import('types').InternalServer} + */ +export function create_server(manifest) { + // set now rather than in `init`, since user modules may read the manifest at their top level + set_manifest(manifest); + + return { init: (opts) => init({ ...opts, manifest }), respond }; +} + +/** @deprecated use the `server` written by `builder.generateServerInstance`, or `init` and `respond` */ +export class Server { + /** @param {import('types').SSRManifest} manifest */ + constructor(manifest) { + Object.assign(this, create_server(manifest)); } } diff --git a/packages/kit/src/types/ambient-private.d.ts b/packages/kit/src/types/ambient-private.d.ts index 74ffbddbebf3..f7e127111492 100644 --- a/packages/kit/src/types/ambient-private.d.ts +++ b/packages/kit/src/types/ambient-private.d.ts @@ -1,7 +1,8 @@ /** Internal version of $app/server */ declare module '/server.js' { - import { SSROptions, ServerHooks, SSRManifest } from 'types'; + import { SSROptions, ServerHooks, SSRManifest, ServerConfigureOptions } from 'types'; + export function configure(options: ServerConfigureOptions): Promise; export const options: SSROptions; export const get_hooks: () => Promise>; export let fix_stack_trace: (error: Error) => string; diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index d5d3d9c76f86..617c760e774d 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -37,17 +37,32 @@ import { Span } from '@opentelemetry/api'; import { PageOptions } from '../exports/vite/static_analysis/types.js'; import { SharedIterator } from '../utils/shared-iterator.js'; +export interface ServerConfigureOptions { + env?: ServerInitOptions['env']; + read?: ServerInitOptions['read']; + manifest?: SSRManifest; + /** the value of `$app/paths`'s `assets`, when it differs from the build-time one */ + assets?: string; + building?: boolean; + prerendering?: boolean; + fix_stack_trace?: (error: Error) => void; +} + export interface ServerModule { - Server: typeof InternalServer; + init(options: ServerConfigureOptions): Promise; + respond(request: Request, options: InternalRequestOptions): Promise; + create_server(manifest: SSRManifest): InternalServer; + /** @deprecated */ + Server: new (manifest: SSRManifest) => InternalServer; } export interface ServerInternalModule { + configure(options: ServerConfigureOptions): Promise; set_assets(path: string): void; set_building(): void; set_manifest(manifest: SSRManifest): void; set_prerendering(): void; set_read_implementation(implementation: (path: string) => ReadableStream): void; - set_version(version: string): void; set_fix_stack_trace(fix_stack_trace: (error: Error) => void): void; get_hooks: () => Promise>; format_response: (status: number, request: Request) => string; @@ -195,9 +210,7 @@ export interface InternalRequestOptions extends RequestOptions { emulator?: Emulator; } -export class InternalServer implements Server { - constructor(manifest: SSRManifest); - init(options: ServerInitOptions): Promise; +export interface InternalServer extends Server { respond(request: Request, options: InternalRequestOptions): Promise; } diff --git a/packages/kit/test/apps/basics/vite.config.js b/packages/kit/test/apps/basics/vite.config.js index 2f6ab39f9af3..2710856db64b 100644 --- a/packages/kit/test/apps/basics/vite.config.js +++ b/packages/kit/test/apps/basics/vite.config.js @@ -28,7 +28,7 @@ export default defineConfig({ instrumentation: `${builder.getServerDirectory()}/instrumentation.server.js`, initializer, module: { - exports: ['Server'] + exports: ['init', 'respond', 'create_server', 'Server'] } }); }, From f7d6ded6009db09a2552ef7d5fa5283212740a58 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:05:59 -0400 Subject: [PATCH 02/23] chore: drop InternalServer, type internal.js from its declaration --- packages/kit/src/runtime/server/index.js | 2 +- packages/kit/src/types/ambient-private.d.ts | 13 ++++++----- packages/kit/src/types/internal.d.ts | 26 ++++----------------- 3 files changed, 13 insertions(+), 28 deletions(-) diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index 71d97273e471..ef14aaf7043e 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -162,7 +162,7 @@ export const respond = IN_WEBCONTAINER ? serialise(respond_to) : respond_to; /** * The `server` object adapters receive from `builder.generateServerInstance` * @param {import('types').SSRManifest} manifest - * @returns {import('types').InternalServer} + * @returns {import('@sveltejs/kit').Server} */ export function create_server(manifest) { // set now rather than in `init`, since user modules may read the manifest at their top level diff --git a/packages/kit/src/types/ambient-private.d.ts b/packages/kit/src/types/ambient-private.d.ts index f7e127111492..78e51cd725fa 100644 --- a/packages/kit/src/types/ambient-private.d.ts +++ b/packages/kit/src/types/ambient-private.d.ts @@ -2,15 +2,16 @@ declare module '/server.js' { import { SSROptions, ServerHooks, SSRManifest, ServerConfigureOptions } from 'types'; - export function configure(options: ServerConfigureOptions): Promise; export const options: SSROptions; - export const get_hooks: () => Promise>; - export let fix_stack_trace: (error: Error) => string; - export let manifest: SSRManifest; - export function read_implementation(path: string): ReadableStream; - export function set_fix_stack_trace(fn: (error: Error) => string): void; + export function get_hooks(): Promise>; + export function configure(options: ServerConfigureOptions): Promise; + export function set_assets(path: string): void; + export function set_building(): void; + export function set_prerendering(): void; export function set_manifest(manifest: SSRManifest): void; export function set_read_implementation(fn: (path: string) => ReadableStream): void; + export function set_fix_stack_trace(fn: (error: Error) => void): void; + export function format_response(status: number, request: Request): string; } declare module '/env/config.js' { diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 617c760e774d..2e9efc952498 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -37,9 +37,7 @@ import { Span } from '@opentelemetry/api'; import { PageOptions } from '../exports/vite/static_analysis/types.js'; import { SharedIterator } from '../utils/shared-iterator.js'; -export interface ServerConfigureOptions { - env?: ServerInitOptions['env']; - read?: ServerInitOptions['read']; +export interface ServerConfigureOptions extends Partial { manifest?: SSRManifest; /** the value of `$app/paths`'s `assets`, when it differs from the build-time one */ assets?: string; @@ -51,22 +49,12 @@ export interface ServerConfigureOptions { export interface ServerModule { init(options: ServerConfigureOptions): Promise; respond(request: Request, options: InternalRequestOptions): Promise; - create_server(manifest: SSRManifest): InternalServer; - /** @deprecated */ - Server: new (manifest: SSRManifest) => InternalServer; + /** the `server` adapters receive from `builder.generateServerInstance` */ + create_server(manifest: SSRManifest): Server; } -export interface ServerInternalModule { - configure(options: ServerConfigureOptions): Promise; - set_assets(path: string): void; - set_building(): void; - set_manifest(manifest: SSRManifest): void; - set_prerendering(): void; - set_read_implementation(implementation: (path: string) => ReadableStream): void; - set_fix_stack_trace(fix_stack_trace: (error: Error) => void): void; - get_hooks: () => Promise>; - format_response: (status: number, request: Request) => string; -} +/** the built `server/internal.js` */ +export type ServerInternalModule = typeof import('/server.js'); export interface Asset { file: string; @@ -210,10 +198,6 @@ export interface InternalRequestOptions extends RequestOptions { emulator?: Emulator; } -export interface InternalServer extends Server { - respond(request: Request, options: InternalRequestOptions): Promise; -} - export interface ManifestData { /** Static files from `config.files.assets`. */ assets: Asset[]; From ed9f08dcd5411bc4c63f846ed30ea1b51590929a Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:38:23 -0400 Subject: [PATCH 03/23] chore: evaluate the env config last, keep Server's methods on its prototype --- .../60-appendix/35-migrating-to-sveltekit-3.md | 1 + packages/kit/src/core/sync/write_server.js | 13 +++++++------ packages/kit/src/runtime/server/index.js | 17 +++++++++++++++-- packages/kit/src/types/ambient-private.d.ts | 8 +------- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md b/documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md index b2575ab27882..04316811d492 100644 --- a/documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md +++ b/documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md @@ -520,6 +520,7 @@ For adapter authors, there are some additional changes: - `builder.compress` returns a list of compressed files - `builder.mkdirp` and `builder.rimraf` are deprecated in favour of `node:fs` methods - `builder.generateManifest` has been removed — use `builder.generateServerInstance` to replace it, and `builder.manifest` to access the manifest +- the `Server` class exported from the server output is deprecated — use the `server` object written by `builder.generateServerInstance` #### Server instrumentation diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index 31f8d6b2e4c7..dc08013b2166 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -80,11 +80,6 @@ export async function configure({ building, prerendering, env, manifest, read, a if (building) set_building(); if (prerendering) set_prerendering(); - if (env) { - const { set_env } = await import('/env/config.js'); - set_env(env); - } - if (manifest) set_manifest(manifest); if (assets !== undefined) set_assets(assets); if (fix_stack_trace) set_fix_stack_trace(fix_stack_trace); @@ -103,9 +98,15 @@ export async function configure({ building, prerendering, env, manifest, read, a ); }); } + + // evaluates the user's \`src/env\` config, which may read any of the above + if (env) { + const { set_env } = await import('/env/config.js'); + set_env(env); + } } -export { set_assets, set_building, set_fix_stack_trace, set_manifest, set_prerendering, set_read_implementation, format_response }; +export { format_response }; `; /** diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index ef14aaf7043e..66d9638710d0 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -168,13 +168,26 @@ export function create_server(manifest) { // set now rather than in `init`, since user modules may read the manifest at their top level set_manifest(manifest); - return { init: (opts) => init({ ...opts, manifest }), respond }; + return { init, respond }; } /** @deprecated use the `server` written by `builder.generateServerInstance`, or `init` and `respond` */ export class Server { /** @param {import('types').SSRManifest} manifest */ constructor(manifest) { - Object.assign(this, create_server(manifest)); + set_manifest(manifest); + } + + /** @param {import('@sveltejs/kit').ServerInitOptions} opts */ + init(opts) { + return init(opts); + } + + /** + * @param {Request} request + * @param {import('types').InternalRequestOptions} options + */ + respond(request, options) { + return respond(request, options); } } diff --git a/packages/kit/src/types/ambient-private.d.ts b/packages/kit/src/types/ambient-private.d.ts index 78e51cd725fa..879b78a6dd9b 100644 --- a/packages/kit/src/types/ambient-private.d.ts +++ b/packages/kit/src/types/ambient-private.d.ts @@ -1,16 +1,10 @@ /** Internal version of $app/server */ declare module '/server.js' { - import { SSROptions, ServerHooks, SSRManifest, ServerConfigureOptions } from 'types'; + import { SSROptions, ServerHooks, ServerConfigureOptions } from 'types'; export const options: SSROptions; export function get_hooks(): Promise>; export function configure(options: ServerConfigureOptions): Promise; - export function set_assets(path: string): void; - export function set_building(): void; - export function set_prerendering(): void; - export function set_manifest(manifest: SSRManifest): void; - export function set_read_implementation(fn: (path: string) => ReadableStream): void; - export function set_fix_stack_trace(fn: (error: Error) => void): void; export function format_response(status: number, request: Request): string; } From 9fdce005a83f77773badc7ab84edd9268d2d34a4 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:16:53 -0400 Subject: [PATCH 04/23] chore: adapters only get to set env and read through the server object --- packages/kit/src/runtime/server/index.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index 66d9638710d0..2ad36e51827a 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -168,19 +168,25 @@ export function create_server(manifest) { // set now rather than in `init`, since user modules may read the manifest at their top level set_manifest(manifest); - return { init, respond }; + return { + // adapters get to set `env` and `read`, nothing else + init: ({ env, read }) => init({ env, read }), + respond + }; } /** @deprecated use the `server` written by `builder.generateServerInstance`, or `init` and `respond` */ export class Server { + #server; + /** @param {import('types').SSRManifest} manifest */ constructor(manifest) { - set_manifest(manifest); + this.#server = create_server(manifest); } /** @param {import('@sveltejs/kit').ServerInitOptions} opts */ init(opts) { - return init(opts); + return this.#server.init(opts); } /** @@ -188,6 +194,6 @@ export class Server { * @param {import('types').InternalRequestOptions} options */ respond(request, options) { - return respond(request, options); + return this.#server.respond(request, options); } } From c440ed4d09147d080bad8db59310929aba65a3c5 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:24:14 -0400 Subject: [PATCH 05/23] chore: changeset --- .changeset/server-boot-configure.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/server-boot-configure.md diff --git a/.changeset/server-boot-configure.md b/.changeset/server-boot-configure.md new file mode 100644 index 000000000000..2819ceb05fe0 --- /dev/null +++ b/.changeset/server-boot-configure.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +chore: configure the server runtime in one place, deprecate `Server` in favour of the `server` object written by `builder.generateServerInstance` From 4bb0844eba2e163c315a500a181a3ea778599165 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:06:12 -0400 Subject: [PATCH 06/23] chore: configure the prerender server once --- packages/kit/src/core/postbuild/fallback.js | 12 +++++++----- packages/kit/src/core/postbuild/prerender.js | 17 +++++++++-------- packages/kit/src/runtime/server/index.js | 4 ++-- packages/kit/src/types/internal.d.ts | 2 +- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/kit/src/core/postbuild/fallback.js b/packages/kit/src/core/postbuild/fallback.js index e488710d97b6..4bf5483ee797 100644 --- a/packages/kit/src/core/postbuild/fallback.js +++ b/packages/kit/src/core/postbuild/fallback.js @@ -20,15 +20,17 @@ async function generate_fallback({ manifest_path, env, out_dir, origin, assets } /** @type {import('types').ServerInternalModule} */ const { configure } = await import(pathToFileURL(`${server_root}/server/internal.js`).href); - await configure({ building: true }); - - /** @type {import('types').ServerModule} */ - const { init, respond } = await import(pathToFileURL(`${server_root}/server/index.js`).href); /** @type {import('types').SSRManifest} */ const manifest = (await import(pathToFileURL(manifest_path).href)).manifest; - await init({ manifest, env }); + // `building` has to be set before the server module evaluates the user's env config + await configure({ building: true, manifest, env }); + + /** @type {import('types').ServerModule} */ + const { init, respond } = await import(pathToFileURL(`${server_root}/server/index.js`).href); + + await init(); const response = await respond(new Request(origin + '/[fallback]'), { getClientAddress: () => { diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index 3a7c14ab9f6b..537b73b34757 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -56,8 +56,14 @@ async function prerender({ pathToFileURL(`${out}/server/internal.js`).href ); - // `building` and `prerendering` have to be set before the server module evaluates the user's env config - await configure({ building: true, prerendering: true, env }); + // everything user modules may read at their top level, before the server module evaluates the user's env config + await configure({ + building: true, + prerendering: true, + env, + manifest, + read: (file) => createReadableStream(`${out}/server/${file}`) + }); /** @type {import('types').ServerModule} */ const { init, respond } = await import(pathToFileURL(`${out}/server/index.js`).href); @@ -644,11 +650,6 @@ async function prerender({ } } - // the user's remote function modules may reference `read` or the `manifest` at the top-level - // so we need to set them before evaluating those modules to avoid potential runtime errors - const read = (/** @type {string} */ file) => createReadableStream(`${out}/server/${file}`); - await configure({ manifest, read }); - /** @type {Array} */ const prerender_functions = []; @@ -669,7 +670,7 @@ async function prerender({ // only run the server after the `should_prerender` check so that we // don't run the user's init hook unnecessarily - await init({ manifest, env, read }); + await init(); log.info('Prerendering'); diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index c29d05c009cf..27a476e8f270 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -49,12 +49,12 @@ if (DEV) { /** * Configures the runtime and loads the user's hooks. Adapters call this at startup, some of them * again per request to refresh `env`, so only the hooks are guarded against running twice - * @param {import('types').ServerConfigureOptions} opts + * @param {import('types').ServerConfigureOptions} [opts] */ export async function init(opts) { if (__SVELTEKIT_SERVER_TRACING_ENABLED__) init_tracing(import('@opentelemetry/api')); - await configure(opts); + if (opts) await configure(opts); await (init_promise ??= (async () => { try { diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 2e9efc952498..04791ad09a27 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -47,7 +47,7 @@ export interface ServerConfigureOptions extends Partial { } export interface ServerModule { - init(options: ServerConfigureOptions): Promise; + init(options?: ServerConfigureOptions): Promise; respond(request: Request, options: InternalRequestOptions): Promise; /** the `server` adapters receive from `builder.generateServerInstance` */ create_server(manifest: SSRManifest): Server; From 221cd4c6b4ea738d2b44e5694005b55a82e45210 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:27:58 -0400 Subject: [PATCH 07/23] chore: gate the server runtime behind configure --- packages/kit/src/core/postbuild/analyse.js | 5 +- packages/kit/src/core/postbuild/fallback.js | 11 +- packages/kit/src/core/postbuild/prerender.js | 12 +- packages/kit/src/exports/vite/build/index.js | 1 - packages/kit/src/exports/vite/dev/index.js | 10 +- .../kit/src/exports/vite/preview/index.js | 11 +- packages/kit/src/runtime/server/index.js | 179 ++---------------- packages/kit/src/runtime/server/instance.js | 153 +++++++++++++++ packages/kit/src/types/internal.d.ts | 13 +- packages/kit/test/apps/basics/vite.config.js | 2 +- 10 files changed, 203 insertions(+), 194 deletions(-) create mode 100644 packages/kit/src/runtime/server/instance.js diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index abb8cfca60c1..5d275ab03574 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -41,10 +41,9 @@ async function analyse({ const config = extract_svelte_config(vite_config); const server_root = join(config.outDir, 'output'); - /** @type {import('types').ServerInternalModule} */ - const { configure } = await import(pathToFileURL(`${server_root}/server/internal.js`).href); + /** @type {import('types').ServerModule} */ + const { configure } = await import(pathToFileURL(`${server_root}/server/index.js`).href); - // everything user modules may read at their top level, before any of them are analysed await configure({ building: true, manifest, diff --git a/packages/kit/src/core/postbuild/fallback.js b/packages/kit/src/core/postbuild/fallback.js index 4bf5483ee797..f3356282f19f 100644 --- a/packages/kit/src/core/postbuild/fallback.js +++ b/packages/kit/src/core/postbuild/fallback.js @@ -18,18 +18,13 @@ export default forked(import.meta.url, generate_fallback); async function generate_fallback({ manifest_path, env, out_dir, origin, assets }) { const server_root = join(out_dir, 'output'); - /** @type {import('types').ServerInternalModule} */ - const { configure } = await import(pathToFileURL(`${server_root}/server/internal.js`).href); + /** @type {import('types').ServerModule} */ + const { configure } = await import(pathToFileURL(`${server_root}/server/index.js`).href); /** @type {import('types').SSRManifest} */ const manifest = (await import(pathToFileURL(manifest_path).href)).manifest; - // `building` has to be set before the server module evaluates the user's env config - await configure({ building: true, manifest, env }); - - /** @type {import('types').ServerModule} */ - const { init, respond } = await import(pathToFileURL(`${server_root}/server/index.js`).href); - + const { init, respond } = await configure({ building: true, manifest, env }); await init(); const response = await respond(new Request(origin + '/[fallback]'), { diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index 537b73b34757..66a6949f77a1 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -51,13 +51,10 @@ async function prerender({ /** @type {import('types').SSRManifest} */ const manifest = (await import(pathToFileURL(manifest_path).href)).manifest; - /** @type {import('types').ServerInternalModule} */ - const { configure, format_response } = await import( - pathToFileURL(`${out}/server/internal.js`).href - ); + /** @type {import('types').ServerModule} */ + const { configure, format_response } = await import(pathToFileURL(`${out}/server/index.js`).href); - // everything user modules may read at their top level, before the server module evaluates the user's env config - await configure({ + const { init, respond } = await configure({ building: true, prerendering: true, env, @@ -65,9 +62,6 @@ async function prerender({ read: (file) => createReadableStream(`${out}/server/${file}`) }); - /** @type {import('types').ServerModule} */ - const { init, respond } = await import(pathToFileURL(`${out}/server/index.js`).href); - const throw_handled = () => { throw new Error('__handled__'); }; diff --git a/packages/kit/src/exports/vite/build/index.js b/packages/kit/src/exports/vite/build/index.js index 786fd70e541e..b3776bba19e4 100644 --- a/packages/kit/src/exports/vite/build/index.js +++ b/packages/kit/src/exports/vite/build/index.js @@ -131,7 +131,6 @@ export function plugin_compile( /** @type {Record} */ const server_input = { index: `${runtime_directory}/server/index.js`, - internal: `/server.js`, env: '/env/config.js', ['remote-entry']: `${runtime_directory}/app/server/remote/index.js` }; diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 4924079e869c..5b39b3aeafd1 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -376,15 +376,11 @@ export async function dev( await runner.import(resolved_instrumentation); } - const { init, respond } = /** @type {ServerModule} */ ( + const { configure, format_response } = /** @type {ServerModule} */ ( await runner.import(`${get_runtime_base(root)}/server/index.js`) ); - const { format_response } = await runner.import( - `${get_runtime_base(root)}/server/internal.js` - ); - - await init({ + const { init, respond } = await configure({ manifest, env, read: (file) => createReadableStream(from_fs(file)), @@ -392,6 +388,8 @@ export async function dev( fix_stack_trace }); + await init(); + const request = (svelte_config.adapter?.vite?.getRequest ?? getRequest)({ base, request: req, diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index d64253751345..2757364cbff1 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -38,18 +38,23 @@ export async function preview(vite, svelte_config) { } /** @type {ServerModule} */ - const { init, respond } = await import(pathToFileURL(join(dir, 'index.js')).href); + const { configure } = await import(pathToFileURL(join(dir, 'index.js')).href); /** @type {{ manifest: import('types').SSRManifest }} */ const { manifest } = await import(pathToFileURL(join(dir, 'manifest.js')).href); + /** @type {import('types').ServerInstance} */ + let server; + try { - await init({ + server = await configure({ manifest, env: loadEnv(vite.config.mode, svelte_config.env.dir, ''), read: (file) => createReadableStream(`${dir}/${file}`), assets }); + + await server.init(); } catch (error) { // Vite erases the error message when starting the preview server so we store // it in the stack instead. This ensures errors thrown using `stackless` @@ -208,7 +213,7 @@ export async function preview(vite, svelte_config) { (svelte_config.adapter?.vite?.setResponse ?? setResponse)( res, - await respond(request, { + await server.respond(request, { getClientAddress: () => { const { remoteAddress } = req.socket; if (remoteAddress) return remoteAddress; diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index 27a476e8f270..a70fcf21c4b8 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -1,181 +1,42 @@ -import { noop } from '../../utils/functions.js'; -import { IN_WEBCONTAINER, REROUTED_URL_HEADER } from '../../constants.js'; -import { respond as handle } from './respond.js'; -import { create_request_state } from './state.js'; -import { configure, options, get_hooks } from '/server.js'; -import { set_manifest, set_options, set_hooks, fix_stack_trace } from './internal.js'; -import { init_tracing } from '@sveltejs/kit/internal/server'; -import { DEV } from 'esm-env'; -import { init_transport } from '#app/internal/transport'; +import { configure as set_state, options } from '/server.js'; +import { set_options } from './internal.js'; // set at module scope because prerendering evaluates user modules before `init` runs set_options(options); -/** @type {Promise} */ -let init_promise; - /** - * Responses that were created with our monkey-patched `fetch`, which may need - * to have their `content-encoding` and `content-length` headers removed - * if returned directly (i.e. `fetch` is being used to proxy a request) - * @type {WeakMap} + * Sets the module-level state the runtime reads, then loads the runtime. Everything that + * evaluates user code sits behind this import, so `building`, `prerendering` and `env` are + * in place before any of it runs + * @param {import('types').ServerConfigureOptions} opts + * @returns {Promise} */ -const decoded_responses = new WeakMap(); - -if (DEV) { - const fetch = globalThis.fetch; - - /** - * @param {RequestInfo | URL} info - * @param {RequestInit} [init] - */ - globalThis.fetch = async (info, init) => { - const response = await fetch(info, init); - const encoding = response.headers.get('content-encoding'); - - if (encoding) { - decoded_responses.set( - response, - new Error( - `Cannot return \`fetch(...)\` directly from a handler if the response has a \`Content-Encoding: ${encoding}\` header. The body has already been decoded` - ) - ); - } - - return response; - }; +export async function configure(opts) { + await set_state(opts); + return import('./instance.js'); } -/** - * Configures the runtime and loads the user's hooks. Adapters call this at startup, some of them - * again per request to refresh `env`, so only the hooks are guarded against running twice - * @param {import('types').ServerConfigureOptions} [opts] - */ -export async function init(opts) { - if (__SVELTEKIT_SERVER_TRACING_ENABLED__) init_tracing(import('@opentelemetry/api')); - - if (opts) await configure(opts); - - await (init_promise ??= (async () => { - try { - const module = await get_hooks(); - - set_hooks({ - handle: module.handle || (({ event, resolve }) => resolve(event)), - handleError: - module.handleError || - (({ kind, error, issues }) => { - if (kind === 'validation') { - console.error('Remote function schema validation failed:', issues); - return; - } - - if (kind !== 'unknown') { - // don't log stack traces for 404s etc, it's all internal gubbins - return; - } - - let e = error; - while (e instanceof Error) { - if (e.stack) { - console.error(e.stack); - } - e = e.cause; - } - - if (e) { - console.error(String(e)); - } - }), - handleFetch: module.handleFetch || (({ request, fetch }) => fetch(request)), - reroute: module.reroute || noop - }); - - init_transport(module.transport ?? {}); - - if (module.init) { - await module.init(); - } - } catch (e) { - if (__SVELTEKIT_DEV__) { - set_hooks({ - handle: () => { - throw e; - }, - handleError: ({ error }) => console.error(error), - handleFetch: ({ request, fetch }) => fetch(request), - reroute: noop - }); - } else { - throw e; - } - } - })()); -} - -/** - * @param {Request} request - * @param {import('types').InternalRequestOptions} options - */ -async function respond_to(request, options) { - const request_state = create_request_state(options); - - const response = await handle(request, request_state); - - if (DEV) { - const error = decoded_responses.get(response); - if (error) console.error(fix_stack_trace(error)); - } - - if (request_state.rerouted_url) { - response.headers.set(REROUTED_URL_HEADER, request_state.rerouted_url); - } - - return response; -} - -/** - * AsyncLocalStorage does not work in webcontainers, so there `sync_store` is never reset - * (see `src/exports/internal/server/event.js`) and requests are handled one at a time - * @param {typeof respond_to} fn - */ -function serialise(fn) { - /** @type {Promise | null} */ - let current = null; - - /** @type {typeof respond_to} */ - return async (...args) => { - const { promise, resolve } = /** @type {PromiseWithResolvers} */ ( - Promise.withResolvers() - ); - - const previous = current; - current = promise; - - await previous; - return fn(...args).finally(resolve); - }; -} - -export const respond = IN_WEBCONTAINER ? serialise(respond_to) : respond_to; - /** * The `server` object adapters receive from `builder.generateServerInstance` * @param {import('types').SSRManifest} manifest * @returns {import('@sveltejs/kit').Server} */ export function create_server(manifest) { - // set now rather than in `init`, since user modules may read the manifest at their top level - set_manifest(manifest); + /** @type {import('types').ServerInstance} */ + let server; return { // adapters get to set `env` and `read`, nothing else - init: ({ env, read }) => init({ env, read }), - respond + init: async ({ env, read }) => { + server = await configure({ manifest, env, read }); + await server.init(); + }, + /** @type {import('types').ServerInstance['respond']} */ + respond: (request, options) => server.respond(request, options) }; } -/** @deprecated use the `server` written by `builder.generateServerInstance`, or `init` and `respond` */ +/** @deprecated use the `server` written by `builder.generateServerInstance`, or `configure` */ export class Server { #server; @@ -205,3 +66,5 @@ export class Server { return response; } } + +export { format_response } from '/server.js'; diff --git a/packages/kit/src/runtime/server/instance.js b/packages/kit/src/runtime/server/instance.js new file mode 100644 index 000000000000..f8b414eb40ac --- /dev/null +++ b/packages/kit/src/runtime/server/instance.js @@ -0,0 +1,153 @@ +import { noop } from '../../utils/functions.js'; +import { IN_WEBCONTAINER, REROUTED_URL_HEADER } from '../../constants.js'; +import { respond as handle } from './respond.js'; +import { create_request_state } from './state.js'; +import { get_hooks } from '/server.js'; +import { set_hooks, fix_stack_trace } from './internal.js'; +import { init_tracing } from '@sveltejs/kit/internal/server'; +import { DEV } from 'esm-env'; +import { init_transport } from '#app/internal/transport'; + +/** @type {Promise} */ +let init_promise; + +/** + * Responses that were created with our monkey-patched `fetch`, which may need + * to have their `content-encoding` and `content-length` headers removed + * if returned directly (i.e. `fetch` is being used to proxy a request) + * @type {WeakMap} + */ +const decoded_responses = new WeakMap(); + +if (DEV) { + const fetch = globalThis.fetch; + + /** + * @param {RequestInfo | URL} info + * @param {RequestInit} [init] + */ + globalThis.fetch = async (info, init) => { + const response = await fetch(info, init); + const encoding = response.headers.get('content-encoding'); + + if (encoding) { + decoded_responses.set( + response, + new Error( + `Cannot return \`fetch(...)\` directly from a handler if the response has a \`Content-Encoding: ${encoding}\` header. The body has already been decoded` + ) + ); + } + + return response; + }; +} + +/** + * Loads the user's hooks, once, however many times an adapter calls it + */ +export async function init() { + if (__SVELTEKIT_SERVER_TRACING_ENABLED__) init_tracing(import('@opentelemetry/api')); + + await (init_promise ??= (async () => { + try { + const module = await get_hooks(); + + set_hooks({ + handle: module.handle || (({ event, resolve }) => resolve(event)), + handleError: + module.handleError || + (({ kind, error, issues }) => { + if (kind === 'validation') { + console.error('Remote function schema validation failed:', issues); + return; + } + + if (kind !== 'unknown') { + // don't log stack traces for 404s etc, it's all internal gubbins + return; + } + + let e = error; + while (e instanceof Error) { + if (e.stack) { + console.error(e.stack); + } + e = e.cause; + } + + if (e) { + console.error(String(e)); + } + }), + handleFetch: module.handleFetch || (({ request, fetch }) => fetch(request)), + reroute: module.reroute || noop + }); + + init_transport(module.transport ?? {}); + + if (module.init) { + await module.init(); + } + } catch (e) { + if (__SVELTEKIT_DEV__) { + set_hooks({ + handle: () => { + throw e; + }, + handleError: ({ error }) => console.error(error), + handleFetch: ({ request, fetch }) => fetch(request), + reroute: noop + }); + } else { + throw e; + } + } + })()); +} + +/** + * @param {Request} request + * @param {import('types').InternalRequestOptions} options + */ +async function respond_to(request, options) { + const request_state = create_request_state(options); + + const response = await handle(request, request_state); + + if (DEV) { + const error = decoded_responses.get(response); + if (error) console.error(fix_stack_trace(error)); + } + + if (request_state.rerouted_url) { + response.headers.set(REROUTED_URL_HEADER, request_state.rerouted_url); + } + + return response; +} + +/** + * AsyncLocalStorage does not work in webcontainers, so there `sync_store` is never reset + * (see `src/exports/internal/server/event.js`) and requests are handled one at a time + * @param {typeof respond_to} fn + */ +function serialise(fn) { + /** @type {Promise | null} */ + let current = null; + + /** @type {typeof respond_to} */ + return async (...args) => { + const { promise, resolve } = /** @type {PromiseWithResolvers} */ ( + Promise.withResolvers() + ); + + const previous = current; + current = promise; + + await previous; + return fn(...args).finally(resolve); + }; +} + +export const respond = IN_WEBCONTAINER ? serialise(respond_to) : respond_to; diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 04791ad09a27..81a9a9dc0549 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -46,16 +46,19 @@ export interface ServerConfigureOptions extends Partial { fix_stack_trace?: (error: Error) => void; } -export interface ServerModule { - init(options?: ServerConfigureOptions): Promise; +export interface ServerInstance { + init(): Promise; respond(request: Request, options: InternalRequestOptions): Promise; +} + +/** the built `server/index.js` */ +export interface ServerModule { + configure(options: ServerConfigureOptions): Promise; /** the `server` adapters receive from `builder.generateServerInstance` */ create_server(manifest: SSRManifest): Server; + format_response(status: number, request: Request): string; } -/** the built `server/internal.js` */ -export type ServerInternalModule = typeof import('/server.js'); - export interface Asset { file: string; type: string | null; diff --git a/packages/kit/test/apps/basics/vite.config.js b/packages/kit/test/apps/basics/vite.config.js index 2710856db64b..9e6e429ac7cb 100644 --- a/packages/kit/test/apps/basics/vite.config.js +++ b/packages/kit/test/apps/basics/vite.config.js @@ -28,7 +28,7 @@ export default defineConfig({ instrumentation: `${builder.getServerDirectory()}/instrumentation.server.js`, initializer, module: { - exports: ['init', 'respond', 'create_server', 'Server'] + exports: ['configure', 'create_server', 'Server', 'format_response'] } }); }, From ca80118cdd5932ce43005bc25ff3e4a432b4b2dc Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:37:33 -0400 Subject: [PATCH 08/23] chore: read options from the generated module and set env after the runtime loads --- packages/kit/src/core/sync/write_server.js | 12 ++---------- packages/kit/src/runtime/server/errors.js | 3 ++- packages/kit/src/runtime/server/index.js | 17 ++++++++--------- packages/kit/src/runtime/server/instance.js | 2 ++ packages/kit/src/runtime/server/internal.js | 15 +-------------- packages/kit/src/runtime/server/page/render.js | 3 ++- packages/kit/src/runtime/server/respond.js | 3 ++- packages/kit/src/types/ambient-private.d.ts | 2 +- packages/kit/src/types/internal.d.ts | 1 + packages/kit/test/mocks/generated-server.js | 8 ++++++++ packages/kit/vitest.kit.config.js | 3 ++- 11 files changed, 31 insertions(+), 38 deletions(-) create mode 100644 packages/kit/test/mocks/generated-server.js diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index dc08013b2166..73667147956f 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -71,12 +71,10 @@ export async function get_hooks() { } /** - * Sets the module-level state the server runtime reads, in the order it has to happen: - * \`building\` and \`prerendering\` before the env module evaluates the user's \`src/env\` config, - * which may read them, and everything else before user modules run + * Sets the module-level state the server runtime reads * @param {import('types').ServerConfigureOptions} opts */ -export async function configure({ building, prerendering, env, manifest, read, assets, fix_stack_trace }) { +export function configure({ building, prerendering, manifest, read, assets, fix_stack_trace }) { if (building) set_building(); if (prerendering) set_prerendering(); @@ -98,12 +96,6 @@ export async function configure({ building, prerendering, env, manifest, read, a ); }); } - - // evaluates the user's \`src/env\` config, which may read any of the above - if (env) { - const { set_env } = await import('/env/config.js'); - set_env(env); - } } export { format_response }; diff --git a/packages/kit/src/runtime/server/errors.js b/packages/kit/src/runtime/server/errors.js index 69c6e1f3bdf0..a2ced02eafb3 100644 --- a/packages/kit/src/runtime/server/errors.js +++ b/packages/kit/src/runtime/server/errors.js @@ -8,7 +8,8 @@ import { import { with_request_store } from '@sveltejs/kit/internal/server'; import { add_deprecated_handle_error_properties, coalesce_to_error } from '../../utils/error.js'; import { negotiate } from '../../utils/http.js'; -import { fix_stack_trace, hooks, options } from './internal.js'; +import { fix_stack_trace, hooks } from './internal.js'; +import { options } from '/server.js'; import { escape_html } from '../../utils/escape.js'; /** diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index a70fcf21c4b8..facfd57b62a0 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -1,19 +1,18 @@ -import { configure as set_state, options } from '/server.js'; -import { set_options } from './internal.js'; - -// set at module scope because prerendering evaluates user modules before `init` runs -set_options(options); +import { configure as set_state } from '/server.js'; /** * Sets the module-level state the runtime reads, then loads the runtime. Everything that - * evaluates user code sits behind this import, so `building`, `prerendering` and `env` are - * in place before any of it runs + * evaluates user code, the env config included, sits behind this import * @param {import('types').ServerConfigureOptions} opts * @returns {Promise} */ export async function configure(opts) { - await set_state(opts); - return import('./instance.js'); + set_state(opts); + + const instance = await import('./instance.js'); + if (opts.env) instance.set_env(opts.env); + + return instance; } /** diff --git a/packages/kit/src/runtime/server/instance.js b/packages/kit/src/runtime/server/instance.js index f8b414eb40ac..2d84b1139861 100644 --- a/packages/kit/src/runtime/server/instance.js +++ b/packages/kit/src/runtime/server/instance.js @@ -151,3 +151,5 @@ function serialise(fn) { } export const respond = IN_WEBCONTAINER ? serialise(respond_to) : respond_to; + +export { set_env } from '/env/config.js'; diff --git a/packages/kit/src/runtime/server/internal.js b/packages/kit/src/runtime/server/internal.js index 943fc4546efb..173354b585a1 100644 --- a/packages/kit/src/runtime/server/internal.js +++ b/packages/kit/src/runtime/server/internal.js @@ -1,4 +1,4 @@ -/** @import { ServerHooks, SSROptions, SSRManifest } from 'types'; */ +/** @import { ServerHooks, SSRManifest } from 'types'; */ import { restore, save } from './dev.js'; import { has_data_suffix, @@ -16,7 +16,6 @@ const styleText = const read_implementation_key = Symbol.for('sveltekit.read_implementation'); const manifest_key = Symbol.for('sveltekit.manifest'); -const options_key = Symbol.for('sveltekit.options'); const hooks_key = Symbol.for('sveltekit.hooks'); export let read_implementation = /** @type {((path: string) => ReadableStream) | null} */ ( @@ -27,10 +26,6 @@ export let manifest = /** @type {SSRManifest} */ ( (__SVELTEKIT_DEV__ && restore(manifest_key)) ?? null ); -export let options = /** @type {SSROptions} */ ( - (__SVELTEKIT_DEV__ && restore(options_key)) ?? null -); - export let hooks = /** @type {ServerHooks} */ ((__SVELTEKIT_DEV__ && restore(hooks_key)) ?? null); /** @@ -50,14 +45,6 @@ export function set_manifest(value) { if (__SVELTEKIT_DEV__) save(manifest_key, value); } -/** - * @param {SSROptions} value - */ -export function set_options(value) { - options = value; - if (__SVELTEKIT_DEV__) save(options_key, value); -} - /** * @param {ServerHooks} value */ diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 9190ea23ea41..575cadce7767 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -28,7 +28,8 @@ import Root from '../../components/root.svelte'; import { render } from 'svelte/server'; import { Props, RenderNode } from '../../props.svelte.js'; import { has_custom_transporters, uneval } from '#app/internal/transport'; -import { manifest, options } from '../internal.js'; +import { manifest } from '../internal.js'; +import { options } from '/server.js'; // TODO rename this function/module diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index b7e158717018..57507786c7b2 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -45,7 +45,8 @@ import { } from '../pathname.js'; import { server_data_serializer } from './page/data_serializer.js'; import { get_remote_id, handle_remote_call } from './remote-functions.js'; -import { hooks, manifest, options } from './internal.js'; +import { hooks, manifest } from './internal.js'; +import { options } from '/server.js'; /** @type {import('types').RequiredResolveOptions['transformPageChunk']} */ const default_transform = ({ html }) => html; diff --git a/packages/kit/src/types/ambient-private.d.ts b/packages/kit/src/types/ambient-private.d.ts index 879b78a6dd9b..252f99e1846a 100644 --- a/packages/kit/src/types/ambient-private.d.ts +++ b/packages/kit/src/types/ambient-private.d.ts @@ -4,7 +4,7 @@ declare module '/server.js' { export const options: SSROptions; export function get_hooks(): Promise>; - export function configure(options: ServerConfigureOptions): Promise; + export function configure(options: ServerConfigureOptions): void; export function format_response(status: number, request: Request): string; } diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 81a9a9dc0549..a6ec0274fe8f 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -49,6 +49,7 @@ export interface ServerConfigureOptions extends Partial { export interface ServerInstance { init(): Promise; respond(request: Request, options: InternalRequestOptions): Promise; + set_env(env: Record): void; } /** the built `server/index.js` */ diff --git a/packages/kit/test/mocks/generated-server.js b/packages/kit/test/mocks/generated-server.js new file mode 100644 index 000000000000..d1f74a7e615e --- /dev/null +++ b/packages/kit/test/mocks/generated-server.js @@ -0,0 +1,8 @@ +// Stub for `/server.js`, which `sync` writes from the user's config + +/** @type {import('types').SSROptions} */ +export const options = /** @type {any} */ ({}); + +export function get_hooks() { + return Promise.resolve({}); +} diff --git a/packages/kit/vitest.kit.config.js b/packages/kit/vitest.kit.config.js index 2d8e35054f69..0d5bf5292b18 100644 --- a/packages/kit/vitest.kit.config.js +++ b/packages/kit/vitest.kit.config.js @@ -32,7 +32,8 @@ export default /** @satisfies {import('vitest/config').ViteUserConfig} */ ({ '#app/paths': mock('app-paths'), '$app/env': mock('app-env'), '$app/paths/internal/client': mock('app-paths-internal-client'), - '$app/paths/internal/server': mock('app-paths-internal-server') + '$app/paths/internal/server': mock('app-paths-internal-server'), + '/server.js': mock('generated-server') }, projects: [ { From 8fa626aee31c220da7870f6e26f1b351ddfcefae Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:54:25 -0400 Subject: [PATCH 09/23] chore: configure the runtime from the entry instead of the generated module --- packages/kit/src/core/sync/write_server.js | 45 +-------------------- packages/kit/src/runtime/server/index.js | 25 +++++++++--- packages/kit/src/runtime/server/internal.js | 20 +++++++-- packages/kit/src/types/ambient-private.d.ts | 4 +- 4 files changed, 38 insertions(+), 56 deletions(-) diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index 73667147956f..9fd1a5c3a58a 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -5,7 +5,6 @@ import { s } from '../../utils/misc.js'; import { load_error_page, load_template } from '../config/index.js'; import { check_spelling, write_if_changed } from './utils.js'; import { escape_html } from '../../utils/escape.js'; -import { runtime_directory } from '../utils.js'; /** * @param {{ @@ -13,20 +12,9 @@ import { runtime_directory } from '../utils.js'; * universal_hooks: string | null; * config: import('types').ValidatedConfig; * template: string; - * runtime_directory: string; * }} opts */ -const server_template = ({ - config, - server_hooks, - universal_hooks, - template, - runtime_directory -}) => ` -import { set_building, set_prerendering } from '$app/env/server'; -import { set_assets } from '$app/paths/internal/server'; -import { set_fix_stack_trace, set_manifest, set_read_implementation, format_response } from '${runtime_directory}/server/internal.js'; -import { stream_from_iterable } from '${runtime_directory}/utils.js'; +const server_template = ({ config, server_hooks, universal_hooks, template }) => ` import error from './shared/error-template.js'; export const options = { @@ -69,36 +57,6 @@ export async function get_hooks() { transport }; } - -/** - * Sets the module-level state the server runtime reads - * @param {import('types').ServerConfigureOptions} opts - */ -export function configure({ building, prerendering, manifest, read, assets, fix_stack_trace }) { - if (building) set_building(); - if (prerendering) set_prerendering(); - - if (manifest) set_manifest(manifest); - if (assets !== undefined) set_assets(assets); - if (fix_stack_trace) set_fix_stack_trace(fix_stack_trace); - - if (read) { - // the public \`read\` may return a promise, the runtime expects a stream - set_read_implementation((file) => { - const result = read(file); - if (result instanceof ReadableStream) return result; - - return stream_from_iterable( - (async function* () { - const stream = await result; - if (stream) yield* stream; - })() - ); - }); - } -} - -export { format_response }; `; /** @@ -139,7 +97,6 @@ export function write_server(config, output, root) { `${output}/server.js`, server_template({ config, - runtime_directory: relative(runtime_directory), server_hooks: server_hooks_file ? relative(server_hooks_file) : null, universal_hooks: universal_hooks_file ? relative(universal_hooks_file) : null, template: load_template(root, config) diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index facfd57b62a0..cdf1554a7642 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -1,4 +1,6 @@ -import { configure as set_state } from '/server.js'; +import { set_building, set_prerendering } from '#app/env/server'; +import { set_assets } from '../app/paths/internal/server.js'; +import { set_fix_stack_trace, set_manifest, set_read_implementation } from './internal.js'; /** * Sets the module-level state the runtime reads, then loads the runtime. Everything that @@ -6,11 +8,24 @@ import { configure as set_state } from '/server.js'; * @param {import('types').ServerConfigureOptions} opts * @returns {Promise} */ -export async function configure(opts) { - set_state(opts); +export async function configure({ + building, + prerendering, + manifest, + read, + assets, + fix_stack_trace, + env +}) { + if (building) set_building(); + if (prerendering) set_prerendering(); + if (manifest) set_manifest(manifest); + if (read) set_read_implementation(read); + if (assets !== undefined) set_assets(assets); + if (fix_stack_trace) set_fix_stack_trace(fix_stack_trace); const instance = await import('./instance.js'); - if (opts.env) instance.set_env(opts.env); + if (env) instance.set_env(env); return instance; } @@ -66,4 +81,4 @@ export class Server { } } -export { format_response } from '/server.js'; +export { format_response } from './internal.js'; diff --git a/packages/kit/src/runtime/server/internal.js b/packages/kit/src/runtime/server/internal.js index 173354b585a1..32db717920ad 100644 --- a/packages/kit/src/runtime/server/internal.js +++ b/packages/kit/src/runtime/server/internal.js @@ -1,5 +1,6 @@ /** @import { ServerHooks, SSRManifest } from 'types'; */ import { restore, save } from './dev.js'; +import { stream_from_iterable } from '../utils.js'; import { has_data_suffix, has_resolution_suffix, @@ -29,11 +30,22 @@ export let manifest = /** @type {SSRManifest} */ ( export let hooks = /** @type {ServerHooks} */ ((__SVELTEKIT_DEV__ && restore(hooks_key)) ?? null); /** - * @param {(path: string) => ReadableStream} fn + * The public `read` may return a promise, the runtime expects a stream + * @param {NonNullable} read */ -export function set_read_implementation(fn) { - read_implementation = fn; - if (__SVELTEKIT_DEV__) save(read_implementation_key, fn); +export function set_read_implementation(read) { + read_implementation = (file) => { + const result = read(file); + if (result instanceof ReadableStream) return result; + + return stream_from_iterable( + (async function* () { + const stream = await result; + if (stream) yield* stream; + })() + ); + }; + if (__SVELTEKIT_DEV__) save(read_implementation_key, read_implementation); } /** diff --git a/packages/kit/src/types/ambient-private.d.ts b/packages/kit/src/types/ambient-private.d.ts index 252f99e1846a..d059d49f868d 100644 --- a/packages/kit/src/types/ambient-private.d.ts +++ b/packages/kit/src/types/ambient-private.d.ts @@ -1,11 +1,9 @@ /** Internal version of $app/server */ declare module '/server.js' { - import { SSROptions, ServerHooks, ServerConfigureOptions } from 'types'; + import { SSROptions, ServerHooks } from 'types'; export const options: SSROptions; export function get_hooks(): Promise>; - export function configure(options: ServerConfigureOptions): void; - export function format_response(status: number, request: Request): string; } declare module '/env/config.js' { From 3dbec6d3faf4c2bf9b6a281d072fb6b695dae349 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:19:47 -0400 Subject: [PATCH 10/23] fix: keep generated imports out of modules `$app/server` reaches --- packages/kit/src/runtime/server/errors.js | 49 +----------------- packages/kit/src/runtime/server/page/index.js | 4 +- .../runtime/server/page/respond_with_error.js | 50 ++++++++++++++++++- packages/kit/src/runtime/server/respond.js | 3 +- packages/kit/test/mocks/generated-server.js | 4 +- 5 files changed, 55 insertions(+), 55 deletions(-) diff --git a/packages/kit/src/runtime/server/errors.js b/packages/kit/src/runtime/server/errors.js index a2ced02eafb3..b67d7a4c1745 100644 --- a/packages/kit/src/runtime/server/errors.js +++ b/packages/kit/src/runtime/server/errors.js @@ -1,4 +1,3 @@ -import { text } from '@sveltejs/kit'; import { HandledHttpError, HttpError, @@ -7,34 +6,8 @@ import { } from '@sveltejs/kit/internal'; import { with_request_store } from '@sveltejs/kit/internal/server'; import { add_deprecated_handle_error_properties, coalesce_to_error } from '../../utils/error.js'; -import { negotiate } from '../../utils/http.js'; +// `$app/server` reaches this module, so it must not import anything generated import { fix_stack_trace, hooks } from './internal.js'; -import { options } from '/server.js'; -import { escape_html } from '../../utils/escape.js'; - -/** - * @param {import('@sveltejs/kit').RequestEvent} event - * @param {import('types').RequestState} state - * @param {unknown} error - */ -export async function handle_fatal_error(event, state, error) { - const body = await handle_error_and_jsonify(event, state, error); - const status = body.status; - - // sec-fetch-dest would be nicer, but non-browser clients and plain HTTP hosts don't send it - const type = negotiate(event.request.headers.get('accept') || 'text/html', [ - 'application/json', - 'text/html' - ]); - - if (event.isDataRequest || type === 'application/json') { - return Response.json(body, { - status - }); - } - - return static_error_page(status, body.message); -} /** * @param {import('@sveltejs/kit').RequestEvent} event @@ -136,23 +109,3 @@ function log_handle_error_hook_failure(error, hook_error) { console.error('Original error:', error); } } - -/** - * Return as a response that renders the error.html - * - * @param {number} status - * @param {string} message - */ -export function static_error_page(status, message) { - let page = options.templates.error({ status, message: escape_html(message) }); - - if (__SVELTEKIT_DEV__) { - // inject Vite HMR client, for easier debugging - page = page.replace('', ''); - } - - return text(page, { - headers: { 'content-type': 'text/html; charset=utf-8' }, - status - }); -} diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index a66753db47e5..136212acdcdb 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -9,7 +9,7 @@ import { add_data_suffix } from '../../pathname.js'; import { build_error_chain, nearest_error_pages } from '../../error-chain.js'; import { redirect_response } from '../utils.js'; import { manifest } from '../internal.js'; -import { static_error_page, handle_error_and_jsonify } from '../errors.js'; +import { handle_error_and_jsonify } from '../errors.js'; import { handle_action_json_request, handle_action_request, @@ -19,10 +19,10 @@ import { import { server_data_serializer, server_data_serializer_json } from './data_serializer.js'; import { load_data, load_server_data } from './load_data.js'; import { render_response } from './render.js'; -import { respond_with_error } from './respond_with_error.js'; import { DEV } from 'esm-env'; import { get_remote_action, handle_remote_form_post } from '../remote-functions.js'; import { PageNodes } from '../../../utils/page_nodes.js'; +import { static_error_page, respond_with_error } from './respond_with_error.js'; /** * The maximum request depth permitted before assuming we're stuck in an infinite loop diff --git a/packages/kit/src/runtime/server/page/respond_with_error.js b/packages/kit/src/runtime/server/page/respond_with_error.js index 2e8cad97d229..96bdc540bc6b 100644 --- a/packages/kit/src/runtime/server/page/respond_with_error.js +++ b/packages/kit/src/runtime/server/page/respond_with_error.js @@ -1,11 +1,15 @@ +import { text } from '@sveltejs/kit'; import { Redirect } from '@sveltejs/kit/internal'; import { render_response } from './render.js'; import { load_data, load_server_data } from './load_data.js'; import { redirect_response } from '../utils.js'; -import { handle_error_and_jsonify, static_error_page } from '../errors.js'; +import { negotiate } from '../../../utils/http.js'; +import { handle_error_and_jsonify } from '../errors.js'; import { PageNodes } from '../../../utils/page_nodes.js'; import { server_data_serializer } from './data_serializer.js'; import { manifest } from '../internal.js'; +import { options } from '/server.js'; +import { escape_html } from '../../../utils/escape.js'; /** * @typedef {import('./types.js').Loaded} Loaded @@ -105,3 +109,47 @@ export async function respond_with_error({ event, state, error, resolve_opts }) return static_error_page(transformed.status, transformed.message); } } + +/** + * Return as a response that renders the error.html + * + * @param {number} status + * @param {string} message + */ +export function static_error_page(status, message) { + let page = options.templates.error({ status, message: escape_html(message) }); + + if (__SVELTEKIT_DEV__) { + // inject Vite HMR client, for easier debugging + page = page.replace('', ''); + } + + return text(page, { + headers: { 'content-type': 'text/html; charset=utf-8' }, + status + }); +} + +/** + * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('types').RequestState} state + * @param {unknown} error + */ +export async function handle_fatal_error(event, state, error) { + const body = await handle_error_and_jsonify(event, state, error); + const status = body.status; + + // sec-fetch-dest would be nicer, but non-browser clients and plain HTTP hosts don't send it + const type = negotiate(event.request.headers.get('accept') || 'text/html', [ + 'application/json', + 'text/html' + ]); + + if (event.isDataRequest || type === 'application/json') { + return Response.json(body, { + status + }); + } + + return static_error_page(status, body.message); +} diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 57507786c7b2..8ef0f27a458b 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -12,10 +12,8 @@ import { base, app_dir } from '#app/paths'; import { is_endpoint_request, render_endpoint } from './endpoint.js'; import { render_page } from './page/index.js'; import { render_response } from './page/render.js'; -import { respond_with_error } from './page/respond_with_error.js'; import { get_self_origin, is_csrf_forbidden, is_remote_forbidden } from './csrf.js'; import { has_prerendered_path, method_not_allowed, redirect_response } from './utils.js'; -import { handle_fatal_error } from './errors.js'; import { decode_pathname, disable_search, @@ -47,6 +45,7 @@ import { server_data_serializer } from './page/data_serializer.js'; import { get_remote_id, handle_remote_call } from './remote-functions.js'; import { hooks, manifest } from './internal.js'; import { options } from '/server.js'; +import { respond_with_error, handle_fatal_error } from './page/respond_with_error.js'; /** @type {import('types').RequiredResolveOptions['transformPageChunk']} */ const default_transform = ({ html }) => html; diff --git a/packages/kit/test/mocks/generated-server.js b/packages/kit/test/mocks/generated-server.js index d1f74a7e615e..b94074c915c4 100644 --- a/packages/kit/test/mocks/generated-server.js +++ b/packages/kit/test/mocks/generated-server.js @@ -1,7 +1,7 @@ // Stub for `/server.js`, which `sync` writes from the user's config -/** @type {import('types').SSROptions} */ -export const options = /** @type {any} */ ({}); +/** @type {any} */ +export const options = {}; export function get_hooks() { return Promise.resolve({}); From 72cfe2552d8397e680a255293fd5b2e0640224a1 Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:12:56 +0000 Subject: [PATCH 11/23] Fix: HEAD response bodies are not stripped when adapters use the recommended `create_server()`/`respond` path, only in the deprecated `Server.respond` wrapper. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes the issue reported at packages/kit/src/runtime/server/index.js:201 ## Bug On the `version-3` branch, HEAD-body stripping lived in the single `Server.respond` method that every adapter used: ```js if (request.method === 'HEAD' && response.body !== null) { response.body.cancel().catch(noop); return new Response(null, response); } ``` After the refactor, the shared implementation moved into the module-level `respond_to()` function, and `create_server()` returns `{ init, respond }` where `respond = respond_to` (or a serialised wrapper). Adapters generated via `builder.generateServerInstance` use this path: - `packages/adapter-vercel/files/serverless.js` → `server.respond(request, {...})` - `packages/adapter-cloudflare/files/worker.js` → `await server.respond(req, {...})` - `packages/adapter-netlify/files/edge.js` → `server.respond(request, {...})` - `packages/adapter-netlify/files/serverless.js` → `server.respond(request, {...})` But the merge added the HEAD handling **only** to the deprecated `Server.respond` method, not to `respond_to`. So the `create_server().respond` / standalone `respond` paths did not strip HEAD bodies. ### Failure mode A `HEAD` request routed through any edge/serverless adapter that returns the `Response` directly to the platform (no HTTP layer to discard the body). The returned `Response` includes a body, which is a regression from version-3 — wasting bandwidth and violating HTTP HEAD semantics for clients. ## Fix Moved the HEAD-body stripping into `respond_to()` so all respond paths (`create_server().respond`, the standalone `respond` export, and the deprecated `Server.respond`) benefit. Simplified the now-redundant deprecated `Server.respond` wrapper to just delegate to `this.#server.respond`. `noop` was already imported at the top of the file. Co-authored-by: Vercel Co-authored-by: teemingc --- packages/kit/src/runtime/server/index.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index c29d05c009cf..6ac0f20436f6 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -131,6 +131,12 @@ async function respond_to(request, options) { response.headers.set(REROUTED_URL_HEADER, request_state.rerouted_url); } + // the HTTP layer discards HEAD response bodies, but nothing does when the server is called directly + if (request.method === 'HEAD' && response.body !== null) { + response.body.cancel().catch(noop); + return new Response(null, response); + } + return response; } @@ -193,15 +199,7 @@ export class Server { * @param {Request} request * @param {import('types').InternalRequestOptions} options */ - async respond(request, options) { - const response = await this.#server.respond(request, options); - - // the HTTP layer discards HEAD response bodies, but nothing does when the server is called directly - if (request.method === 'HEAD' && response.body !== null) { - response.body.cancel().catch(noop); - return new Response(null, response); - } - - return response; + respond(request, options) { + return this.#server.respond(request, options); } } From 1677ceb590687a2276b0d2d5f4581587c2a49e34 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:39:39 -0400 Subject: [PATCH 12/23] chore: keep the call context on the event view instead of cloning the request state `RequestState` carries five `is_in_*` booleans that say what kind of code is on the stack, and the only way to flip one is to clone the whole state object and re-enter the store with the copy. The event is now a class. `event.clone(kind)` is the view for a kind of code, with the kinds as bit flags read through getters like `event.in_render`, and a subclass for what that kind may not do. A clone copies a fixed field list: 18 ns against 305 ns for the spread copy. --- .../kit/src/exports/hooks/sequence.spec.js | 2 +- .../kit/src/exports/internal/server/event.js | 233 +++++++++++++++++- .../src/exports/internal/server/event.spec.js | 104 ++++++++ .../kit/src/exports/internal/server/index.js | 27 +- .../internal}/server/validate-headers.js | 0 .../internal}/server/validate-headers.spec.js | 0 .../src/runtime/app/server/remote/command.js | 20 +- .../kit/src/runtime/app/server/remote/form.js | 4 +- .../runtime/app/server/remote/prerender.js | 12 +- .../app/server/remote/prerender.spec.js | 20 +- .../src/runtime/app/server/remote/query.js | 47 ++-- .../runtime/app/server/remote/requested.js | 2 +- .../src/runtime/app/server/remote/shared.js | 84 +------ packages/kit/src/runtime/server/data/index.js | 4 +- packages/kit/src/runtime/server/endpoint.js | 8 +- packages/kit/src/runtime/server/errors.js | 4 +- packages/kit/src/runtime/server/fetch.js | 2 +- .../kit/src/runtime/server/page/actions.js | 3 +- .../runtime/server/page/data_serializer.js | 4 +- packages/kit/src/runtime/server/page/index.js | 2 +- .../kit/src/runtime/server/page/load_data.js | 6 +- .../kit/src/runtime/server/page/render.js | 12 +- .../runtime/server/page/respond_with_error.js | 4 +- .../src/runtime/server/remote-functions.js | 22 +- .../runtime/server/remote-functions.spec.js | 11 +- packages/kit/src/runtime/server/respond.js | 106 +++----- packages/kit/src/runtime/server/state.js | 11 +- packages/kit/src/runtime/server/utils.js | 2 +- packages/kit/src/types/internal.d.ts | 11 +- 29 files changed, 478 insertions(+), 289 deletions(-) create mode 100644 packages/kit/src/exports/internal/server/event.spec.js rename packages/kit/src/{runtime => exports/internal}/server/validate-headers.js (100%) rename packages/kit/src/{runtime => exports/internal}/server/validate-headers.spec.js (100%) diff --git a/packages/kit/src/exports/hooks/sequence.spec.js b/packages/kit/src/exports/hooks/sequence.spec.js index 00cfaf06b6f7..de46f009c93e 100644 --- a/packages/kit/src/exports/hooks/sequence.spec.js +++ b/packages/kit/src/exports/hooks/sequence.spec.js @@ -14,7 +14,7 @@ vi.mock(import('@sveltejs/kit/internal/server'), async (actualPromise) => { return { ...actual, get_request_store: () => ({ - event: dummy_event, + event: /** @type {any} */ (dummy_event), state: /** @type {RequestState} */ (/** @type {unknown} */ ({})) }) }; diff --git a/packages/kit/src/exports/internal/server/event.js b/packages/kit/src/exports/internal/server/event.js index 3916562a8b00..9ce20508917e 100644 --- a/packages/kit/src/exports/internal/server/event.js +++ b/packages/kit/src/exports/internal/server/event.js @@ -1,7 +1,234 @@ -/** @import { RequestEvent } from '@sveltejs/kit' */ -/** @import { RequestStore } from 'types' */ +/** @import { Cookies, RequestEvent as Interface } from '@sveltejs/kit' */ +/** @import { RequestState, RequestStore } from 'types' */ /** @import { AsyncLocalStorage } from 'node:async_hooks' */ +import { DEV } from 'esm-env'; import { IN_WEBCONTAINER } from '../../../constants.js'; +import { validateHeaders } from './validate-headers.js'; + +/** The kinds of code an event gets handed to, and the groups the runtime asks about */ +export const QUERY = 1; +export const PRERENDER = 2; +export const FORM = 4; +export const COMMAND = 8; +export const RENDER = 16; + +/** The kinds on the stack, kept under a symbol so it is not part of the public shape */ +export const CONTEXT = Symbol('sveltekit.context'); + +const PAGE = Symbol('sveltekit.page'); + +/** What a query view copies its page fields from */ +const NO_PAGE = /** @type {Interface} */ ({}); + +/** @type {Interface['setHeaders']} */ +function forbid_set_headers() { + throw new Error('setHeaders is not allowed in remote functions'); +} + +/** + * What remote functions may do with cookies + * @param {Cookies} cookies + * @param {boolean} read_only + * @returns {Cookies} + */ +function remote_cookies(cookies, read_only) { + /** + * @param {'set' | 'delete'} verb + * @param {import('cookie').SerializeOptions} opts + */ + const check = (verb, opts) => { + if (read_only) { + throw new Error(`Cannot ${verb} cookies in \`query\` or \`prerender\` functions`); + } + if (opts.path && !opts.path.startsWith('/')) { + throw new Error('Cookies in remote functions must have an absolute path'); + } + }; + + return { + ...cookies, + set: (name, value, opts) => { + check('set', opts); + return cookies.set(name, value, opts); + }, + delete: (name, opts) => { + check('delete', opts); + return cookies.delete(name, opts); + } + }; +} + +/** + * @param {RequestState} state + * @param {Record} new_headers + */ +function set_headers(state, new_headers) { + if (state.responded) { + throw new Error('Cannot use `setHeaders(...)` after the response has been generated'); + } + + if (DEV) { + validateHeaders(new_headers); + } + + const { headers } = state; + + for (const key in new_headers) { + const lower = key.toLowerCase(); + const value = new_headers[key]; + + if (lower === 'set-cookie') { + throw new Error( + 'Use `event.cookies.set(name, value, options)` instead of `event.setHeaders` to set cookies' + ); + } else if (lower in headers) { + // appendHeaders-style for Server-Timing https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing + if (lower === 'server-timing') { + headers[lower] += ', ' + value; + } else { + throw new Error(`"${key}" header is already set`); + } + } else { + headers[lower] = value; + + if (state.prerendering && lower === 'cache-control') { + state.prerendering.cache = value; + } + } + } +} + +/** + * The event as one class per kind of restriction, so that a view for a kind of code is a + * clone with the same fields rather than a copy of whatever the source enumerates + * @implements {Interface} + */ +export class RequestEvent { + /** @type {number} */ + [CONTEXT]; + + /** + * @param {Interface} source + * @param {number} flags + */ + constructor(source, flags) { + this.cookies = source.cookies; + this.fetch = source.fetch; + this.getClientAddress = source.getClientAddress; + this.locals = source.locals; + this.platform = source.platform; + this.request = source.request; + this.setHeaders = source.setHeaders; + + const page = this[PAGE](source); + this.url = page.url; + this.params = page.params; + this.route = page.route; + + this.isDataRequest = source.isDataRequest; + this.isSubRequest = source.isSubRequest; + this.isRemoteRequest = source.isRemoteRequest; + this.tracing = source.tracing; + this[CONTEXT] = flags; + } + + /** + * The root event of a request, which every view is cloned from. Its behaviour is created + * once here and shared by reference with the views, so that destructuring keeps working + * @param {Omit} fields + * @param {RequestState} state + * @returns {RequestEvent} + */ + static create(fields, state) { + const event = new RequestEvent(/** @type {Interface} */ (fields), 0); + + event.setHeaders = (new_headers) => set_headers(state, new_headers); + + return event; + } + + /** + * An event a user built by hand for `resolve` becomes one of ours + * @param {Interface} event + * @returns {RequestEvent} + */ + static from(event) { + return event instanceof RequestEvent + ? event + : new RequestEvent(event, /** @type {Partial} */ (event)[CONTEXT] ?? 0); + } + + /** + * Where the fields a query may not read are copied from, so a query view neither reads + * them from its source nor keeps them + * @param {Interface} source + */ + [PAGE](source) { + return source; + } + + /** Inside a `query` function, however deep */ + get in_query() { + return (this[CONTEXT] & QUERY) !== 0; + } + + /** Inside a `query` or `prerender` function, which may not write cookies or call commands */ + get read_only() { + return (this[CONTEXT] & (QUERY | PRERENDER)) !== 0; + } + + /** Inside a `form` or `command` function */ + get in_mutation() { + return (this[CONTEXT] & (FORM | COMMAND)) !== 0; + } + + /** Inside any remote function */ + get in_remote() { + return (this[CONTEXT] & (QUERY | PRERENDER | FORM | COMMAND)) !== 0; + } + + /** While the page renders */ + get in_render() { + return (this[CONTEXT] & RENDER) !== 0; + } + + /** + * The only way to copy an event: a view for the given kind of code (0 for a plain copy), + * minus what that kind may not do, with the kinds already on the stack carried along + * @param {number} kind + * @param {Partial} [overrides] + * @returns {RequestEvent} + */ + clone(kind, overrides) { + const flags = this[CONTEXT] | kind; + const view = new (flags & QUERY ? QueryEvent : RequestEvent)(this, flags); + + if (kind & (QUERY | PRERENDER | FORM | COMMAND)) { + view.cookies = remote_cookies(this.cookies, view.read_only); + view.setHeaders = forbid_set_headers; + } + + return Object.assign(view, overrides); + } +} + +/** A query may not read the page, so a query view never copies it and reads throw */ +class QueryEvent extends RequestEvent { + [PAGE]() { + return NO_PAGE; + } +} + +for (const property of /** @type {const} */ (['url', 'params', 'route'])) { + Object.defineProperty(QueryEvent.prototype, property, { + get() { + throw new Error( + `Cannot access event.${property} in a query. Pass the value as an argument to the query instead` + ); + }, + set() {} + }); +} /** @type {RequestStore | null} */ let sync_store = null; @@ -23,7 +250,7 @@ import('node:async_hooks') * In environments without [`AsyncLocalStorage`](https://nodejs.org/api/async_context.html#class-asynclocalstorage), this must be called synchronously (i.e. not after an `await`). * @since 2.20.0 * - * @returns {RequestEvent} + * @returns {Interface} */ export function getRequestEvent() { const event = try_get_request_store()?.event; diff --git a/packages/kit/src/exports/internal/server/event.spec.js b/packages/kit/src/exports/internal/server/event.spec.js new file mode 100644 index 000000000000..5c9892a1dff2 --- /dev/null +++ b/packages/kit/src/exports/internal/server/event.spec.js @@ -0,0 +1,104 @@ +/** @import { RequestEvent as Interface } from '@sveltejs/kit' */ +/** @import { RequestState } from 'types' */ +import { assert, expect, test } from 'vitest'; +import { RequestEvent, CONTEXT, QUERY, COMMAND, RENDER } from './event.js'; + +function root() { + return new RequestEvent( + /** @type {Interface} */ ( + /** @type {unknown} */ ({ + url: new URL('http://localhost/page'), + params: { id: '1' }, + route: { id: '/page' }, + cookies: { set: () => {}, delete: () => {} }, + setHeaders: () => {}, + tracing: { enabled: false } + }) + ), + 0 + ); +} + +test('flags accumulate through nested views', () => { + const event = root().clone(RENDER).clone(QUERY); + + assert.isTrue(event.in_render); + assert.isTrue(event.in_query); + assert.isTrue(event.in_remote); + assert.isTrue(event.read_only); + assert.isFalse(event.in_mutation); + assert.isFalse(root().in_render); +}); + +test('a query view throws on access to the page, on every copy', () => { + const query = root().clone(QUERY); + const traced = query.clone(0, { locals: {} }); + + for (const event of [query, traced, traced.clone(QUERY)]) { + for (const property of /** @type {const} */ (['url', 'params', 'route'])) { + expect(() => event[property]).toThrow(`Cannot access event.${property} in a query`); + } + } + + assert.equal(root().clone(COMMAND).url.pathname, '/page'); +}); + +test('a spread of a view is not an event any more', () => { + const copy = { ...root().clone(QUERY) }; + + assert.isUndefined(copy.url); + assert.isFalse(copy instanceof RequestEvent); + assert.isUndefined(/** @type {any} */ (copy).in_query); + expect(() => /** @type {any} */ (copy).clone(QUERY)).toThrow(TypeError); +}); + +test('an event built by hand is adopted with its flags', () => { + const own = root().clone(RENDER); + const adopted = RequestEvent.from({ ...own }); + + assert.isTrue(adopted instanceof RequestEvent); + assert.isTrue(adopted.in_render); + assert.strictEqual(RequestEvent.from(own), own); +}); + +test('views share the request data and own nothing else', () => { + const base = root(); + const event = base.clone(QUERY); + + assert.strictEqual(event.locals, base.locals); + assert.deepEqual(Object.getOwnPropertySymbols(event), [CONTEXT]); + assert.isFalse(Object.hasOwn(event, 'url')); +}); + +test('remote views restrict headers and cookies', () => { + const query = root().clone(QUERY); + const command = root().clone(COMMAND); + + expect(() => query.setHeaders({})).toThrow('setHeaders is not allowed'); + expect(() => query.cookies.set('a', 'b', { path: '/' })).toThrow('Cannot set cookies'); + expect(() => command.cookies.set('a', 'b', { path: 'x' })).toThrow('absolute path'); + command.cookies.set('a', 'b', { path: '/' }); +}); + +test('the root event owns setHeaders, which writes to the request state until it responded', () => { + const state = /** @type {RequestState} */ ( + /** @type {unknown} */ ({ headers: {}, responded: false }) + ); + const event = RequestEvent.create(/** @type {any} */ ({ cookies: {} }), state); + const { setHeaders } = event; + + setHeaders({ 'Cache-Control': 'max-age=60', 'Server-Timing': 'a;dur=1' }); + setHeaders({ 'server-timing': 'b;dur=2' }); + assert.deepEqual(state.headers, { + 'cache-control': 'max-age=60', + 'server-timing': 'a;dur=1, b;dur=2' + }); + + expect(() => setHeaders({ 'cache-control': 'no-store' })).toThrow('already set'); + expect(() => setHeaders({ 'set-cookie': 'a=b' })).toThrow('event.cookies.set'); + + assert.strictEqual(event.clone(RENDER).setHeaders, setHeaders); + + state.responded = true; + expect(() => setHeaders({ 'x-a': 'b' })).toThrow('after the response'); +}); diff --git a/packages/kit/src/exports/internal/server/index.js b/packages/kit/src/exports/internal/server/index.js index a48db594f0db..ca7ea3e9e99a 100644 --- a/packages/kit/src/exports/internal/server/index.js +++ b/packages/kit/src/exports/internal/server/index.js @@ -1,5 +1,6 @@ /** @import { Span } from '@opentelemetry/api' */ -import { try_get_request_store } from './event.js'; +/** @import { RequestEvent as Interface } from '@sveltejs/kit' */ +import { RequestEvent, try_get_request_store } from './event.js'; export function get_origin() { // `request.url` rather than `event.url`, which throws inside queries @@ -8,26 +9,26 @@ export function get_origin() { } /** - * @template {{ tracing: { enabled: boolean, root: Span, current: Span } }} T - * @param {T} event_like + * @param {Interface} event * @param {Span} current - * @returns {T} + * @returns {RequestEvent} */ -export function merge_tracing(event_like, current) { - return { - ...event_like, - tracing: { - ...event_like.tracing, - current - } - }; +export function merge_tracing(event, current) { + return RequestEvent.from(event).clone(0, { tracing: { ...event.tracing, current } }); } export { with_request_store, getRequestEvent, get_request_store, - try_get_request_store + try_get_request_store, + RequestEvent, + CONTEXT, + QUERY, + PRERENDER, + FORM, + COMMAND, + RENDER } from './event.js'; export { init_remote_functions } from './remote-functions.js'; diff --git a/packages/kit/src/runtime/server/validate-headers.js b/packages/kit/src/exports/internal/server/validate-headers.js similarity index 100% rename from packages/kit/src/runtime/server/validate-headers.js rename to packages/kit/src/exports/internal/server/validate-headers.js diff --git a/packages/kit/src/runtime/server/validate-headers.spec.js b/packages/kit/src/exports/internal/server/validate-headers.spec.js similarity index 100% rename from packages/kit/src/runtime/server/validate-headers.spec.js rename to packages/kit/src/exports/internal/server/validate-headers.spec.js diff --git a/packages/kit/src/runtime/app/server/remote/command.js b/packages/kit/src/runtime/app/server/remote/command.js index ddc9fcb9be9e..8afccf6cbf5d 100644 --- a/packages/kit/src/runtime/app/server/remote/command.js +++ b/packages/kit/src/runtime/app/server/remote/command.js @@ -1,7 +1,7 @@ /** @import { RemoteCommand } from '$app/server' */ /** @import { MaybePromise, RemoteCommandInternals } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ -import { get_request_store } from '@sveltejs/kit/internal/server'; +import { get_request_store, COMMAND } from '@sveltejs/kit/internal/server'; import { create_validator, run_remote_function } from './shared.js'; import { MUTATIVE_METHODS } from '../../../../constants.js'; @@ -64,26 +64,22 @@ export function command(validate_or_fn, maybe_fn) { /** @type {RemoteCommand & { __: RemoteCommandInternals }} */ const wrapper = (arg) => { const { event, state } = get_request_store(); + const nested = event.read_only; - if ( - !MUTATIVE_METHODS.includes(event.request.method) || - state.is_in_remote_query || - state.is_in_remote_prerender - ) { - const violation = - state.is_in_remote_query || state.is_in_remote_prerender - ? `inside a query or prerender function` - : `from a ${event.request.method} handler`; + if (nested || !MUTATIVE_METHODS.includes(event.request.method)) { + const violation = nested + ? `inside a query or prerender function` + : `from a ${event.request.method} handler`; throw new Error(`Cannot call a command (${__.name}) ${violation}`); } - if (state.is_in_render) { + if (event.in_render) { throw new Error(`Cannot call a command (${__.name}) during server-side rendering`); } const promise = Promise.resolve( - run_remote_function(event, state, true, () => validate(arg), fn) + run_remote_function(event, state, COMMAND, () => validate(arg), fn) ); // @ts-expect-error diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index f7d7de6521e3..5a2ed87f3cad 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -1,7 +1,7 @@ /** @import { RemoteFormInput, RemoteForm, RemoteFormInvalidField } from '$app/server' */ /** @import { InternalRemoteFormIssue, MaybePromise, HasNonOptionalBoolean, RemoteFormInternals } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ -import { get_request_store } from '@sveltejs/kit/internal/server'; +import { FORM, get_request_store } from '@sveltejs/kit/internal/server'; import { create_field_proxy, set_nested_value, @@ -118,7 +118,7 @@ export function form(validate_or_fn, maybe_fn) { output.result = await run_remote_function( event, state, - true, + FORM, () => data, (data) => (!maybe_fn ? fn() : fn(data, issue)) ); diff --git a/packages/kit/src/runtime/app/server/remote/prerender.js b/packages/kit/src/runtime/app/server/remote/prerender.js index b7aa91f10ad3..a8b5d07504e3 100644 --- a/packages/kit/src/runtime/app/server/remote/prerender.js +++ b/packages/kit/src/runtime/app/server/remote/prerender.js @@ -2,7 +2,7 @@ /** @import { RemoteFunctionResponse, RemotePrerenderInputsGenerator, RemotePrerenderInternals, MaybePromise } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { HandledHttpError } from '@sveltejs/kit/internal'; -import { get_request_store } from '@sveltejs/kit/internal/server'; +import { get_request_store, PRERENDER } from '@sveltejs/kit/internal/server'; import { stringify_remote_arg } from '../../../shared.js'; import { parse, stringify } from '#app/internal/transport'; import { noop } from '../../../../utils/functions.js'; @@ -90,7 +90,7 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { // implicit lookup, so that the result is inlined into the page payload (`data.p`) // and the client doesn't need to fetch it again upon hydration /** @type {Promise & Partial>} */ - const promise = get_response(__, payload, state, async () => { + const promise = get_response(__, payload, event, state, async () => { const id = __.id; const url = `${base}/${app_dir}/remote/${id}${payload ? `/${payload}` : ''}`; @@ -126,13 +126,7 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { return /** @type {Promise} */ (state.prerendering.remote_responses.get(url)); } - const promise = run_remote_function( - event, - { ...state, is_in_remote_prerender: true }, - false, - () => validate(arg), - fn - ); + const promise = run_remote_function(event, state, PRERENDER, () => validate(arg), fn); if (state.prerendering) { state.prerendering.remote_responses.set(url, promise); diff --git a/packages/kit/src/runtime/app/server/remote/prerender.spec.js b/packages/kit/src/runtime/app/server/remote/prerender.spec.js index 73b418882b7c..eeeca72eb077 100644 --- a/packages/kit/src/runtime/app/server/remote/prerender.spec.js +++ b/packages/kit/src/runtime/app/server/remote/prerender.spec.js @@ -1,8 +1,8 @@ -/** @import { RequestEvent } from '@sveltejs/kit' */ /** @import { RequestState } from 'types' */ import { expect, test, vi } from 'vitest'; import { HandledHttpError, ValidationError } from '@sveltejs/kit/internal'; import { prerender } from './prerender.js'; +import { RequestEvent } from '@sveltejs/kit/internal/server'; import { init_transport, stringify } from '#app/internal/transport'; init_transport({}); @@ -33,18 +33,20 @@ function setup(fetch_impl) { /** @type {any} */ (wrapper).__.id = 'hash/fn'; store.current = { - event: /** @type {RequestEvent} */ ( - /** @type {unknown} */ ({ - request: { url: 'http://localhost/' }, - isRemoteRequest: false, - cookies: {} - }) + event: new RequestEvent( + /** @type {import('@sveltejs/kit').RequestEvent} */ ( + /** @type {unknown} */ ({ + request: { url: 'http://localhost/' }, + isRemoteRequest: false, + cookies: {} + }) + ), + 0 ), state: /** @type {RequestState} */ ( /** @type {unknown} */ ({ remote: {}, - prerendering: undefined, - is_in_remote_query: false + prerendering: undefined }) ) }; diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 7eb5bf20490a..06ee8525bec1 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -1,8 +1,8 @@ /** @import { RemoteLiveQuery, RemoteLiveQueryFunction, RemoteQuery, RemoteQueryFunction } from '$app/server' */ -/** @import { RequestEvent } from '@sveltejs/kit' */ +/** @import { RequestEvent } from '@sveltejs/kit/internal/server' */ /** @import { RemoteInternals, MaybePromise, RequestState, RemoteQueryLiveInternals, RemoteQueryBatchInternals, RemoteQueryInternals, RemoteLiveQueryUserFunctionReturnType } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ -import { get_request_store } from '@sveltejs/kit/internal/server'; +import { get_request_store, QUERY } from '@sveltejs/kit/internal/server'; import { create_remote_key, stringify_remote_arg } from '../../../shared.js'; import { prerendering } from '#app/env/server'; import { @@ -79,13 +79,7 @@ export function query(validate_or_fn, maybe_fn) { const { event, state } = get_request_store(); return create_query_resource(__, payload, event, state, () => - run_remote_function( - event, - { ...state, is_in_remote_query: true }, - false, - () => validated_arg, - fn - ) + run_remote_function(event, state, QUERY, () => validated_arg, fn) ); } }; @@ -102,13 +96,7 @@ export function query(validate_or_fn, maybe_fn) { const payload = stringify_remote_arg(arg); return create_query_resource(__, payload, event, state, () => - run_remote_function( - event, - { ...state, is_in_remote_query: true }, - false, - () => validate(arg), - fn - ) + run_remote_function(event, state, QUERY, () => validate(arg), fn) ); }; @@ -164,14 +152,7 @@ function live(validate_or_fn, maybe_fn) { * @param {any} get_input */ const run = (event, state, get_input) => - run_remote_generator( - event, - { ...state, is_in_remote_query: true }, - false, - get_input, - fn, - __.name - ); + run_remote_generator(event, state, QUERY, get_input, fn, __.name); /** @type {RemoteQueryLiveInternals} */ const __ = { @@ -292,8 +273,8 @@ function batch(validate_or_fn, maybe_fn) { try { return await run_remote_function( event, - { ...state, is_in_remote_query: true }, - false, + state, + QUERY, async () => Promise.all(entries.map((entry) => entry.get_validated())), async (input) => { const get_result = await fn(input); @@ -335,8 +316,8 @@ function batch(validate_or_fn, maybe_fn) { return run_remote_function( event, - { ...state, is_in_remote_query: true }, - false, + state, + QUERY, async () => Promise.all(args.map(validate)), async (/** @type {any[]} */ input) => { const get_result = await fn(input); @@ -405,7 +386,7 @@ export function refresh(event, state, internals, payload, fn) { return; } - if (!event.isRemoteRequest && state.is_in_remote_form_or_command) { + if (!event.isRemoteRequest && event.in_mutation) { // ...or this is a no-JS (native) form submission, where the page re-renders // anyway so there's no live client cache to apply a single-flight update to. return; @@ -437,14 +418,14 @@ function create_query_resource(__, payload, event, state, fn) { let promise = null; const get_promise = () => { - return (promise ??= get_response(__, payload, state, fn)); + return (promise ??= get_response(__, payload, event, state, fn)); }; const populate_hydratable = () => { // accessing data properties needs to kick off the work // so that it gets seeded in the hydration cache // and becomes available on the client - if (__.id && state.is_in_render) { + if (__.id && event.in_render) { // swallow rejections so they don't crash the server — the error is // serialized into the response and surfaced on the client instead get_promise().catch(noop); @@ -524,11 +505,11 @@ function create_live_query_resource(__, payload, event, state, get_generator) { }; const get_promise = () => { - return (promise ??= get_response(__, payload, state, get_first_value)); + return (promise ??= get_response(__, payload, event, state, get_first_value)); }; const populate_hydratable = () => { - if (__.id && state.is_in_render) { + if (__.id && event.in_render) { // swallow rejections so they don't crash the server — the error is // serialized into the response and surfaced on the client instead get_promise().catch(noop); diff --git a/packages/kit/src/runtime/app/server/remote/requested.js b/packages/kit/src/runtime/app/server/remote/requested.js index 29a1f3075b07..79fea7852bf3 100644 --- a/packages/kit/src/runtime/app/server/remote/requested.js +++ b/packages/kit/src/runtime/app/server/remote/requested.js @@ -138,7 +138,7 @@ export function requested(query, limit) { // command/form wrapper when we enter them, and if we initialize them here // we will enable requested(...) in contexts where it shouldn't be allowed, // such as load functions or other server functions - if (!state.is_in_remote_form_or_command) { + if (!event.in_mutation) { throw new Error( 'requested(...) can only be called in the context of a command/form remote function' ); diff --git a/packages/kit/src/runtime/app/server/remote/shared.js b/packages/kit/src/runtime/app/server/remote/shared.js index ad74bcbcc04f..f3b4eec105db 100644 --- a/packages/kit/src/runtime/app/server/remote/shared.js +++ b/packages/kit/src/runtime/app/server/remote/shared.js @@ -1,5 +1,5 @@ -/** @import { RequestEvent } from '@sveltejs/kit' */ -/** @import { MaybePromise, RequestState, RemoteInternals, RequestStore, RemoteLiveQueryUserFunctionReturnType } from 'types' */ +/** @import { RequestEvent } from '@sveltejs/kit/internal/server' */ +/** @import { MaybePromise, RequestState, RemoteInternals, RemoteLiveQueryUserFunctionReturnType } from 'types' */ import { error } from '@sveltejs/kit'; import { ValidationError } from '@sveltejs/kit/internal'; import { with_request_store } from '@sveltejs/kit/internal/server'; @@ -54,18 +54,19 @@ export function create_validator(validate_or_fn, maybe_fn) { * @template {MaybePromise} T * @param {RemoteInternals} internals * @param {string} payload — the stringified raw argument (i.e. the cache key the client will use) + * @param {RequestEvent} event * @param {RequestState} state * @param {() => Promise} get_result * @returns {Promise} */ -export async function get_response(internals, payload, state, get_result) { +export async function get_response(internals, payload, event, state, get_result) { // wait a beat, in case `myQuery().set(...)` or `myQuery().refresh()` is immediately called // eslint-disable-next-line @typescript-eslint/await-thenable await 0; const cache = get_cache(internals, state); - if (!state.is_in_remote_query) { + if (!event.in_query) { // if this is a top-level (not nested) `await myQuery()`, include it in the serialized response get_implicit_lookup(internals, state)[payload] = get_result; } @@ -73,80 +74,17 @@ export async function get_response(internals, payload, state, get_result) { return (cache[payload] ??= get_result()); } -/** - * @param {RequestEvent} event - * @param {RequestState} state - * @param {boolean} allow_cookies - * @returns {RequestStore} - */ -function derive_remote_function_event(event, state, allow_cookies) { - /** @type {RequestEvent} */ - const derived = { - ...event, - setHeaders: () => { - throw new Error('setHeaders is not allowed in remote functions'); - }, - cookies: { - ...event.cookies, - set: (name, value, opts) => { - if (!allow_cookies) { - throw new Error('Cannot set cookies in `query` or `prerender` functions'); - } - - if (opts.path && !opts.path.startsWith('/')) { - throw new Error('Cookies set in remote functions must have an absolute path'); - } - - return event.cookies.set(name, value, opts); - }, - delete: (name, opts) => { - if (!allow_cookies) { - throw new Error('Cannot delete cookies in `query` or `prerender` functions'); - } - - if (opts.path && !opts.path.startsWith('/')) { - throw new Error('Cookies deleted in remote functions must have an absolute path'); - } - - return event.cookies.delete(name, opts); - } - } - }; - - if (state.is_in_remote_query) { - for (const property of ['url', 'params', 'route']) { - // non-enumerable so spreading for a nested derivation doesn't invoke the getter - Object.defineProperty(derived, property, { - enumerable: false, - get() { - throw new Error( - `Cannot access event.${property} in a query. Pass the value as an argument to the query instead` - ); - } - }); - } - } - - return { - event: derived, - state: { - ...state, - is_in_remote_function: true - } - }; -} - /** * Like `with_event` but removes things from `event` you cannot see/call in remote functions, such as `setHeaders`. * @template T * @param {RequestEvent} event * @param {RequestState} state - * @param {boolean} allow_cookies + * @param {number} kind * @param {() => any} get_input * @param {(arg?: any) => T} fn */ -export async function run_remote_function(event, state, allow_cookies, get_input, fn) { - const store = derive_remote_function_event(event, state, allow_cookies); +export async function run_remote_function(event, state, kind, get_input, fn) { + const store = { event: event.clone(kind), state }; // In two parts, each with_event, so that runtimes without async local storage can still get the event at the start of the function const input = await with_request_store(store, get_input); @@ -158,13 +96,13 @@ export async function run_remote_function(event, state, allow_cookies, get_input * @template T * @param {RequestEvent} event * @param {RequestState} state - * @param {boolean} allow_cookies + * @param {number} kind * @param {() => any} get_input * @param {(arg?: any) => RemoteLiveQueryUserFunctionReturnType} fn * @param {string} name */ -export async function* run_remote_generator(event, state, allow_cookies, get_input, fn, name) { - const store = derive_remote_function_event(event, state, allow_cookies); +export async function* run_remote_generator(event, state, kind, get_input, fn, name) { + const store = { event: event.clone(kind), state }; // In two parts, each with_event, so that runtimes without async local storage can still get the event at the start of the function / calls to next const input = await with_request_store(store, get_input); diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index 85052e10b0b2..afa9038fb83c 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -11,7 +11,7 @@ import { with_version_header } from '../utils.js'; import { manifest } from '../internal.js'; /** - * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('@sveltejs/kit/internal/server').RequestEvent} event * @param {import('types').RequestState} state * @param {{ page: Pick | null }} route * @param {boolean[] | undefined} invalidated_data_nodes @@ -33,7 +33,7 @@ export async function render_data(event, state, route, invalidated_data_nodes, t const url = new URL(event.url); url.pathname = normalize_path(url.pathname, trailing_slash); - const new_event = { ...event, url }; + const new_event = event.clone(0, { url }); const functions = node_ids.map((n, i) => { return once(async () => { diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js index 7f16c177f8d4..ee7134f90122 100644 --- a/packages/kit/src/runtime/server/endpoint.js +++ b/packages/kit/src/runtime/server/endpoint.js @@ -5,7 +5,7 @@ import { negotiate } from '../../utils/http.js'; import { method_not_allowed } from './utils.js'; /** - * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('@sveltejs/kit/internal/server').RequestEvent} event * @param {import('types').RequestState} state * @param {import('types').SSREndpoint} mod * @returns {Promise} @@ -46,9 +46,7 @@ export async function render_endpoint(event, state, mod) { } try { - const response = await with_request_store({ event, state }, () => - handler(/** @type {import('@sveltejs/kit').RequestEvent>} */ (event)) - ); + const response = await with_request_store({ event, state }, () => handler(event)); if (!(response instanceof Response)) { throw new Error( @@ -94,7 +92,7 @@ export async function render_endpoint(event, state, mod) { } /** - * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('@sveltejs/kit/internal/server').RequestEvent} event */ export function is_endpoint_request(event) { const { method, headers } = event.request; diff --git a/packages/kit/src/runtime/server/errors.js b/packages/kit/src/runtime/server/errors.js index b67d7a4c1745..f46db4614278 100644 --- a/packages/kit/src/runtime/server/errors.js +++ b/packages/kit/src/runtime/server/errors.js @@ -10,7 +10,7 @@ import { add_deprecated_handle_error_properties, coalesce_to_error } from '../.. import { fix_stack_trace, hooks } from './internal.js'; /** - * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('@sveltejs/kit/internal/server').RequestEvent} event * @param {import('types').RequestState} state * @param {any} error * @returns {App.Error | Promise} @@ -69,7 +69,7 @@ export function handle_error_and_jsonify(event, state, error) { } if (result instanceof Promise) { - if (!__SVELTEKIT_SUPPORTS_ASYNC__ && state.is_in_render) { + if (!__SVELTEKIT_SUPPORTS_ASYNC__ && event.in_render) { console.warn( `To use an async \`handleError\` hook to handle errors that occur during rendering, you must enable \`compilerOptions.experimental.async\` in the SvelteKit plugin of your Vite config. The returned error has been replaced with a generic object` ); diff --git a/packages/kit/src/runtime/server/fetch.js b/packages/kit/src/runtime/server/fetch.js index 12ccd1086ea2..823ab261da10 100644 --- a/packages/kit/src/runtime/server/fetch.js +++ b/packages/kit/src/runtime/server/fetch.js @@ -8,7 +8,7 @@ import { fork_state_for_subrequest } from './state.js'; /** * @param {{ - * event: import('@sveltejs/kit').RequestEvent; + * event: import('@sveltejs/kit/internal/server').RequestEvent; * state: import('types').RequestState; * get_cookie_header: (url: URL, header: string | null) => string; * set_internal: (name: string, value: string, opts: import('./page/types.js').Cookie['options']) => void; diff --git a/packages/kit/src/runtime/server/page/actions.js b/packages/kit/src/runtime/server/page/actions.js index 6699856221fe..d415efce386e 100644 --- a/packages/kit/src/runtime/server/page/actions.js +++ b/packages/kit/src/runtime/server/page/actions.js @@ -1,4 +1,5 @@ -/** @import { RequestEvent, Actions } from '@sveltejs/kit' */ +/** @import { Actions } from '@sveltejs/kit' */ +/** @import { RequestEvent } from '@sveltejs/kit/internal/server' */ /** @import { ActionResult } from '$app/forms' */ /** @import { SSRNode, ServerNode, ServerActionResult } from 'types' */ import { DEV } from 'esm-env'; diff --git a/packages/kit/src/runtime/server/page/data_serializer.js b/packages/kit/src/runtime/server/page/data_serializer.js index 286373e89e83..c2aef76ca7a3 100644 --- a/packages/kit/src/runtime/server/page/data_serializer.js +++ b/packages/kit/src/runtime/server/page/data_serializer.js @@ -8,7 +8,7 @@ import { encoders } from '#app/internal/transport'; /** * If the serialized data contains promises, `chunks` will be an * async iterable containing their resolutions - * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('@sveltejs/kit/internal/server').RequestEvent} event * @param {import('types').RequestState} state * @returns {import('./types.js').ServerDataSerializer} */ @@ -123,7 +123,7 @@ export function server_data_serializer(event, state) { /** * If the serialized data contains promises, `chunks` will be an * async iterable containing their resolutions - * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('@sveltejs/kit/internal/server').RequestEvent} event * @param {import('types').RequestState} state * @returns {import('./types.js').ServerDataSerializerJson} */ diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index 136212acdcdb..0cb892d35d5f 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -1,4 +1,4 @@ -/** @import { RequestEvent } from '@sveltejs/kit' */ +/** @import { RequestEvent } from '@sveltejs/kit/internal/server' */ /** @import { PageNodeIndexes, RequestState, RequiredResolveOptions, ServerDataNode, SSRNode } from 'types' */ import { text } from '@sveltejs/kit'; import { Redirect } from '@sveltejs/kit/internal'; diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js index 93941f4c6bec..a2b64e6a91fc 100644 --- a/packages/kit/src/runtime/server/page/load_data.js +++ b/packages/kit/src/runtime/server/page/load_data.js @@ -10,7 +10,7 @@ import { get_node_type } from '../utils.js'; /** * Calls the user's server `load` function. * @param {{ - * event: import('@sveltejs/kit').RequestEvent; + * event: import('@sveltejs/kit/internal/server').RequestEvent; * state: import('types').RequestState; * node: import('types').SSRNode | undefined; * parent: () => Promise>; @@ -191,7 +191,7 @@ export async function load_server_data({ event, state, node, parent }) { /** * Calls the user's `load` function. * @param {{ - * event: import('@sveltejs/kit').RequestEvent; + * event: import('@sveltejs/kit/internal/server').RequestEvent; * state: import('types').RequestState; * fetched: import('./types.js').Fetched[]; * node: import('types').SSRNode | undefined; @@ -256,7 +256,7 @@ export async function load_data({ } /** - * @param {Pick} event + * @param {Pick} event * @param {import('types').PrerenderOptions | undefined} prerendering * @param {import('./types.js').Fetched[]} fetched * @param {boolean} csr diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 575cadce7767..ee6ade8dc62a 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -18,7 +18,7 @@ import { add_resolution_suffix, route_id_resolution_pathname } from '../../pathname.js'; -import { try_get_request_store, with_request_store } from '@sveltejs/kit/internal/server'; +import { try_get_request_store, with_request_store, RENDER } from '@sveltejs/kit/internal/server'; import { stream_text } from '../../utils.js'; import { count_non_ssi_comments } from '../utils.js'; import { handle_error_and_jsonify } from '../errors.js'; @@ -41,7 +41,7 @@ import { options } from '/server.js'; * page_config: { ssr: boolean; csr: boolean }; * status: number; * error: App.Error | null; - * event: import('@sveltejs/kit').RequestEvent; + * event: import('@sveltejs/kit/internal/server').RequestEvent; * state: import('types').RequestState; * resolve_opts: import('types').RequiredResolveOptions; * action_result?: import('types').ServerActionResult; @@ -183,7 +183,7 @@ export async function render_response({ props.page.data = data; - const render_state = { ...state, is_in_render: true }; + const render_event = event.clone(RENDER); const render_opts = { context: new Map([ @@ -201,7 +201,7 @@ export async function render_response({ throw e; } - const handled = handle_error_and_jsonify(event, render_state, e); + const handled = handle_error_and_jsonify(render_event, state, e); // TODO 4.0 make this an async function and await `handled` if (handled instanceof Promise) { @@ -232,7 +232,7 @@ export async function render_response({ throw new Error( `Cannot call \`fetch\` eagerly during server-side rendering with relative URL (${info}) — put your \`fetch\` calls inside \`onMount\` or a \`load\` function instead` ); - } else if (!warned && !try_get_request_store()?.state.is_in_remote_function) { + } else if (!warned && !(try_get_request_store()?.event ?? event).in_remote) { console.warn( 'Avoid calling `fetch` eagerly during server-side rendering — put your `fetch` calls inside `onMount` or a `load` function instead' ); @@ -243,7 +243,7 @@ export async function render_response({ }; } - rendered = await with_request_store({ event, state: render_state }, async () => { + rendered = await with_request_store({ event: render_event, state }, async () => { return render(Root, { ...render_opts, props }); }); diff --git a/packages/kit/src/runtime/server/page/respond_with_error.js b/packages/kit/src/runtime/server/page/respond_with_error.js index 96bdc540bc6b..6769a1d5729e 100644 --- a/packages/kit/src/runtime/server/page/respond_with_error.js +++ b/packages/kit/src/runtime/server/page/respond_with_error.js @@ -17,7 +17,7 @@ import { escape_html } from '../../../utils/escape.js'; /** * @param {{ - * event: import('@sveltejs/kit').RequestEvent; + * event: import('@sveltejs/kit/internal/server').RequestEvent; * state: import('types').RequestState; * error: unknown; * resolve_opts: import('types').RequiredResolveOptions; @@ -131,7 +131,7 @@ export function static_error_page(status, message) { } /** - * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('@sveltejs/kit/internal/server').RequestEvent} event * @param {import('types').RequestState} state * @param {unknown} error */ diff --git a/packages/kit/src/runtime/server/remote-functions.js b/packages/kit/src/runtime/server/remote-functions.js index 5c6eadee66ed..dac97d0661ce 100644 --- a/packages/kit/src/runtime/server/remote-functions.js +++ b/packages/kit/src/runtime/server/remote-functions.js @@ -1,4 +1,4 @@ -/** @import { RequestEvent } from '@sveltejs/kit' */ +/** @import { RequestEvent } from '@sveltejs/kit/internal/server' */ /** @import { RemoteForm } from '$app/server' */ /** @import { RemoteFormInternals, RemoteFunctionData, RemoteFunctionResponse, RemoteInternals, RequestState, ServerActionResult } from 'types' */ @@ -35,12 +35,11 @@ const KEEP_ALIVE_INTERVAL = 30_000; */ export function create_live_query_response(event, state, internals, arg) { const cancellation = new AbortController(); - const live_event = { - ...event, + const live_event = event.clone(0, { request: new Request(event.request, { signal: AbortSignal.any([event.request.signal, cancellation.signal]) }) - }; + }); const generator = internals.run(live_event, state, arg); @@ -257,10 +256,7 @@ async function handle_remote_call_internal(event, state, id) { } const fn = internals.fn; - data._ = await with_request_store( - { event, state: { ...state, is_in_remote_form_or_command: true } }, - () => fn(input, meta, form_data) - ); + data._ = await with_request_store({ event, state }, () => fn(input, meta, form_data)); if (data._.issues) { // special case — don't serialize refreshes/reconnects @@ -282,10 +278,7 @@ async function handle_remote_call_internal(event, state, id) { state.remote.requested = create_requested_map(refreshes); const arg = parse_remote_arg(payload); - data._ = await with_request_store( - { event, state: { ...state, is_in_remote_form_or_command: true } }, - () => fn(arg) - ); + data._ = await with_request_store({ event, state }, () => fn(arg)); break; } @@ -565,10 +558,7 @@ async function handle_remote_form_post_internal(event, state, id) { data.id = JSON.parse(decodeURIComponent(action_id)); } - await with_request_store( - { event, state: { ...state, is_in_remote_form_or_command: true } }, - () => __.fn(data, meta, form_data) - ); + await with_request_store({ event, state }, () => __.fn(data, meta, form_data)); // We don't want the data to appear on `let { form } = $props()`, which is why we're not returning it. // It is instead available on `myForm.result`, setting of which happens within the remote `form` function. diff --git a/packages/kit/src/runtime/server/remote-functions.spec.js b/packages/kit/src/runtime/server/remote-functions.spec.js index c09a4fe307b5..38f72575f5d7 100644 --- a/packages/kit/src/runtime/server/remote-functions.spec.js +++ b/packages/kit/src/runtime/server/remote-functions.spec.js @@ -1,6 +1,6 @@ import { beforeAll, expect, test, vi } from 'vitest'; import { init_transport, parse } from '#app/internal/transport'; -import { get_request_store } from '@sveltejs/kit/internal/server'; +import { get_request_store, RequestEvent } from '@sveltejs/kit/internal/server'; const decoder = new TextDecoder(); @@ -24,9 +24,12 @@ beforeAll(async () => { * @param {(event: import('@sveltejs/kit').RequestEvent) => AsyncGenerator} run */ function create_response(run) { - const event = /** @type {import('@sveltejs/kit').RequestEvent} */ ({ - request: new Request('http://localhost/_app/remote/test?payload=undefined') - }); + const event = new RequestEvent( + /** @type {import('@sveltejs/kit').RequestEvent} */ ({ + request: new Request('http://localhost/_app/remote/test?payload=undefined') + }), + 0 + ); return create_live_query_response( event, diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 8ef0f27a458b..7442dcde9b2e 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -6,7 +6,8 @@ import { merge_tracing, otel, record_span, - with_request_store + with_request_store, + RequestEvent } from '@sveltejs/kit/internal/server'; import { base, app_dir } from '#app/paths'; import { is_endpoint_request, render_endpoint } from './endpoint.js'; @@ -30,7 +31,6 @@ import { action_json_redirect, is_action_json_request } from './page/actions.js' import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM } from '../shared.js'; import { get_public_env } from './env_module.js'; import { resolve_route, resolve_route_by_id } from './page/server_routing.js'; -import { validateHeaders } from './validate-headers.js'; import { add_data_suffix, add_resolution_suffix, @@ -174,72 +174,39 @@ export async function internal_respond(request, state) { } } - /** @type {Record} */ - const headers = {}; - const { cookies, new_cookies, get_cookie_header, set_internal, set_trailing_slash } = get_cookies( request, url ); - /** @type {import('@sveltejs/kit').RequestEvent} */ - const event = { - cookies, - // @ts-expect-error `fetch` needs to be created after the `event` itself - fetch: null, - getClientAddress: - state.getClientAddress || - (() => { - throw new Error( - `${__SVELTEKIT_ADAPTER_NAME__} does not specify getClientAddress. Please raise an issue` - ); - }), - locals: {}, - params: {}, - platform: state.emulator?.platform - ? await state.emulator.platform({ - config: {}, - prerender: !!state.prerendering?.fallback - }) - : state.platform, - request, - route: { id: null }, - setHeaders: (new_headers) => { - if (DEV) { - validateHeaders(new_headers); - } - - for (const key in new_headers) { - const lower = key.toLowerCase(); - const value = new_headers[key]; - - if (lower === 'set-cookie') { + const event = RequestEvent.create( + { + cookies, + getClientAddress: + state.getClientAddress || + (() => { throw new Error( - 'Use `event.cookies.set(name, value, options)` instead of `event.setHeaders` to set cookies' + `${__SVELTEKIT_ADAPTER_NAME__} does not specify getClientAddress. Please raise an issue` ); - } else if (lower in headers) { - // appendHeaders-style for Server-Timing https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing - if (lower === 'server-timing') { - headers[lower] += ', ' + value; - } else { - throw new Error(`"${key}" header is already set`); - } - } else { - headers[lower] = value; - - if (state.prerendering && lower === 'cache-control') { - state.prerendering.cache = /** @type {string} */ (value); - } - } - } + }), + locals: {}, + params: {}, + platform: state.emulator?.platform + ? await state.emulator.platform({ + config: {}, + prerender: !!state.prerendering?.fallback + }) + : state.platform, + request, + route: { id: null }, + url, + isDataRequest: is_data_request, + isSubRequest: state.depth > 0, + isRemoteRequest: !!remote_id }, - url, - isDataRequest: is_data_request, - isSubRequest: state.depth > 0, - isRemoteRequest: !!remote_id - }; + state + ); - // @ts-expect-error this has to be assigned lazily event.fetch = create_fetch({ event, state, @@ -369,9 +336,7 @@ export async function internal_respond(request, state) { if (result) { route = result.route; - // @ts-expect-error this has to be assigned lazily event.route = { id: route.id }; - // @ts-expect-error this has to be assigned lazily event.params = result.params; } } catch (e) { @@ -433,7 +398,6 @@ export async function internal_respond(request, state) { } if (state.emulator?.platform) { - // @ts-expect-error this has to be assigned lazily event.platform = await state.emulator.platform({ config, prerender }); } @@ -479,14 +443,13 @@ export async function internal_respond(request, state) { 'sveltekit.is_sub_request': event.isSubRequest }, fn: async (root_span) => { - const traced_event = { - ...event, + const traced_event = event.clone(0, { tracing: { enabled: __SVELTEKIT_SERVER_TRACING_ENABLED__, root: root_span, current: root_span } - }; + }); return await with_request_store({ event: traced_event, state }, () => hooks.handle({ @@ -505,9 +468,8 @@ export async function internal_respond(request, state) { (response) => { // add headers/cookies here, rather than inside `resolve`, so that we // can do it once for all responses instead of once per `return` - for (const key in headers) { - const value = headers[key]; - response.headers.set(key, /** @type {string} */ (value)); + for (const key in state.headers) { + response.headers.set(key, state.headers[key]); } add_cookies_to_headers(response.headers, new_cookies.values()); @@ -578,7 +540,7 @@ export async function internal_respond(request, state) { } /** - * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('@sveltejs/kit/internal/server').RequestEvent} event * @param {PageNodes | undefined} page_nodes * @param {import('@sveltejs/kit/hooks').ResolveOptions} [opts] */ @@ -788,14 +750,10 @@ export async function internal_respond(request, state) { // HttpError from endpoint can end up here - TODO should it be handled there instead? return await handle_fatal_error(event, state, e); } finally { + state.responded = true; event.cookies.set = () => { throw new Error('Cannot use `cookies.set(...)` after the response has been generated'); }; - - // @ts-expect-error this has to be assigned lazily - event.setHeaders = () => { - throw new Error('Cannot use `setHeaders(...)` after the response has been generated'); - }; } } } diff --git a/packages/kit/src/runtime/server/state.js b/packages/kit/src/runtime/server/state.js index 77905dc4ac88..4506096f4437 100644 --- a/packages/kit/src/runtime/server/state.js +++ b/packages/kit/src/runtime/server/state.js @@ -1,8 +1,10 @@ /** @import { InternalRequestOptions, RequestState } from 'types' */ -/** Per-request caches and context flags — never carried into a fork. */ +/** Per-request caches — never carried into a fork. */ function transient_fields() { return { + headers: {}, + responded: false, remote: { data: null, explicit: null, @@ -12,12 +14,7 @@ function transient_fields() { ignored: null, batches: null, live_iterators: null - }, - is_in_remote_function: false, - is_in_remote_form_or_command: false, - is_in_remote_query: false, - is_in_remote_prerender: false, - is_in_render: false + } }; } diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js index 7a9155629bf6..ef1a5881fb73 100644 --- a/packages/kit/src/runtime/server/utils.js +++ b/packages/kit/src/runtime/server/utils.js @@ -53,7 +53,7 @@ export function with_version_header(response) { } /** - * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('@sveltejs/kit/internal/server').RequestEvent} event * @param {Error & { path: string }} error */ export function clarify_devalue_error(event, error) { diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index a6ec0274fe8f..51d0304e579e 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -733,6 +733,10 @@ export interface RequestState { * Allows us to prevent `event.fetch` from making infinitely looping internal requests. */ readonly depth: number; + /** Set through `event.setHeaders`, added to the response once there is one */ + readonly headers: Record; + /** Once the response exists, `setHeaders` throws */ + responded: boolean; readonly remote: { /** Resolved query/prerender data, populated by `await myQuery()` or `myQuery.set(...)` */ data: null | Map>>; @@ -780,15 +784,10 @@ export interface RequestState { */ live_iterators: null | Map>; }; - readonly is_in_remote_function: boolean; - readonly is_in_remote_form_or_command: boolean; - readonly is_in_remote_query: boolean; - readonly is_in_remote_prerender: boolean; - readonly is_in_render: boolean; } export interface RequestStore { - event: RequestEvent; + event: import('../exports/internal/server/event.js').RequestEvent; state: RequestState; } From a600d7f96df57d54924309ae5d7cebe6f12bbf86 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:40:23 -0400 Subject: [PATCH 13/23] restrict cookies in remote views through a class instead of a wrapper with closures --- .../kit/src/exports/internal/server/event.js | 68 +++++++++++++------ 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/packages/kit/src/exports/internal/server/event.js b/packages/kit/src/exports/internal/server/event.js index 9ce20508917e..4bf1ec8dc73c 100644 --- a/packages/kit/src/exports/internal/server/event.js +++ b/packages/kit/src/exports/internal/server/event.js @@ -27,35 +27,65 @@ function forbid_set_headers() { /** * What remote functions may do with cookies - * @param {Cookies} cookies - * @param {boolean} read_only - * @returns {Cookies} + * @implements {Cookies} */ -function remote_cookies(cookies, read_only) { +class RemoteCookies { + #cookies; + #read_only; + + /** + * @param {Cookies} cookies + * @param {boolean} read_only + */ + constructor(cookies, read_only) { + this.#cookies = cookies; + this.#read_only = read_only; + } + /** * @param {'set' | 'delete'} verb * @param {import('cookie').SerializeOptions} opts */ - const check = (verb, opts) => { - if (read_only) { + #check(verb, opts) { + if (this.#read_only) { throw new Error(`Cannot ${verb} cookies in \`query\` or \`prerender\` functions`); } if (opts.path && !opts.path.startsWith('/')) { throw new Error('Cookies in remote functions must have an absolute path'); } - }; + } - return { - ...cookies, - set: (name, value, opts) => { - check('set', opts); - return cookies.set(name, value, opts); - }, - delete: (name, opts) => { - check('delete', opts); - return cookies.delete(name, opts); - } - }; + /** @type {Cookies['get']} */ + get(name, opts) { + return this.#cookies.get(name, opts); + } + + /** @type {Cookies['getAll']} */ + getAll(opts) { + return this.#cookies.getAll(opts); + } + + /** @type {Cookies['serialize']} */ + serialize(name, value, opts) { + return this.#cookies.serialize(name, value, opts); + } + + /** @type {Cookies['parse']} */ + parse(header, opts) { + return this.#cookies.parse(header, opts); + } + + /** @type {Cookies['set']} */ + set(name, value, opts) { + this.#check('set', opts); + return this.#cookies.set(name, value, opts); + } + + /** @type {Cookies['delete']} */ + delete(name, opts) { + this.#check('delete', opts); + return this.#cookies.delete(name, opts); + } } /** @@ -204,7 +234,7 @@ export class RequestEvent { const view = new (flags & QUERY ? QueryEvent : RequestEvent)(this, flags); if (kind & (QUERY | PRERENDER | FORM | COMMAND)) { - view.cookies = remote_cookies(this.cookies, view.read_only); + view.cookies = new RemoteCookies(this.cookies, view.read_only); view.setHeaders = forbid_set_headers; } From dfdbdf6c785a53eb05bc5f264314a63b630ea3c2 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:22:12 -0400 Subject: [PATCH 14/23] branch on the flags for the page fields instead of dispatching through a method --- .../kit/src/exports/internal/server/event.js | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/packages/kit/src/exports/internal/server/event.js b/packages/kit/src/exports/internal/server/event.js index 4bf1ec8dc73c..8d4979248092 100644 --- a/packages/kit/src/exports/internal/server/event.js +++ b/packages/kit/src/exports/internal/server/event.js @@ -15,8 +15,6 @@ export const RENDER = 16; /** The kinds on the stack, kept under a symbol so it is not part of the public shape */ export const CONTEXT = Symbol('sveltekit.context'); -const PAGE = Symbol('sveltekit.page'); - /** What a query view copies its page fields from */ const NO_PAGE = /** @type {Interface} */ ({}); @@ -150,7 +148,8 @@ export class RequestEvent { this.request = source.request; this.setHeaders = source.setHeaders; - const page = this[PAGE](source); + // a query view neither reads the page from its source nor keeps it + const page = flags & QUERY ? NO_PAGE : source; this.url = page.url; this.params = page.params; this.route = page.route; @@ -188,15 +187,6 @@ export class RequestEvent { : new RequestEvent(event, /** @type {Partial} */ (event)[CONTEXT] ?? 0); } - /** - * Where the fields a query may not read are copied from, so a query view neither reads - * them from its source nor keeps them - * @param {Interface} source - */ - [PAGE](source) { - return source; - } - /** Inside a `query` function, however deep */ get in_query() { return (this[CONTEXT] & QUERY) !== 0; @@ -243,11 +233,7 @@ export class RequestEvent { } /** A query may not read the page, so a query view never copies it and reads throw */ -class QueryEvent extends RequestEvent { - [PAGE]() { - return NO_PAGE; - } -} +class QueryEvent extends RequestEvent {} for (const property of /** @type {const} */ (['url', 'params', 'route'])) { Object.defineProperty(QueryEvent.prototype, property, { From 1f92dde1e753e2bdba3b6dcdf4fdd5aa286e9d06 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:18:40 -0400 Subject: [PATCH 15/23] give the query view its own constructor and store overrides at the call site --- .../kit/src/exports/internal/server/event.js | 55 +++++++++++++------ .../src/exports/internal/server/event.spec.js | 2 +- .../kit/src/exports/internal/server/index.js | 4 +- packages/kit/src/runtime/server/data/index.js | 3 +- .../src/runtime/server/remote-functions.js | 7 +-- packages/kit/src/runtime/server/respond.js | 13 ++--- 6 files changed, 54 insertions(+), 30 deletions(-) diff --git a/packages/kit/src/exports/internal/server/event.js b/packages/kit/src/exports/internal/server/event.js index 8d4979248092..d4cf3dd6fc94 100644 --- a/packages/kit/src/exports/internal/server/event.js +++ b/packages/kit/src/exports/internal/server/event.js @@ -15,9 +15,6 @@ export const RENDER = 16; /** The kinds on the stack, kept under a symbol so it is not part of the public shape */ export const CONTEXT = Symbol('sveltekit.context'); -/** What a query view copies its page fields from */ -const NO_PAGE = /** @type {Interface} */ ({}); - /** @type {Interface['setHeaders']} */ function forbid_set_headers() { throw new Error('setHeaders is not allowed in remote functions'); @@ -147,13 +144,9 @@ export class RequestEvent { this.platform = source.platform; this.request = source.request; this.setHeaders = source.setHeaders; - - // a query view neither reads the page from its source nor keeps it - const page = flags & QUERY ? NO_PAGE : source; - this.url = page.url; - this.params = page.params; - this.route = page.route; - + this.url = source.url; + this.params = source.params; + this.route = source.route; this.isDataRequest = source.isDataRequest; this.isSubRequest = source.isSubRequest; this.isRemoteRequest = source.isRemoteRequest; @@ -216,24 +209,54 @@ export class RequestEvent { * The only way to copy an event: a view for the given kind of code (0 for a plain copy), * minus what that kind may not do, with the kinds already on the stack carried along * @param {number} kind - * @param {Partial} [overrides] * @returns {RequestEvent} */ - clone(kind, overrides) { + clone(kind) { const flags = this[CONTEXT] | kind; - const view = new (flags & QUERY ? QueryEvent : RequestEvent)(this, flags); + const view = + flags & QUERY + ? /** @type {RequestEvent} */ (new QueryEvent(this, flags)) + : new RequestEvent(this, flags); if (kind & (QUERY | PRERENDER | FORM | COMMAND)) { view.cookies = new RemoteCookies(this.cookies, view.read_only); view.setHeaders = forbid_set_headers; } - return Object.assign(view, overrides); + return view; + } +} + +/** + * A query may not read the page, so a query view never copies it and reads throw. It copies + * its own field list instead of extending `RequestEvent`, which would run the parent + * constructor and hand its stores a second shape + */ +class QueryEvent { + /** @type {number} */ + [CONTEXT]; + + /** + * @param {Interface} source + * @param {number} flags + */ + constructor(source, flags) { + this.cookies = source.cookies; + this.fetch = source.fetch; + this.getClientAddress = source.getClientAddress; + this.locals = source.locals; + this.platform = source.platform; + this.request = source.request; + this.setHeaders = source.setHeaders; + this.isDataRequest = source.isDataRequest; + this.isSubRequest = source.isSubRequest; + this.isRemoteRequest = source.isRemoteRequest; + this.tracing = source.tracing; + this[CONTEXT] = flags; } } -/** A query may not read the page, so a query view never copies it and reads throw */ -class QueryEvent extends RequestEvent {} +Object.setPrototypeOf(QueryEvent.prototype, RequestEvent.prototype); for (const property of /** @type {const} */ (['url', 'params', 'route'])) { Object.defineProperty(QueryEvent.prototype, property, { diff --git a/packages/kit/src/exports/internal/server/event.spec.js b/packages/kit/src/exports/internal/server/event.spec.js index 5c9892a1dff2..8b3f7a93cf4d 100644 --- a/packages/kit/src/exports/internal/server/event.spec.js +++ b/packages/kit/src/exports/internal/server/event.spec.js @@ -32,7 +32,7 @@ test('flags accumulate through nested views', () => { test('a query view throws on access to the page, on every copy', () => { const query = root().clone(QUERY); - const traced = query.clone(0, { locals: {} }); + const traced = query.clone(0); for (const event of [query, traced, traced.clone(QUERY)]) { for (const property of /** @type {const} */ (['url', 'params', 'route'])) { diff --git a/packages/kit/src/exports/internal/server/index.js b/packages/kit/src/exports/internal/server/index.js index ca7ea3e9e99a..8d4430f2b2e1 100644 --- a/packages/kit/src/exports/internal/server/index.js +++ b/packages/kit/src/exports/internal/server/index.js @@ -14,7 +14,9 @@ export function get_origin() { * @returns {RequestEvent} */ export function merge_tracing(event, current) { - return RequestEvent.from(event).clone(0, { tracing: { ...event.tracing, current } }); + const traced = RequestEvent.from(event).clone(0); + traced.tracing = { ...event.tracing, current }; + return traced; } export { diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index afa9038fb83c..38400736e588 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -33,7 +33,8 @@ export async function render_data(event, state, route, invalidated_data_nodes, t const url = new URL(event.url); url.pathname = normalize_path(url.pathname, trailing_slash); - const new_event = event.clone(0, { url }); + const new_event = event.clone(0); + new_event.url = url; const functions = node_ids.map((n, i) => { return once(async () => { diff --git a/packages/kit/src/runtime/server/remote-functions.js b/packages/kit/src/runtime/server/remote-functions.js index dac97d0661ce..660339d70c6a 100644 --- a/packages/kit/src/runtime/server/remote-functions.js +++ b/packages/kit/src/runtime/server/remote-functions.js @@ -35,10 +35,9 @@ const KEEP_ALIVE_INTERVAL = 30_000; */ export function create_live_query_response(event, state, internals, arg) { const cancellation = new AbortController(); - const live_event = event.clone(0, { - request: new Request(event.request, { - signal: AbortSignal.any([event.request.signal, cancellation.signal]) - }) + const live_event = event.clone(0); + live_event.request = new Request(event.request, { + signal: AbortSignal.any([event.request.signal, cancellation.signal]) }); const generator = internals.run(live_event, state, arg); diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 7442dcde9b2e..4b87f3255884 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -443,13 +443,12 @@ export async function internal_respond(request, state) { 'sveltekit.is_sub_request': event.isSubRequest }, fn: async (root_span) => { - const traced_event = event.clone(0, { - tracing: { - enabled: __SVELTEKIT_SERVER_TRACING_ENABLED__, - root: root_span, - current: root_span - } - }); + const traced_event = event.clone(0); + traced_event.tracing = { + enabled: __SVELTEKIT_SERVER_TRACING_ENABLED__, + root: root_span, + current: root_span + }; return await with_request_store({ event: traced_event, state }, () => hooks.handle({ From df4665ade2c48cd9a0a904f05d0b2e5318698355 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:35:18 -0400 Subject: [PATCH 16/23] drop what the query view no longer needs --- packages/kit/src/exports/internal/server/event.js | 9 ++++----- packages/kit/src/exports/internal/server/index.js | 1 - packages/kit/src/runtime/app/server/remote/requested.js | 4 ++-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/kit/src/exports/internal/server/event.js b/packages/kit/src/exports/internal/server/event.js index d4cf3dd6fc94..f5f8e9182657 100644 --- a/packages/kit/src/exports/internal/server/event.js +++ b/packages/kit/src/exports/internal/server/event.js @@ -124,8 +124,8 @@ function set_headers(state, new_headers) { } /** - * The event as one class per kind of restriction, so that a view for a kind of code is a - * clone with the same fields rather than a copy of whatever the source enumerates + * The event as a class, so that a view for a kind of code is a clone with a fixed field list + * rather than a copy of whatever the source enumerates * @implements {Interface} */ export class RequestEvent { @@ -258,14 +258,13 @@ class QueryEvent { Object.setPrototypeOf(QueryEvent.prototype, RequestEvent.prototype); -for (const property of /** @type {const} */ (['url', 'params', 'route'])) { +for (const property of ['url', 'params', 'route']) { Object.defineProperty(QueryEvent.prototype, property, { get() { throw new Error( `Cannot access event.${property} in a query. Pass the value as an argument to the query instead` ); - }, - set() {} + } }); } diff --git a/packages/kit/src/exports/internal/server/index.js b/packages/kit/src/exports/internal/server/index.js index 8d4430f2b2e1..14b86f9b0fc8 100644 --- a/packages/kit/src/exports/internal/server/index.js +++ b/packages/kit/src/exports/internal/server/index.js @@ -25,7 +25,6 @@ export { get_request_store, try_get_request_store, RequestEvent, - CONTEXT, QUERY, PRERENDER, FORM, diff --git a/packages/kit/src/runtime/app/server/remote/requested.js b/packages/kit/src/runtime/app/server/remote/requested.js index 79fea7852bf3..fb62ee5988a1 100644 --- a/packages/kit/src/runtime/app/server/remote/requested.js +++ b/packages/kit/src/runtime/app/server/remote/requested.js @@ -134,8 +134,8 @@ export function requested(query, limit) { ignored.add(create_remote_key(__.id, payload)); }; - // note: don't initialize these maps here -- they will be initialized by the - // command/form wrapper when we enter them, and if we initialize them here + // note: don't initialize these maps here -- the remote call handler initializes + // them when it handles a form or command call, and if we initialize them here // we will enable requested(...) in contexts where it shouldn't be allowed, // such as load functions or other server functions if (!event.in_mutation) { From e03132482b306d4ae20ce0733ca58f0843c08b74 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv Date: Mon, 14 Sep 2026 18:59:49 -0400 Subject: [PATCH 17/23] Update packages/kit/src/exports/internal/server/event.js Co-authored-by: Elliott Johnson --- packages/kit/src/exports/internal/server/event.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/kit/src/exports/internal/server/event.js b/packages/kit/src/exports/internal/server/event.js index f5f8e9182657..8854a2c2da2a 100644 --- a/packages/kit/src/exports/internal/server/event.js +++ b/packages/kit/src/exports/internal/server/event.js @@ -163,9 +163,7 @@ export class RequestEvent { */ static create(fields, state) { const event = new RequestEvent(/** @type {Interface} */ (fields), 0); - event.setHeaders = (new_headers) => set_headers(state, new_headers); - return event; } From 3cda5c8cb89a709507dac3a7066a972417906d96 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv Date: Mon, 14 Sep 2026 21:04:34 -0400 Subject: [PATCH 18/23] Update packages/kit/src/exports/internal/server/event.js Co-authored-by: Elliott Johnson --- packages/kit/src/exports/internal/server/event.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/exports/internal/server/event.js b/packages/kit/src/exports/internal/server/event.js index 8854a2c2da2a..b5668b980b7d 100644 --- a/packages/kit/src/exports/internal/server/event.js +++ b/packages/kit/src/exports/internal/server/event.js @@ -98,7 +98,7 @@ function set_headers(state, new_headers) { const { headers } = state; - for (const key in new_headers) { + for (const key of Object.keys(new_headers)) { const lower = key.toLowerCase(); const value = new_headers[key]; From a5acab6dc0fbe0a63ff432001168125197f555c0 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:40:12 -0400 Subject: [PATCH 19/23] trace through a method on the event and name the kinds a command checks --- packages/kit/src/exports/hooks/sequence.js | 10 ++-- .../kit/src/exports/internal/server/event.js | 31 +++++++---- .../src/exports/internal/server/event.spec.js | 6 +- .../kit/src/exports/internal/server/index.js | 15 +---- .../src/runtime/app/server/remote/command.js | 2 +- packages/kit/src/runtime/server/data/index.js | 2 +- .../kit/src/runtime/server/page/actions.js | 4 +- .../kit/src/runtime/server/page/load_data.js | 6 +- .../src/runtime/server/remote-functions.js | 8 +-- .../runtime/server/remote-functions.spec.js | 16 +++--- packages/kit/src/runtime/server/respond.js | 55 +++++++++---------- 11 files changed, 76 insertions(+), 79 deletions(-) diff --git a/packages/kit/src/exports/hooks/sequence.js b/packages/kit/src/exports/hooks/sequence.js index 8dd0b546c84e..359aefae22c0 100644 --- a/packages/kit/src/exports/hooks/sequence.js +++ b/packages/kit/src/exports/hooks/sequence.js @@ -1,7 +1,7 @@ -/** @import { RequestEvent } from '@sveltejs/kit' */ +/** @import { RequestEvent as Interface } from '@sveltejs/kit' */ /** @import { Handle, ResolveOptions } from '@sveltejs/kit/hooks' */ import { - merge_tracing, + RequestEvent, get_request_store, record_span, with_request_store @@ -90,7 +90,7 @@ export function sequence(...handlers) { /** * @param {number} i - * @param {RequestEvent} event + * @param {Interface} event * @param {ResolveOptions | undefined} parent_options * @returns {Promise} */ @@ -101,7 +101,9 @@ export function sequence(...handlers) { name: `sveltekit.handle.sequenced.${handle.name ? handle.name : i}`, attributes: {}, fn: async (current) => { - const traced_event = merge_tracing(event, current); + const traced_event = RequestEvent.from(event); + traced_event.tracing = { ...event.tracing, current }; + return await with_request_store({ event: traced_event, state }, () => handle({ event: traced_event, diff --git a/packages/kit/src/exports/internal/server/event.js b/packages/kit/src/exports/internal/server/event.js index b5668b980b7d..38c1b078eda1 100644 --- a/packages/kit/src/exports/internal/server/event.js +++ b/packages/kit/src/exports/internal/server/event.js @@ -1,4 +1,5 @@ /** @import { Cookies, RequestEvent as Interface } from '@sveltejs/kit' */ +/** @import { Span } from '@opentelemetry/api' */ /** @import { RequestState, RequestStore } from 'types' */ /** @import { AsyncLocalStorage } from 'node:async_hooks' */ import { DEV } from 'esm-env'; @@ -168,13 +169,13 @@ export class RequestEvent { } /** - * An event a user built by hand for `resolve` becomes one of ours + * A copy of an event that may have been built by hand for `resolve` * @param {Interface} event * @returns {RequestEvent} */ static from(event) { return event instanceof RequestEvent - ? event + ? event.clone() : new RequestEvent(event, /** @type {Partial} */ (event)[CONTEXT] ?? 0); } @@ -183,9 +184,9 @@ export class RequestEvent { return (this[CONTEXT] & QUERY) !== 0; } - /** Inside a `query` or `prerender` function, which may not write cookies or call commands */ - get read_only() { - return (this[CONTEXT] & (QUERY | PRERENDER)) !== 0; + /** Inside a `prerender` function, however deep */ + get in_prerender() { + return (this[CONTEXT] & PRERENDER) !== 0; } /** Inside a `form` or `command` function */ @@ -204,12 +205,12 @@ export class RequestEvent { } /** - * The only way to copy an event: a view for the given kind of code (0 for a plain copy), - * minus what that kind may not do, with the kinds already on the stack carried along - * @param {number} kind + * The only way to copy an event: a view for the given kind of code, minus what that kind + * may not do, with the kinds already on the stack carried along + * @param {number} [kind] * @returns {RequestEvent} */ - clone(kind) { + clone(kind = 0) { const flags = this[CONTEXT] | kind; const view = flags & QUERY @@ -217,12 +218,22 @@ export class RequestEvent { : new RequestEvent(this, flags); if (kind & (QUERY | PRERENDER | FORM | COMMAND)) { - view.cookies = new RemoteCookies(this.cookies, view.read_only); + view.cookies = new RemoteCookies(this.cookies, view.in_query || view.in_prerender); view.setHeaders = forbid_set_headers; } return view; } + + /** + * @param {Span} current + * @returns {RequestEvent} + */ + traced(current) { + const view = this.clone(); + view.tracing = { ...this.tracing, current }; + return view; + } } /** diff --git a/packages/kit/src/exports/internal/server/event.spec.js b/packages/kit/src/exports/internal/server/event.spec.js index 8b3f7a93cf4d..63155bb82b3b 100644 --- a/packages/kit/src/exports/internal/server/event.spec.js +++ b/packages/kit/src/exports/internal/server/event.spec.js @@ -25,14 +25,14 @@ test('flags accumulate through nested views', () => { assert.isTrue(event.in_render); assert.isTrue(event.in_query); assert.isTrue(event.in_remote); - assert.isTrue(event.read_only); + assert.isFalse(event.in_prerender); assert.isFalse(event.in_mutation); assert.isFalse(root().in_render); }); test('a query view throws on access to the page, on every copy', () => { const query = root().clone(QUERY); - const traced = query.clone(0); + const traced = query.clone(); for (const event of [query, traced, traced.clone(QUERY)]) { for (const property of /** @type {const} */ (['url', 'params', 'route'])) { @@ -58,7 +58,7 @@ test('an event built by hand is adopted with its flags', () => { assert.isTrue(adopted instanceof RequestEvent); assert.isTrue(adopted.in_render); - assert.strictEqual(RequestEvent.from(own), own); + assert.notStrictEqual(RequestEvent.from(own), own); }); test('views share the request data and own nothing else', () => { diff --git a/packages/kit/src/exports/internal/server/index.js b/packages/kit/src/exports/internal/server/index.js index 14b86f9b0fc8..c68599190030 100644 --- a/packages/kit/src/exports/internal/server/index.js +++ b/packages/kit/src/exports/internal/server/index.js @@ -1,6 +1,4 @@ -/** @import { Span } from '@opentelemetry/api' */ -/** @import { RequestEvent as Interface } from '@sveltejs/kit' */ -import { RequestEvent, try_get_request_store } from './event.js'; +import { try_get_request_store } from './event.js'; export function get_origin() { // `request.url` rather than `event.url`, which throws inside queries @@ -8,17 +6,6 @@ export function get_origin() { return request && new URL(request.url).origin; } -/** - * @param {Interface} event - * @param {Span} current - * @returns {RequestEvent} - */ -export function merge_tracing(event, current) { - const traced = RequestEvent.from(event).clone(0); - traced.tracing = { ...event.tracing, current }; - return traced; -} - export { with_request_store, getRequestEvent, diff --git a/packages/kit/src/runtime/app/server/remote/command.js b/packages/kit/src/runtime/app/server/remote/command.js index 8afccf6cbf5d..680ade03dc70 100644 --- a/packages/kit/src/runtime/app/server/remote/command.js +++ b/packages/kit/src/runtime/app/server/remote/command.js @@ -64,7 +64,7 @@ export function command(validate_or_fn, maybe_fn) { /** @type {RemoteCommand & { __: RemoteCommandInternals }} */ const wrapper = (arg) => { const { event, state } = get_request_store(); - const nested = event.read_only; + const nested = event.in_query || event.in_prerender; if (nested || !MUTATIVE_METHODS.includes(event.request.method)) { const violation = nested diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index 38400736e588..d2138671fdcc 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -33,7 +33,7 @@ export async function render_data(event, state, route, invalidated_data_nodes, t const url = new URL(event.url); url.pathname = normalize_path(url.pathname, trailing_slash); - const new_event = event.clone(0); + const new_event = event.clone(); new_event.url = url; const functions = node_ids.map((n, i) => { diff --git a/packages/kit/src/runtime/server/page/actions.js b/packages/kit/src/runtime/server/page/actions.js index d415efce386e..b4a1f19f5b1d 100644 --- a/packages/kit/src/runtime/server/page/actions.js +++ b/packages/kit/src/runtime/server/page/actions.js @@ -4,7 +4,7 @@ /** @import { SSRNode, ServerNode, ServerActionResult } from 'types' */ import { DEV } from 'esm-env'; import { HttpError, Redirect, ActionFailure, SvelteKitError } from '@sveltejs/kit/internal'; -import { with_request_store, merge_tracing, record_span } from '@sveltejs/kit/internal/server'; +import { with_request_store, record_span } from '@sveltejs/kit/internal/server'; import { normalize_error } from '../../../utils/error.js'; import { is_form_content_type, negotiate } from '../../../utils/http.js'; import { with_version_header } from '../utils.js'; @@ -256,7 +256,7 @@ async function call_action(event, state, actions) { 'http.route': event.route.id || 'unknown' }, fn: async (current) => { - const traced_event = merge_tracing(event, current); + const traced_event = event.traced(current); const result = await with_request_store({ event: traced_event, state }, () => action(traced_event) diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js index a2b64e6a91fc..f9c0b6d9f401 100644 --- a/packages/kit/src/runtime/server/page/load_data.js +++ b/packages/kit/src/runtime/server/page/load_data.js @@ -2,7 +2,7 @@ import { DEV } from 'esm-env'; import { noop } from '../../../utils/functions.js'; import { disable_search, make_trackable } from '../../../utils/url.js'; import { fetch_cache_url, validate_depends, validate_load_response } from '../../shared.js'; -import { with_request_store, merge_tracing, record_span } from '@sveltejs/kit/internal/server'; +import { with_request_store, record_span } from '@sveltejs/kit/internal/server'; import { base64_encode } from '../../utils.js'; import { NULL_BODY_STATUS } from '../constants.js'; import { get_node_type } from '../utils.js'; @@ -80,7 +80,7 @@ export async function load_server_data({ event, state, node, parent }) { 'http.route': event.route.id || 'unknown' }, fn: async (current) => { - const traced_event = merge_tracing(event, current); + const traced_event = event.traced(current); const result = await with_request_store({ event: traced_event, state }, () => load.call(null, { ...traced_event, @@ -229,7 +229,7 @@ export async function load_data({ 'http.route': event.route.id || 'unknown' }, fn: async (current) => { - const traced_event = merge_tracing(event, current); + const traced_event = event.traced(current); return await with_request_store({ event: traced_event, state }, () => load.call(null, { diff --git a/packages/kit/src/runtime/server/remote-functions.js b/packages/kit/src/runtime/server/remote-functions.js index 660339d70c6a..669b86d0abee 100644 --- a/packages/kit/src/runtime/server/remote-functions.js +++ b/packages/kit/src/runtime/server/remote-functions.js @@ -4,7 +4,7 @@ import { error } from '@sveltejs/kit'; import { Redirect, SvelteKitError } from '@sveltejs/kit/internal'; -import { with_request_store, merge_tracing, record_span } from '@sveltejs/kit/internal/server'; +import { with_request_store, record_span } from '@sveltejs/kit/internal/server'; import { app_dir, base } from '#app/paths'; import { is_form_content_type } from '../../utils/http.js'; import { create_remote_key, parse_remote_arg, split_remote_key } from '../shared.js'; @@ -35,7 +35,7 @@ const KEEP_ALIVE_INTERVAL = 30_000; */ export function create_live_query_response(event, state, internals, arg) { const cancellation = new AbortController(); - const live_event = event.clone(0); + const live_event = event.clone(); live_event.request = new Request(event.request, { signal: AbortSignal.any([event.request.signal, cancellation.signal]) }); @@ -146,7 +146,7 @@ export async function handle_remote_call(event, state, id) { 'sveltekit.remote.call.id': id }, fn: async (current) => { - const traced_event = merge_tracing(event, current); + const traced_event = event.traced(current); const response = await with_request_store({ event: traced_event, state }, () => handle_remote_call_internal(traced_event, state, id) ); @@ -512,7 +512,7 @@ export async function handle_remote_form_post(event, state, id) { 'sveltekit.remote.form.post.id': id }, fn: (current) => { - const traced_event = merge_tracing(event, current); + const traced_event = event.traced(current); return with_request_store({ event: traced_event, state }, () => handle_remote_form_post_internal(traced_event, state, id) ); diff --git a/packages/kit/src/runtime/server/remote-functions.spec.js b/packages/kit/src/runtime/server/remote-functions.spec.js index 38f72575f5d7..cb9fb99489aa 100644 --- a/packages/kit/src/runtime/server/remote-functions.spec.js +++ b/packages/kit/src/runtime/server/remote-functions.spec.js @@ -119,13 +119,15 @@ test('serializes explicitly ignored requested updates', async () => { ); const response = await handle_remote_call( - /** @type {any} */ ({ - request: new Request('http://localhost/_app/remote/hash/command', { - method: 'POST', - body: JSON.stringify({ payload: '', refreshes: ['hash/query/[-1]'] }) - }), - tracing: { current: { setAttributes: vi.fn() } } - }), + RequestEvent.from( + /** @type {any} */ ({ + request: new Request('http://localhost/_app/remote/hash/command', { + method: 'POST', + body: JSON.stringify({ payload: '', refreshes: ['hash/query/[-1]'] }) + }), + tracing: { current: { setAttributes: vi.fn() } } + }) + ), /** @type {any} */ ({ remote: { requested: null, ignored: null } }), 'hash/command' ); diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 4b87f3255884..a11335e91bbf 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -2,13 +2,7 @@ import { DEV } from 'esm-env'; import { text } from '@sveltejs/kit'; import { Redirect, SvelteKitError } from '@sveltejs/kit/internal'; -import { - merge_tracing, - otel, - record_span, - with_request_store, - RequestEvent -} from '@sveltejs/kit/internal/server'; +import { otel, record_span, with_request_store, RequestEvent } from '@sveltejs/kit/internal/server'; import { base, app_dir } from '#app/paths'; import { is_endpoint_request, render_endpoint } from './endpoint.js'; import { render_page } from './page/index.js'; @@ -443,7 +437,7 @@ export async function internal_respond(request, state) { 'sveltekit.is_sub_request': event.isSubRequest }, fn: async (root_span) => { - const traced_event = event.clone(0); + const traced_event = event.clone(); traced_event.tracing = { enabled: __SVELTEKIT_SERVER_TRACING_ENABLED__, root: root_span, @@ -460,32 +454,33 @@ export async function internal_respond(request, state) { 'http.route': event.route.id || 'unknown' }, fn: (resolve_span) => { + const traced_event = RequestEvent.from(event); + traced_event.tracing = { ...event.tracing, current: resolve_span }; + // counter-intuitively, we need to clear the event, so that it's not // e.g. accessible when loading modules needed to handle the request return with_request_store(null, () => - resolve(merge_tracing(event, resolve_span), page_nodes, opts).then( - (response) => { - // add headers/cookies here, rather than inside `resolve`, so that we - // can do it once for all responses instead of once per `return` - for (const key in state.headers) { - response.headers.set(key, state.headers[key]); - } - - add_cookies_to_headers(response.headers, new_cookies.values()); - - if (state.prerendering && event.route.id !== null) { - response.headers.set('x-sveltekit-routeid', encodeURI(event.route.id)); - } - - resolve_span.setAttributes({ - 'http.response.status_code': response.status, - 'http.response.body.size': - response.headers.get('content-length') || 'unknown' - }); - - return response; + resolve(traced_event, page_nodes, opts).then((response) => { + // add headers/cookies here, rather than inside `resolve`, so that we + // can do it once for all responses instead of once per `return` + for (const key in state.headers) { + response.headers.set(key, state.headers[key]); } - ) + + add_cookies_to_headers(response.headers, new_cookies.values()); + + if (state.prerendering && event.route.id !== null) { + response.headers.set('x-sveltekit-routeid', encodeURI(event.route.id)); + } + + resolve_span.setAttributes({ + 'http.response.status_code': response.status, + 'http.response.body.size': + response.headers.get('content-length') || 'unknown' + }); + + return response; + }) ); } }); From f86206c1af85c71665047c93c1b56bab499fbf7d Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:04:23 -0400 Subject: [PATCH 20/23] test requested() in a command called from other server code Co-authored-by: kdelay <90545043+kdelay@users.noreply.github.com> --- .../runtime/app/server/remote/command.spec.js | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 packages/kit/src/runtime/app/server/remote/command.spec.js diff --git a/packages/kit/src/runtime/app/server/remote/command.spec.js b/packages/kit/src/runtime/app/server/remote/command.spec.js new file mode 100644 index 000000000000..1ced5f613e06 --- /dev/null +++ b/packages/kit/src/runtime/app/server/remote/command.spec.js @@ -0,0 +1,73 @@ +/** @import { RequestState } from 'types' */ +import { expect, test, vi } from 'vitest'; +import { RequestEvent, with_request_store } from '@sveltejs/kit/internal/server'; +import { init_transport } from '#app/internal/transport'; + +vi.stubGlobal('__SVELTEKIT_DEV__', false); +vi.stubGlobal('__SVELTEKIT_APP_VERSION__', 'test'); + +init_transport({}); + +const { command } = await import('./command.js'); +const { query } = await import('./query.js'); +const { requested } = await import('./requested.js'); + +/** + * By default this mimics a request that did not come through the `/_app/remote/...` + * endpoint, e.g. a `POST` handler in a `+server.js` file that calls a command directly + * @param {Record} [remote] + * @param {boolean} [is_remote_request] + */ +function setup(remote = {}, is_remote_request = false) { + const get_items = query(() => ['a', 'b']); + /** @type {any} */ (get_items).__.id = 'hash/get_items'; + + return { + get_items, + store: { + event: new RequestEvent( + /** @type {import('@sveltejs/kit').RequestEvent} */ ( + /** @type {unknown} */ ({ + request: new Request('http://localhost/api/add', { method: 'POST' }), + isRemoteRequest: is_remote_request, + cookies: {} + }) + ), + 0 + ), + state: /** @type {RequestState} */ (/** @type {unknown} */ ({ remote })) + } + }; +} + +// https://github.com/sveltejs/kit/issues/17035 +test('requested() yields nothing when the command was not called via the remote endpoint', async () => { + const { get_items, store } = setup(); + + const add = command(async () => { + const entries = [...requested(get_items, 1)]; + await requested(get_items, 1).refreshAll(); + return entries.length; + }); + + await expect(with_request_store(store, () => add())).resolves.toBe(0); +}); + +test('requested() throws when called outside a command or form', () => { + const { get_items, store } = setup(); + + expect(() => with_request_store(store, () => requested(get_items, 1))).toThrow( + 'requested(...) can only be called in the context of a command/form remote function' + ); +}); + +test('requested() still yields the queries the client asked to refresh', async () => { + const { get_items, store } = setup( + { requested: new Map([['hash/get_items', new Set([''])]]) }, + true + ); + + const add = command(() => [...requested(get_items, 1)].map(({ arg }) => arg)); + + await expect(with_request_store(store, () => add())).resolves.toEqual([undefined]); +}); From 7e19b706b8992670373f54f168daecbbda051c96 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:17:22 -0400 Subject: [PATCH 21/23] cover requested() in the existing endpoint command test instead --- .../runtime/app/server/remote/command.spec.js | 73 ------------------- .../remote/server-endpoint/internal.remote.ts | 5 +- 2 files changed, 3 insertions(+), 75 deletions(-) delete mode 100644 packages/kit/src/runtime/app/server/remote/command.spec.js diff --git a/packages/kit/src/runtime/app/server/remote/command.spec.js b/packages/kit/src/runtime/app/server/remote/command.spec.js deleted file mode 100644 index 1ced5f613e06..000000000000 --- a/packages/kit/src/runtime/app/server/remote/command.spec.js +++ /dev/null @@ -1,73 +0,0 @@ -/** @import { RequestState } from 'types' */ -import { expect, test, vi } from 'vitest'; -import { RequestEvent, with_request_store } from '@sveltejs/kit/internal/server'; -import { init_transport } from '#app/internal/transport'; - -vi.stubGlobal('__SVELTEKIT_DEV__', false); -vi.stubGlobal('__SVELTEKIT_APP_VERSION__', 'test'); - -init_transport({}); - -const { command } = await import('./command.js'); -const { query } = await import('./query.js'); -const { requested } = await import('./requested.js'); - -/** - * By default this mimics a request that did not come through the `/_app/remote/...` - * endpoint, e.g. a `POST` handler in a `+server.js` file that calls a command directly - * @param {Record} [remote] - * @param {boolean} [is_remote_request] - */ -function setup(remote = {}, is_remote_request = false) { - const get_items = query(() => ['a', 'b']); - /** @type {any} */ (get_items).__.id = 'hash/get_items'; - - return { - get_items, - store: { - event: new RequestEvent( - /** @type {import('@sveltejs/kit').RequestEvent} */ ( - /** @type {unknown} */ ({ - request: new Request('http://localhost/api/add', { method: 'POST' }), - isRemoteRequest: is_remote_request, - cookies: {} - }) - ), - 0 - ), - state: /** @type {RequestState} */ (/** @type {unknown} */ ({ remote })) - } - }; -} - -// https://github.com/sveltejs/kit/issues/17035 -test('requested() yields nothing when the command was not called via the remote endpoint', async () => { - const { get_items, store } = setup(); - - const add = command(async () => { - const entries = [...requested(get_items, 1)]; - await requested(get_items, 1).refreshAll(); - return entries.length; - }); - - await expect(with_request_store(store, () => add())).resolves.toBe(0); -}); - -test('requested() throws when called outside a command or form', () => { - const { get_items, store } = setup(); - - expect(() => with_request_store(store, () => requested(get_items, 1))).toThrow( - 'requested(...) can only be called in the context of a command/form remote function' - ); -}); - -test('requested() still yields the queries the client asked to refresh', async () => { - const { get_items, store } = setup( - { requested: new Map([['hash/get_items', new Set([''])]]) }, - true - ); - - const add = command(() => [...requested(get_items, 1)].map(({ arg }) => arg)); - - await expect(with_request_store(store, () => add())).resolves.toEqual([undefined]); -}); diff --git a/packages/kit/test/apps/async/src/routes/remote/server-endpoint/internal.remote.ts b/packages/kit/test/apps/async/src/routes/remote/server-endpoint/internal.remote.ts index 25e83a63bee9..fac747945208 100644 --- a/packages/kit/test/apps/async/src/routes/remote/server-endpoint/internal.remote.ts +++ b/packages/kit/test/apps/async/src/routes/remote/server-endpoint/internal.remote.ts @@ -1,9 +1,10 @@ -import { command, query } from '$app/server'; +import { command, query, requested } from '$app/server'; export const get = query(() => { return 'get'; }); -export const add = command('unchecked', () => { +export const add = command('unchecked', async () => { + await requested(get, 1).refreshAll(); return 'post'; }); From 23fca4eea54554cfb8d9a4543cb8a2fc9041def1 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:29:50 -0400 Subject: [PATCH 22/23] keep setHeaders in respond, guard the store in dev, trim the event class --- .../requested-outside-remote-request.md | 5 ++ .changeset/set-headers-after-response.md | 5 ++ packages/kit/src/exports/hooks/sequence.js | 3 +- .../kit/src/exports/internal/server/event.js | 89 +++++-------------- .../src/exports/internal/server/event.spec.js | 35 ++------ .../src/runtime/app/server/remote/query.js | 4 +- .../runtime/app/server/remote/requested.js | 4 - packages/kit/src/runtime/server/endpoint.js | 2 +- packages/kit/src/runtime/server/fetch.js | 2 +- .../kit/src/runtime/server/page/load_data.js | 2 +- .../kit/src/runtime/server/page/render.js | 2 +- .../runtime/server/remote-functions.spec.js | 5 +- packages/kit/src/runtime/server/respond.js | 54 +++++++++-- packages/kit/src/runtime/server/state.js | 2 - packages/kit/src/runtime/server/utils.js | 2 +- .../server/validate-headers.js | 0 .../server/validate-headers.spec.js | 0 packages/kit/src/types/internal.d.ts | 4 - .../kit/test/apps/basics/src/hooks.server.js | 11 +++ .../test/apps/basics/test/playwright/test.js | 9 ++ 20 files changed, 114 insertions(+), 126 deletions(-) create mode 100644 .changeset/requested-outside-remote-request.md create mode 100644 .changeset/set-headers-after-response.md rename packages/kit/src/{exports/internal => runtime}/server/validate-headers.js (100%) rename packages/kit/src/{exports/internal => runtime}/server/validate-headers.spec.js (100%) diff --git a/.changeset/requested-outside-remote-request.md b/.changeset/requested-outside-remote-request.md new file mode 100644 index 000000000000..2cf498f3e657 --- /dev/null +++ b/.changeset/requested-outside-remote-request.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: make `requested()` work in commands called from form actions and endpoints diff --git a/.changeset/set-headers-after-response.md b/.changeset/set-headers-after-response.md new file mode 100644 index 000000000000..1da57b64cd37 --- /dev/null +++ b/.changeset/set-headers-after-response.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: throw when `setHeaders` is called after the response has been generated, whichever copy of the event it is called on diff --git a/packages/kit/src/exports/hooks/sequence.js b/packages/kit/src/exports/hooks/sequence.js index 359aefae22c0..5b39191fc7e9 100644 --- a/packages/kit/src/exports/hooks/sequence.js +++ b/packages/kit/src/exports/hooks/sequence.js @@ -101,8 +101,7 @@ export function sequence(...handlers) { name: `sveltekit.handle.sequenced.${handle.name ? handle.name : i}`, attributes: {}, fn: async (current) => { - const traced_event = RequestEvent.from(event); - traced_event.tracing = { ...event.tracing, current }; + const traced_event = RequestEvent.from(event, current); return await with_request_store({ event: traced_event, state }, () => handle({ diff --git a/packages/kit/src/exports/internal/server/event.js b/packages/kit/src/exports/internal/server/event.js index 38c1b078eda1..8d76f2c80b4d 100644 --- a/packages/kit/src/exports/internal/server/event.js +++ b/packages/kit/src/exports/internal/server/event.js @@ -1,10 +1,9 @@ /** @import { Cookies, RequestEvent as Interface } from '@sveltejs/kit' */ /** @import { Span } from '@opentelemetry/api' */ -/** @import { RequestState, RequestStore } from 'types' */ +/** @import { RequestStore } from 'types' */ /** @import { AsyncLocalStorage } from 'node:async_hooks' */ import { DEV } from 'esm-env'; import { IN_WEBCONTAINER } from '../../../constants.js'; -import { validateHeaders } from './validate-headers.js'; /** The kinds of code an event gets handed to, and the groups the runtime asks about */ export const QUERY = 1; @@ -12,6 +11,7 @@ export const PRERENDER = 2; export const FORM = 4; export const COMMAND = 8; export const RENDER = 16; +const REMOTE = QUERY | PRERENDER | FORM | COMMAND; /** The kinds on the stack, kept under a symbol so it is not part of the public shape */ export const CONTEXT = Symbol('sveltekit.context'); @@ -27,15 +27,15 @@ function forbid_set_headers() { */ class RemoteCookies { #cookies; - #read_only; + #flags; /** * @param {Cookies} cookies - * @param {boolean} read_only + * @param {number} flags the kinds on the stack */ - constructor(cookies, read_only) { + constructor(cookies, flags) { this.#cookies = cookies; - this.#read_only = read_only; + this.#flags = flags; } /** @@ -43,7 +43,7 @@ class RemoteCookies { * @param {import('cookie').SerializeOptions} opts */ #check(verb, opts) { - if (this.#read_only) { + if (this.#flags & (QUERY | PRERENDER)) { throw new Error(`Cannot ${verb} cookies in \`query\` or \`prerender\` functions`); } if (opts.path && !opts.path.startsWith('/')) { @@ -84,46 +84,6 @@ class RemoteCookies { } } -/** - * @param {RequestState} state - * @param {Record} new_headers - */ -function set_headers(state, new_headers) { - if (state.responded) { - throw new Error('Cannot use `setHeaders(...)` after the response has been generated'); - } - - if (DEV) { - validateHeaders(new_headers); - } - - const { headers } = state; - - for (const key of Object.keys(new_headers)) { - const lower = key.toLowerCase(); - const value = new_headers[key]; - - if (lower === 'set-cookie') { - throw new Error( - 'Use `event.cookies.set(name, value, options)` instead of `event.setHeaders` to set cookies' - ); - } else if (lower in headers) { - // appendHeaders-style for Server-Timing https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing - if (lower === 'server-timing') { - headers[lower] += ', ' + value; - } else { - throw new Error(`"${key}" header is already set`); - } - } else { - headers[lower] = value; - - if (state.prerendering && lower === 'cache-control') { - state.prerendering.cache = value; - } - } - } -} - /** * The event as a class, so that a view for a kind of code is a clone with a fixed field list * rather than a copy of whatever the source enumerates @@ -156,27 +116,15 @@ export class RequestEvent { } /** - * The root event of a request, which every view is cloned from. Its behaviour is created - * once here and shared by reference with the views, so that destructuring keeps working - * @param {Omit} fields - * @param {RequestState} state - * @returns {RequestEvent} - */ - static create(fields, state) { - const event = new RequestEvent(/** @type {Interface} */ (fields), 0); - event.setHeaders = (new_headers) => set_headers(state, new_headers); - return event; - } - - /** - * A copy of an event that may have been built by hand for `resolve` + * A traced copy of the event a `handle` hook passes on, which it may have built by hand * @param {Interface} event + * @param {Span} current * @returns {RequestEvent} */ - static from(event) { - return event instanceof RequestEvent - ? event.clone() - : new RequestEvent(event, /** @type {Partial} */ (event)[CONTEXT] ?? 0); + static from(event, current) { + const view = new RequestEvent(event, 0); + view.tracing = { ...event.tracing, current }; + return view; } /** Inside a `query` function, however deep */ @@ -196,7 +144,7 @@ export class RequestEvent { /** Inside any remote function */ get in_remote() { - return (this[CONTEXT] & (QUERY | PRERENDER | FORM | COMMAND)) !== 0; + return (this[CONTEXT] & REMOTE) !== 0; } /** While the page renders */ @@ -217,8 +165,8 @@ export class RequestEvent { ? /** @type {RequestEvent} */ (new QueryEvent(this, flags)) : new RequestEvent(this, flags); - if (kind & (QUERY | PRERENDER | FORM | COMMAND)) { - view.cookies = new RemoteCookies(this.cookies, view.in_query || view.in_prerender); + if (kind & REMOTE) { + view.cookies = new RemoteCookies(this.cookies, flags); view.setHeaders = forbid_set_headers; } @@ -240,6 +188,7 @@ export class RequestEvent { * A query may not read the page, so a query view never copies it and reads throw. It copies * its own field list instead of extending `RequestEvent`, which would run the parent * constructor and hand its stores a second shape + * @implements {Omit} */ class QueryEvent { /** @type {number} */ @@ -345,6 +294,10 @@ export function try_get_request_store() { * @param {() => T} fn */ export function with_request_store(store, fn) { + if (DEV && store && !(store.event instanceof RequestEvent)) { + throw new Error('The request store only holds events made by `RequestEvent`, never a copy'); + } + try { sync_store = store; return als ? als.run(store, fn) : fn(); diff --git a/packages/kit/src/exports/internal/server/event.spec.js b/packages/kit/src/exports/internal/server/event.spec.js index 63155bb82b3b..fa4620a4313b 100644 --- a/packages/kit/src/exports/internal/server/event.spec.js +++ b/packages/kit/src/exports/internal/server/event.spec.js @@ -1,5 +1,4 @@ /** @import { RequestEvent as Interface } from '@sveltejs/kit' */ -/** @import { RequestState } from 'types' */ import { assert, expect, test } from 'vitest'; import { RequestEvent, CONTEXT, QUERY, COMMAND, RENDER } from './event.js'; @@ -52,13 +51,13 @@ test('a spread of a view is not an event any more', () => { expect(() => /** @type {any} */ (copy).clone(QUERY)).toThrow(TypeError); }); -test('an event built by hand is adopted with its flags', () => { - const own = root().clone(RENDER); - const adopted = RequestEvent.from({ ...own }); +test('an event built by hand for `resolve` is adopted as a traced root', () => { + const span = /** @type {any} */ ({}); + const adopted = RequestEvent.from({ ...root() }, span); assert.isTrue(adopted instanceof RequestEvent); - assert.isTrue(adopted.in_render); - assert.notStrictEqual(RequestEvent.from(own), own); + assert.isFalse(adopted.in_render); + assert.strictEqual(adopted.tracing.current, span); }); test('views share the request data and own nothing else', () => { @@ -78,27 +77,7 @@ test('remote views restrict headers and cookies', () => { expect(() => query.cookies.set('a', 'b', { path: '/' })).toThrow('Cannot set cookies'); expect(() => command.cookies.set('a', 'b', { path: 'x' })).toThrow('absolute path'); command.cookies.set('a', 'b', { path: '/' }); -}); - -test('the root event owns setHeaders, which writes to the request state until it responded', () => { - const state = /** @type {RequestState} */ ( - /** @type {unknown} */ ({ headers: {}, responded: false }) + expect(() => command.clone(QUERY).cookies.set('a', 'b', { path: '/' })).toThrow( + 'Cannot set cookies' ); - const event = RequestEvent.create(/** @type {any} */ ({ cookies: {} }), state); - const { setHeaders } = event; - - setHeaders({ 'Cache-Control': 'max-age=60', 'Server-Timing': 'a;dur=1' }); - setHeaders({ 'server-timing': 'b;dur=2' }); - assert.deepEqual(state.headers, { - 'cache-control': 'max-age=60', - 'server-timing': 'a;dur=1, b;dur=2' - }); - - expect(() => setHeaders({ 'cache-control': 'no-store' })).toThrow('already set'); - expect(() => setHeaders({ 'set-cookie': 'a=b' })).toThrow('event.cookies.set'); - - assert.strictEqual(event.clone(RENDER).setHeaders, setHeaders); - - state.responded = true; - expect(() => setHeaders({ 'x-a': 'b' })).toThrow('after the response'); }); diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 06ee8525bec1..b97add126124 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -387,8 +387,8 @@ export function refresh(event, state, internals, payload, fn) { } if (!event.isRemoteRequest && event.in_mutation) { - // ...or this is a no-JS (native) form submission, where the page re-renders - // anyway so there's no live client cache to apply a single-flight update to. + // ...or the mutation runs outside a remote request (a no-JS form submission, or a + // command called from an action or endpoint), so there is no client cache to update. return; } diff --git a/packages/kit/src/runtime/app/server/remote/requested.js b/packages/kit/src/runtime/app/server/remote/requested.js index fb62ee5988a1..dc343b16c191 100644 --- a/packages/kit/src/runtime/app/server/remote/requested.js +++ b/packages/kit/src/runtime/app/server/remote/requested.js @@ -134,10 +134,6 @@ export function requested(query, limit) { ignored.add(create_remote_key(__.id, payload)); }; - // note: don't initialize these maps here -- the remote call handler initializes - // them when it handles a form or command call, and if we initialize them here - // we will enable requested(...) in contexts where it shouldn't be allowed, - // such as load functions or other server functions if (!event.in_mutation) { throw new Error( 'requested(...) can only be called in the context of a command/form remote function' diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js index 48326499b7e6..4aedccb024e1 100644 --- a/packages/kit/src/runtime/server/endpoint.js +++ b/packages/kit/src/runtime/server/endpoint.js @@ -93,7 +93,7 @@ export async function render_endpoint(event, state, mod) { } /** - * @param {import('@sveltejs/kit/internal/server').RequestEvent} event + * @param {import('@sveltejs/kit').RequestEvent} event */ export function is_endpoint_request(event) { const { method, headers } = event.request; diff --git a/packages/kit/src/runtime/server/fetch.js b/packages/kit/src/runtime/server/fetch.js index 823ab261da10..12ccd1086ea2 100644 --- a/packages/kit/src/runtime/server/fetch.js +++ b/packages/kit/src/runtime/server/fetch.js @@ -8,7 +8,7 @@ import { fork_state_for_subrequest } from './state.js'; /** * @param {{ - * event: import('@sveltejs/kit/internal/server').RequestEvent; + * event: import('@sveltejs/kit').RequestEvent; * state: import('types').RequestState; * get_cookie_header: (url: URL, header: string | null) => string; * set_internal: (name: string, value: string, opts: import('./page/types.js').Cookie['options']) => void; diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js index f9c0b6d9f401..2bce90954f53 100644 --- a/packages/kit/src/runtime/server/page/load_data.js +++ b/packages/kit/src/runtime/server/page/load_data.js @@ -256,7 +256,7 @@ export async function load_data({ } /** - * @param {Pick} event + * @param {Pick} event * @param {import('types').PrerenderOptions | undefined} prerendering * @param {import('./types.js').Fetched[]} fetched * @param {boolean} csr diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 6706fe626394..ce20822b0c8d 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -230,7 +230,7 @@ export async function render_response({ throw new Error( `Cannot call \`fetch\` eagerly during server-side rendering with relative URL (${info}) — put your \`fetch\` calls inside \`onMount\` or a \`load\` function instead` ); - } else if (!warned && !(try_get_request_store()?.event ?? event).in_remote) { + } else if (!warned && !try_get_request_store()?.event.in_remote) { console.warn( 'Avoid calling `fetch` eagerly during server-side rendering — put your `fetch` calls inside `onMount` or a `load` function instead' ); diff --git a/packages/kit/src/runtime/server/remote-functions.spec.js b/packages/kit/src/runtime/server/remote-functions.spec.js index cb9fb99489aa..71340cceb8be 100644 --- a/packages/kit/src/runtime/server/remote-functions.spec.js +++ b/packages/kit/src/runtime/server/remote-functions.spec.js @@ -119,14 +119,15 @@ test('serializes explicitly ignored requested updates', async () => { ); const response = await handle_remote_call( - RequestEvent.from( + new RequestEvent( /** @type {any} */ ({ request: new Request('http://localhost/_app/remote/hash/command', { method: 'POST', body: JSON.stringify({ payload: '', refreshes: ['hash/query/[-1]'] }) }), tracing: { current: { setAttributes: vi.fn() } } - }) + }), + 0 ), /** @type {any} */ ({ remote: { requested: null, ignored: null } }), 'hash/command' diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index e905f62fedc9..0916765b2d15 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -19,6 +19,7 @@ import { find_route } from '../../utils/routing.js'; import { redirect_json_response, render_data } from './data/index.js'; import { add_cookies_to_headers, get_cookies } from './cookie.js'; import { create_fetch } from './fetch.js'; +import { validateHeaders } from './validate-headers.js'; import { PageNodes } from '../../utils/page_nodes.js'; import { validate_server_exports } from '../../utils/exports.js'; import { action_json_redirect, is_action_json_request } from './page/actions.js'; @@ -174,13 +175,17 @@ export async function internal_respond(request, state) { } } + /** @type {Record} */ + const headers = {}; + let responded = false; + const { cookies, new_cookies, get_cookie_header, set_internal, set_trailing_slash } = get_cookies( request, url ); - const event = RequestEvent.create( - { + const event = new RequestEvent( + /** @type {import('@sveltejs/kit').RequestEvent} */ ({ cookies, getClientAddress: state.getClientAddress || @@ -199,12 +204,44 @@ export async function internal_respond(request, state) { : state.platform, request, route: { id: null }, + setHeaders: (new_headers) => { + if (responded) { + throw new Error('Cannot use `setHeaders(...)` after the response has been generated'); + } + + if (DEV) { + validateHeaders(new_headers); + } + + for (const [key, value] of Object.entries(new_headers)) { + const lower = key.toLowerCase(); + + if (lower === 'set-cookie') { + throw new Error( + 'Use `event.cookies.set(name, value, options)` instead of `event.setHeaders` to set cookies' + ); + } else if (Object.hasOwn(headers, lower)) { + // appendHeaders-style for Server-Timing https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing + if (lower === 'server-timing') { + headers[lower] += ', ' + value; + } else { + throw new Error(`"${key}" header is already set`); + } + } else { + headers[lower] = value; + + if (state.prerendering && lower === 'cache-control') { + state.prerendering.cache = value; + } + } + } + }, url, isDataRequest: is_data_request, isSubRequest: state.depth > 0, isRemoteRequest: !!remote_id - }, - state + }), + 0 ); event.fetch = create_fetch({ @@ -460,8 +497,7 @@ export async function internal_respond(request, state) { 'http.route': event.route.id || 'unknown' }, fn: (resolve_span) => { - const traced_event = RequestEvent.from(event); - traced_event.tracing = { ...event.tracing, current: resolve_span }; + const traced_event = RequestEvent.from(event, resolve_span); // counter-intuitively, we need to clear the event, so that it's not // e.g. accessible when loading modules needed to handle the request @@ -469,8 +505,8 @@ export async function internal_respond(request, state) { resolve(traced_event, page_nodes, opts).then((response) => { // add headers/cookies here, rather than inside `resolve`, so that we // can do it once for all responses instead of once per `return` - for (const key in state.headers) { - response.headers.set(key, state.headers[key]); + for (const [key, value] of Object.entries(headers)) { + response.headers.set(key, value); } add_cookies_to_headers(response.headers, new_cookies.values()); @@ -750,7 +786,7 @@ export async function internal_respond(request, state) { // HttpError from endpoint can end up here - TODO should it be handled there instead? return await handle_fatal_error(event, state, e); } finally { - state.responded = true; + responded = true; event.cookies.set = () => { throw new Error('Cannot use `cookies.set(...)` after the response has been generated'); }; diff --git a/packages/kit/src/runtime/server/state.js b/packages/kit/src/runtime/server/state.js index 4506096f4437..18a69516346d 100644 --- a/packages/kit/src/runtime/server/state.js +++ b/packages/kit/src/runtime/server/state.js @@ -3,8 +3,6 @@ /** Per-request caches — never carried into a fork. */ function transient_fields() { return { - headers: {}, - responded: false, remote: { data: null, explicit: null, diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js index ef1a5881fb73..7a9155629bf6 100644 --- a/packages/kit/src/runtime/server/utils.js +++ b/packages/kit/src/runtime/server/utils.js @@ -53,7 +53,7 @@ export function with_version_header(response) { } /** - * @param {import('@sveltejs/kit/internal/server').RequestEvent} event + * @param {import('@sveltejs/kit').RequestEvent} event * @param {Error & { path: string }} error */ export function clarify_devalue_error(event, error) { diff --git a/packages/kit/src/exports/internal/server/validate-headers.js b/packages/kit/src/runtime/server/validate-headers.js similarity index 100% rename from packages/kit/src/exports/internal/server/validate-headers.js rename to packages/kit/src/runtime/server/validate-headers.js diff --git a/packages/kit/src/exports/internal/server/validate-headers.spec.js b/packages/kit/src/runtime/server/validate-headers.spec.js similarity index 100% rename from packages/kit/src/exports/internal/server/validate-headers.spec.js rename to packages/kit/src/runtime/server/validate-headers.spec.js diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 0dd135803717..977a52792032 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -736,10 +736,6 @@ export interface RequestState { * Allows us to prevent `event.fetch` from making infinitely looping internal requests. */ readonly depth: number; - /** Set through `event.setHeaders`, added to the response once there is one */ - readonly headers: Record; - /** Once the response exists, `setHeaders` throws */ - responded: boolean; readonly remote: { /** Resolved query/prerender data, populated by `await myQuery()` or `myQuery.set(...)` */ data: null | Map>>; diff --git a/packages/kit/test/apps/basics/src/hooks.server.js b/packages/kit/test/apps/basics/src/hooks.server.js index 2f35ad2d1e0b..99caf8388a94 100644 --- a/packages/kit/test/apps/basics/src/hooks.server.js +++ b/packages/kit/test/apps/basics/src/hooks.server.js @@ -89,6 +89,17 @@ export const handle = sequence( event.locals.answer = 42; return resolve(event); }, + async ({ event, resolve }) => { + const response = await resolve(event); + if (event.url.searchParams.has('set-headers-after-resolve')) { + try { + event.setHeaders({ 'x-late': '1' }); + } catch (e) { + return new Response(/** @type {Error} */ (e).message); + } + } + return response; + }, ({ event, resolve }) => { if ( event.request.url.includes('__data.json') && diff --git a/packages/kit/test/apps/basics/test/playwright/test.js b/packages/kit/test/apps/basics/test/playwright/test.js index 86e81e0426fc..dd4a46270c43 100644 --- a/packages/kit/test/apps/basics/test/playwright/test.js +++ b/packages/kit/test/apps/basics/test/playwright/test.js @@ -1738,6 +1738,15 @@ test.describe('getRequestEvent', () => { }); }); +test.describe('setHeaders', () => { + test('throws once the response has been generated', async ({ request }) => { + const response = await request.get('/?set-headers-after-resolve'); + expect(await response.text()).toBe( + 'Cannot use `setHeaders(...)` after the response has been generated' + ); + }); +}); + test.describe('params prop', () => { test('params prop is passed to the page', async ({ page, clicknav }) => { await page.goto('/params-prop'); From 3e12e1e8adf2feb79748d64b88ac3c43590f855d Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:32:45 -0400 Subject: [PATCH 23/23] read the request url in the test hook, prerendered pages have no searchParams --- packages/kit/test/apps/basics/src/hooks.server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/test/apps/basics/src/hooks.server.js b/packages/kit/test/apps/basics/src/hooks.server.js index 99caf8388a94..8e4244249dc2 100644 --- a/packages/kit/test/apps/basics/src/hooks.server.js +++ b/packages/kit/test/apps/basics/src/hooks.server.js @@ -91,7 +91,7 @@ export const handle = sequence( }, async ({ event, resolve }) => { const response = await resolve(event); - if (event.url.searchParams.has('set-headers-after-resolve')) { + if (event.request.url.includes('?set-headers-after-resolve')) { try { event.setHeaders({ 'x-late': '1' }); } catch (e) {