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 e488710d97b6..f3356282f19f 100644 --- a/packages/kit/src/core/postbuild/fallback.js +++ b/packages/kit/src/core/postbuild/fallback.js @@ -18,17 +18,14 @@ 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); - await configure({ building: true }); - /** @type {import('types').ServerModule} */ - const { init, respond } = await import(pathToFileURL(`${server_root}/server/index.js`).href); + const { configure } = 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 }); + const { init, respond } = await configure({ building: true, manifest, env }); + 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..66a6949f77a1 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -51,16 +51,16 @@ 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 - ); - - // `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 { init, respond } = await import(pathToFileURL(`${out}/server/index.js`).href); + const { configure, format_response } = await import(pathToFileURL(`${out}/server/index.js`).href); + + const { init, respond } = await configure({ + building: true, + prerendering: true, + env, + manifest, + read: (file) => createReadableStream(`${out}/server/${file}`) + }); const throw_handled = () => { throw new Error('__handled__'); @@ -644,11 +644,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 +664,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/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index dc08013b2166..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,44 +57,6 @@ export async function get_hooks() { transport }; } - -/** - * 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 }; `; /** @@ -147,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/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/errors.js b/packages/kit/src/runtime/server/errors.js index 69c6e1f3bdf0..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,33 +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'; -import { fix_stack_trace, hooks, options } from './internal.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); -} +// `$app/server` reaches this module, so it must not import anything generated +import { fix_stack_trace, hooks } from './internal.js'; /** * @param {import('@sveltejs/kit').RequestEvent} event @@ -135,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/index.js b/packages/kit/src/runtime/server/index.js index 6ac0f20436f6..2259004c4c49 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -1,187 +1,56 @@ -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'; - -// 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} - */ -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; - }; -} +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'; /** - * 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 + * Sets the module-level state the runtime reads, then loads the runtime. Everything that + * evaluates user code, the env config included, sits behind this import * @param {import('types').ServerConfigureOptions} opts + * @returns {Promise} */ -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; - } - } - })()); +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 (env) instance.set_env(env); + + return instance; } -/** - * @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); - } - - // 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; -} - -/** - * 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; @@ -203,3 +72,5 @@ export class Server { return this.#server.respond(request, options); } } + +export { format_response } from './internal.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..129e91991e61 --- /dev/null +++ b/packages/kit/src/runtime/server/instance.js @@ -0,0 +1,161 @@ +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); + } + + // 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; +} + +/** + * 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; + +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..32db717920ad 100644 --- a/packages/kit/src/runtime/server/internal.js +++ b/packages/kit/src/runtime/server/internal.js @@ -1,5 +1,6 @@ -/** @import { ServerHooks, SSROptions, SSRManifest } from 'types'; */ +/** @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, @@ -16,7 +17,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,18 +27,25 @@ 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); /** - * @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); } /** @@ -50,14 +57,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/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/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/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 b7e158717018..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, @@ -45,7 +43,9 @@ 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'; +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/src/types/ambient-private.d.ts b/packages/kit/src/types/ambient-private.d.ts index 879b78a6dd9b..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): 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 2e9efc952498..a6ec0274fe8f 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -46,16 +46,20 @@ 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; + set_env(env: Record): void; +} + +/** 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'] } }); }, diff --git a/packages/kit/test/mocks/generated-server.js b/packages/kit/test/mocks/generated-server.js new file mode 100644 index 000000000000..b94074c915c4 --- /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 {any} */ +export const options = {}; + +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: [ {