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` 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/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..dc08013b2166 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,7 +70,43 @@ export async function get_hooks() { }; } -export { set_assets, set_building, set_fix_stack_trace, set_manifest, set_prerendering, set_read_implementation, format_response }; +/** + * 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 (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; + })() + ); + }); + } + + // 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/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 14d0de64e312..6ac0f20436f6 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,148 +46,160 @@ 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); - }; +/** + * 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; + } + + 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; + } } + })()); +} - set_manifest(manifest); +/** + * @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)); } - /** - * @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 (request_state.rerouted_url) { + response.headers.set(REROUTED_URL_HEADER, request_state.rerouted_url); + } - // 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(); + // 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); + } - 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; - } + return response; +} - if (kind !== 'unknown') { - // don't log stack traces for 404s etc, it's all internal gubbins - return; - } +/** + * 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; - let e = error; - while (e instanceof Error) { - if (e.stack) { - console.error(e.stack); - } - e = e.cause; - } + /** @type {typeof respond_to} */ + return async (...args) => { + const { promise, resolve } = /** @type {PromiseWithResolvers} */ ( + Promise.withResolvers() + ); - if (e) { - console.error(String(e)); - } - }), - handleFetch: module.handleFetch || (({ request, fetch }) => fetch(request)), - reroute: module.reroute || noop - }); + const previous = current; + current = promise; - 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; - } - } - })()); - } + await previous; + return fn(...args).finally(resolve); + }; +} - /** - * @param {Request} request - * @param {import('types').InternalRequestOptions} options - */ - async respond(request, options) { - const request_state = create_request_state(options); +export const respond = IN_WEBCONTAINER ? serialise(respond_to) : respond_to; - const response = await respond(request, request_state); +/** + * 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); + + return { + // adapters get to set `env` and `read`, nothing else + init: ({ env, read }) => init({ env, read }), + respond + }; +} - if (DEV) { - const error = decoded_responses.get(response); - if (error) console.error(fix_stack_trace(error)); - } +/** @deprecated use the `server` written by `builder.generateServerInstance`, or `init` and `respond` */ +export class Server { + #server; - if (request_state.rerouted_url) { - response.headers.set(REROUTED_URL_HEADER, request_state.rerouted_url); - } + /** @param {import('types').SSRManifest} manifest */ + constructor(manifest) { + this.#server = create_server(manifest); + } - // 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); - } + /** @param {import('@sveltejs/kit').ServerInitOptions} opts */ + init(opts) { + return this.#server.init(opts); + } - return response; + /** + * @param {Request} request + * @param {import('types').InternalRequestOptions} options + */ + respond(request, options) { + return this.#server.respond(request, options); } } diff --git a/packages/kit/src/types/ambient-private.d.ts b/packages/kit/src/types/ambient-private.d.ts index 74ffbddbebf3..879b78a6dd9b 100644 --- a/packages/kit/src/types/ambient-private.d.ts +++ b/packages/kit/src/types/ambient-private.d.ts @@ -1,15 +1,11 @@ /** Internal version of $app/server */ declare module '/server.js' { - import { SSROptions, ServerHooks, SSRManifest } from 'types'; + import { SSROptions, ServerHooks, ServerConfigureOptions } from 'types'; 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 set_manifest(manifest: SSRManifest): void; - export function set_read_implementation(fn: (path: string) => ReadableStream): void; + export function get_hooks(): Promise>; + export function configure(options: ServerConfigureOptions): Promise; + 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 d5d3d9c76f86..2e9efc952498 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -37,22 +37,25 @@ import { Span } from '@opentelemetry/api'; import { PageOptions } from '../exports/vite/static_analysis/types.js'; import { SharedIterator } from '../utils/shared-iterator.js'; -export interface ServerModule { - Server: typeof InternalServer; +export interface ServerConfigureOptions extends Partial { + 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 ServerInternalModule { - 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; +export interface ServerModule { + init(options: ServerConfigureOptions): Promise; + respond(request: Request, options: InternalRequestOptions): Promise; + /** the `server` adapters receive from `builder.generateServerInstance` */ + create_server(manifest: SSRManifest): Server; } +/** the built `server/internal.js` */ +export type ServerInternalModule = typeof import('/server.js'); + export interface Asset { file: string; type: string | null; @@ -195,12 +198,6 @@ export interface InternalRequestOptions extends RequestOptions { emulator?: Emulator; } -export class InternalServer implements Server { - constructor(manifest: SSRManifest); - init(options: ServerInitOptions): Promise; - respond(request: Request, options: InternalRequestOptions): Promise; -} - export interface ManifestData { /** Static files from `config.files.assets`. */ assets: Asset[]; 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'] } }); },