UNPKG

@sveltejs/kit

Version:

SvelteKit is the fastest way to build Svelte apps

703 lines (593 loc) • 20.3 kB
/** @import { Component } from 'svelte'; */ /** @import { SyncRenderOutput } from 'svelte/server' */ import * as devalue from 'devalue'; import { DEV } from 'esm-env'; import { isRedirect, text } from '@sveltejs/kit'; import * as paths from '#app/paths'; import { relative } from '$app/paths/internal/server'; import { hash } from '../../../utils/hash.js'; import { escape_html } from '../../../utils/escape.js'; import { serialize_data } from './serialize_data.js'; import { s } from '../../../utils/misc.js'; import { Csp } from './csp.js'; import { uneval_action_response } from './actions.js'; import { SCHEME } from '../../../utils/url.js'; import { client_path, create_server_routing_response, generate_route_object, resolve_paths } from './server_routing.js'; import { add_data_suffix, add_resolution_suffix, route_id_resolution_pathname } from '../../pathname.js'; import { try_get_request_store, with_request_store } 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'; import * as env from '<sveltekit:generated>/env/config.js'; import { collect_remote_data } from '../remote-functions.js'; 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 } from '../internal.js'; import { options } from '<sveltekit:generated>/server.js'; import * as e from '../../../messages/server-errors.js'; import * as w from '../../../messages/server-warnings.js'; // TODO rename this function/module /** * Creates the HTML response. * @param {{ * branch: Array<import('./types.js').Loaded>; * fetched: Array<import('./types.js').Fetched>; * page_config: { ssr: boolean; csr: boolean }; * status: number; * error: App.Error | null; * event: import('@sveltejs/kit').RequestEvent; * state: import('types').RequestState; * resolve_opts: import('types').RequiredResolveOptions; * action_result?: import('types').ServerActionResult; * data_serializer: import('./types.js').ServerDataSerializer; * error_components?: Array<import('svelte').Component | undefined> * }} opts */ export async function render_response({ branch, fetched, page_config, status, error = null, event, state, resolve_opts, action_result, data_serializer, error_components }) { if (state.prerendering || state.prerender_default === true) { if (options.csp.mode === 'nonce') { e.prerender_nonce(); } if (options.app_template_contains_nonce) { e.prerender_template_nonce({ tag: '%sveltekit.nonce%' }); } } const client = manifest.client; const modulepreloads = new Set(client?.imports); const stylesheets = new Set(client?.stylesheets); /** @type {Map<string, import('types').FontDependency>} */ const fonts = new Map(client?.fonts.map((font) => [font.file, font])); /** * The value of the Link header that is added to the response when not prerendering * @type {Set<string>} */ const link_headers = new Set(); /** @type {Map<string, string>} */ // TODO if we add a client entry point one day, we will need to include inline_styles with the entry, otherwise stylesheets will be linked even if they are below inlineStyleThreshold const inline_styles = new Map(); /** @type {Omit<SyncRenderOutput, 'html'>} */ let rendered; const form_value = action_result?.type === 'success' || action_result?.type === 'failure' ? (action_result.data ?? null) : null; /** @type {string} */ let base = paths.base; /** @type {string} */ let assets = paths.assets; /** * An expression that will evaluate in the client to determine the resolved base path. * We use a relative path when possible to support IPFS, the internet archive, etc. */ let base_expression = s(paths.base); const csp = new Csp(options.csp, { prerender: !!(state.prerendering || state.prerender_default === true) }); // if appropriate, use relative paths for greater portability if (relative) { if (!state.prerendering?.fallback) { // the relative path depth must reflect the URL the browser is actually at, which // for a data request includes the `__data.json` suffix that was stripped during routing const pathname = event.isDataRequest ? add_data_suffix(event.url.pathname) : event.url.pathname; ({ base, assets } = resolve_paths(pathname)); // resolve e.g. '../..' against current location, then remove trailing slash base_expression = `new URL(${s(base)}, location).pathname.slice(0, -1)`; } else if (__SVELTEKIT_HASH_ROUTING__) { // we have to assume that we're in the right place base_expression = "new URL('.', location).pathname.slice(0, -1)"; } } if (page_config.ssr) { const page = { error, params: /** @type {Record<string, any>} */ (event.params), route: event.route, status, url: event.url, data: {}, form: form_value, shallow: null, state: {} }; const props = new Props({ page, tree: new RenderNode( // TODO tidy up /** @type {Component} */ (await branch[0].node.component?.()), undefined ), form: form_value, error: error ?? undefined }); let current_node = props.tree; let data = props.page.data; for (let i = 0; i < branch.length; i += 1) { const node = branch[i]; data = { ...data, ...node.data }; current_node.data = data; if (i < branch.length - 1) { current_node = current_node.child = new RenderNode( // TODO tidy up /** @type {Component} */ (await branch[i + 1].node.component?.()), error_components?.[i + 1] ); } } props.page.data = data; const render_state = { ...state, is_in_render: true }; const render_opts = { context: new Map([ [ '__request__', { page: props.page } ] ]), csp: csp.script_needs_nonce ? { nonce: csp.nonce } : { hash: csp.script_needs_hash }, transformError: error_components ? /** @param {unknown} e */ (e) => { if (isRedirect(e)) { throw e; } const handled = handle_error_and_jsonify(event, render_state, e); // TODO 4.0 make this an async function and await `handled` if (handled instanceof Promise) { return handled.then((e) => { error = e; props.page.error = error; props.page.status = status = error.status; return error; }); } error = handled; props.page.error = error; props.page.status = status = error.status; return error; } : undefined }; const fetch = globalThis.fetch; try { if (DEV) { let warned = false; globalThis.fetch = (info, init) => { if (typeof info === 'string' && !SCHEME.test(info)) { e.ssr_fetch_relative_url({ url: info }); } else if (!warned && !try_get_request_store()?.state.is_in_remote_function) { w.ssr_fetch_eager(); warned = true; } return fetch(info, init); }; } rendered = await with_request_store({ event, state: render_state }, async () => { return render(Root, { ...render_opts, props }); }); if (rendered.hashes) { csp.add_script_hashes(rendered.hashes.script); } } finally { if (DEV) { globalThis.fetch = fetch; } } } else { rendered = { head: '', body: '', hashes: { script: [] } }; } for (const { node } of branch) { for (const url of node.imports) modulepreloads.add(url); for (const url of node.stylesheets) stylesheets.add(url); for (const font of node.fonts) fonts.set(font.file, font); if (node.inline_styles && !client?.inline) { Object.entries(await node.inline_styles()).forEach(([filename, css]) => { if (typeof css === 'string') { inline_styles.set(filename, css); return; } inline_styles.set(filename, css(`${assets}/${paths.app_dir}/immutable/assets`, assets)); }); } } const head = new Head(rendered.head); let body = rendered.body; /** @param {string} path */ const prefixed = (path) => client_path(path, { base, assets }); const style = client?.inline ? client.inline?.style : Array.from(inline_styles.values()).join('\n'); if (style) { // We always inline all styles to avoid FOUC during development. // Once that's accomplished, we find and remove the style node using the // `data-sveltekit` attribute once CSR kicks in const attributes = __SVELTEKIT_DEV__ ? ['data-sveltekit'] : []; if (csp.style_needs_nonce) attributes.push(`nonce="${csp.nonce}"`); await csp.add_style(style); head.add_style(style, attributes); } /** * see the `output.linkHeaderPreload` option for details on why we have multiple options here * @param {string} path * @param {string[]} attributes */ const add_preload = (path, attributes) => { if ( __SVELTEKIT_LINK_HEADER_PRELOAD__ && !(state.prerendering || state.prerender_default === true) ) { link_headers.add(`<${encodeURI(path)}>; ${attributes.join('; ')}; nopush`); } else { head.add_link_tag(path, attributes); } }; for (const dep of stylesheets) { const path = prefixed(dep); const attributes = ['rel="stylesheet"']; if (inline_styles.has(dep)) { // don't load stylesheets that are already inlined // include them in disabled state so that Vite can detect them and doesn't try to add them attributes.push('disabled', 'media="(max-width: 0)"'); } else { if (__SVELTEKIT_LINK_HEADER_PRELOAD__ && resolve_opts.preload({ type: 'css', path })) { link_headers.add(`<${encodeURI(path)}>; rel="preload"; as="style"; nopush`); } } head.add_stylesheet(path, attributes); } for (const { file, filename } of fonts.values()) { const path = prefixed(file); if (resolve_opts.preload({ type: 'font', path, filename })) { const ext = file.slice(file.lastIndexOf('.') + 1); add_preload(path, ['rel="preload"', 'as="font"', `type="font/${ext}"`, 'crossorigin']); } } const global = __SVELTEKIT_GLOBAL_NAME__; const { data, chunks } = data_serializer.get_data(csp); if (page_config.ssr && page_config.csr) { body += `\n\t\t\t${fetched .map((item) => serialize_data( item, resolve_opts.filterSerializedResponseHeaders, !!(state.prerendering || state.prerender_default === true) ) ) .join('\n\t\t\t')}`; } if (page_config.csr && client) { const route = client.routes?.find((r) => r.id === event.route.id) ?? null; // when serving a prerendered page in an app that uses runtime public env vars, we must // import the env.js module so that it evaluates before any user code can evaluate. // TODO revert to using top-level await once https://bugs.webkit.org/show_bug.cgi?id=242740 is fixed // https://github.com/sveltejs/kit/pull/11601 const load_env_eagerly = client.uses_env_dynamic_public && (state.prerendering || state.prerender_default === true); if (load_env_eagerly) { modulepreloads.add(`${paths.app_dir}/env.js`); } if (!client.inline) { for (const dep of modulepreloads) { const path = prefixed(dep); if (resolve_opts.preload({ type: 'js', path })) { add_preload(path, ['rel="modulepreload"']); } } } // prerender a `/path/to/page/__route.js` module if (client.routes && state.prerendering && !state.prerendering.fallback) { const pathname = add_resolution_suffix(event.url.pathname); state.prerendering.dependencies.set( pathname, create_server_routing_response(route, event.params, new URL(pathname, event.url), client) ); // Prerender a route-ID-keyed `/_app/routes/<id>/__route.js` module alongside the // pathname-keyed one above, so that `preloadCode(id)` can resolve a route ID without // hitting the server. if (route && !state.prerendering.resolved_route_ids.has(route.id)) { state.prerendering.resolved_route_ids.add(route.id); const id_pathname = paths.base + route_id_resolution_pathname(route.id); state.prerendering.dependencies.set( id_pathname, create_server_routing_response(route, null, new URL(id_pathname, event.url), client) ); } } const blocks = []; const properties = [`base: ${base_expression}`, `version: ${s(__SVELTEKIT_APP_VERSION__)}`]; if (paths.assets) { properties.push(`assets: ${s(paths.assets)}`); } if (client.uses_env_dynamic_public) { properties.push(`env: ${load_env_eagerly ? 'null' : devalue.uneval(env.rendered_env)}`); } if (chunks) { blocks.push('const deferred = new Map();'); properties.push(`defer: (id) => new Promise((fulfil, reject) => { deferred.set(id, { fulfil, reject }); })`); let app_declaration = ''; if (has_custom_transporters) { if (client.inline) { app_declaration = `const app = ${global}.app.app;`; } else if (client.app) { app_declaration = `const kit = await import(${s(prefixed(client.start))}); kit.init(${global}); const app = await import(${s(prefixed(client.app))});`; } else { app_declaration = `const { app } = await import(${s(prefixed(client.start))});`; } } const prelude = app_declaration ? `${app_declaration} const [data, error] = fn(app);` : `const [data, error] = fn();`; // When resolving, the id might not yet be available due to the data // be evaluated upon init of kit, so we use a timeout to retry properties.push(`resolve: async (id, fn) => { ${prelude} const try_to_resolve = () => { if (!deferred.has(id)) { setTimeout(try_to_resolve, 0); return; } const { fulfil, reject } = deferred.get(id); deferred.delete(id); if (error) reject(error); else fulfil(data); } try_to_resolve(); }`); } // create this before declaring `data`, which may contain references to `${global}` blocks.push(`${global} = { ${properties.join(',\n\t\t\t\t\t\t')} };`); const args = ['element']; blocks.push('const element = document.currentScript.parentElement;'); if (page_config.ssr) { const serialized = { form: 'null', error: 'null' }; if (form_value) { serialized.form = uneval_action_response( form_value, /** @type {string} */ (event.route.id) ); } if (error) { serialized.error = devalue.uneval(error); } const hydrate = [ `node_ids: [${branch.map(({ node }) => node.index).join(', ')}]`, `data: ${data}`, `form: ${serialized.form}`, `error: ${serialized.error}` ]; if (status !== 200 && !error) { hydrate.push(`status: ${status}`); } if (client.routes) { if (route) { const stringified = generate_route_object(route, event.url, client).replaceAll( '\n', '\n\t\t\t\t\t\t\t' ); // make output after it's put together with the rest more readable hydrate.push(`params: ${devalue.uneval(event.params)}`, `server_route: ${stringified}`); } } else if (__SVELTEKIT_EMBEDDED__) { hydrate.push(`params: ${devalue.uneval(event.params)}`, `route: ${s(event.route)}`); } const indent = '\t'.repeat(load_env_eagerly ? 7 : 6); args.push(`{\n${indent}\t${hydrate.join(`,\n${indent}\t`)}\n${indent}}`); } const remote_data = await collect_remote_data({}, event, state); const serialized_data = Object.keys(remote_data).length > 0 ? `${global}.data = ${uneval(remote_data)};\n\n\t\t\t\t\t\t` : ''; // `client.app` is a proxy for `bundleStrategy === 'split'` const boot = client.inline ? `${client.inline.script} ${serialized_data}${global}.app.start(${args.join(', ')});` : client.app ? `import(${s(prefixed(client.start))}).then(async (kit) => { kit.init(${global}); const app = await import(${s(prefixed(client.app))}); ${serialized_data}kit.start(app, ${args.join(', ')}); });` : `import(${s(prefixed(client.start))}).then((app) => { ${serialized_data}app.start(${args.join(', ')}) });`; if (load_env_eagerly) { blocks.push(`import(${s(`${base}/${paths.app_dir}/env.js`)}).then(({ env }) => { ${global}.env = env; ${boot.replace(/\n/g, '\n\t')} });`); } else { blocks.push(boot); } if (__SVELTEKIT_SERVICE_WORKER__) { let opts = ", { type: 'module' }"; if (options.service_worker_options != null) { const service_worker_options = { ...options.service_worker_options, type: 'module' }; opts = `, ${s(service_worker_options)}`; } // we use an anonymous function instead of an arrow function to support // older browsers (https://github.com/sveltejs/kit/pull/5417) blocks.push(`if ('serviceWorker' in navigator) { const script_url = '${prefixed('service-worker.js')}'; const policy = globalThis?.window?.trustedTypes?.createPolicy( 'sveltekit-trusted-url', { createScriptURL(url) { return url; } } ); const sanitised = policy?.createScriptURL(script_url) ?? script_url; addEventListener('load', function () { navigator.serviceWorker.register(sanitised${opts}); }); }`); } const init_app = ` { ${blocks.join('\n\n\t\t\t\t\t')} } `; await csp.add_script(init_app); body += `\n\t\t\t<script${ csp.script_needs_nonce ? ` nonce="${csp.nonce}"` : '' }>${init_app}</script>\n\t\t`; } const headers = new Headers({ 'x-sveltekit-page': 'true', 'content-type': 'text/html' }); if (state.prerendering || state.prerender_default === true) { // TODO read headers set with setHeaders and convert into http-equiv where possible const csp_headers = csp.csp_provider.get_meta(); if (csp_headers) { head.add_http_equiv(csp_headers); } if (state.prerendering?.cache) { head.add_http_equiv( `<meta http-equiv="cache-control" content="${escape_html(state.prerendering.cache, true)}">` ); } } else { const csp_header = csp.csp_provider.get_header(); if (csp_header) { headers.set('content-security-policy', csp_header); } const report_only_header = csp.report_only_provider.get_header(); if (report_only_header) { headers.set('content-security-policy-report-only', report_only_header); } if (__SVELTEKIT_LINK_HEADER_PRELOAD__ && link_headers.size) { headers.set('link', Array.from(link_headers).join(', ')); } } const html = options.templates.app({ head: head.build(), body, assets, nonce: /** @type {string} */ (csp.nonce), env: env.explicit_public_env }); // TODO flush chunks as early as we can const transformed = (await resolve_opts.transformPageChunk({ html, done: true })) || ''; if (!chunks) { headers.set('etag', `"${hash(transformed)}"`); } if (DEV) { if (page_config.csr) { if (count_non_ssi_comments(transformed) < count_non_ssi_comments(html)) { w.transform_page_chunk_comments(); } } else { if (chunks) { w.streaming_without_csr(); } } } return chunks ? new Response(stream_text(transformed + '\n', chunks), { status, headers }) : text(transformed, { status, headers }); } class Head { #rendered; /** @type {string[]} */ #http_equiv = []; /** @type {string[]} */ #link_tags = []; /** @type {string[]} */ #style_tags = []; /** @type {string[]} */ #stylesheet_links = []; /** * @param {string} rendered */ constructor(rendered) { this.#rendered = rendered; } build() { return [ ...this.#http_equiv, ...this.#link_tags, this.#rendered, ...this.#style_tags, ...this.#stylesheet_links ].join('\n\t\t'); } /** * @param {string} style * @param {string[]} attributes */ add_style(style, attributes) { this.#style_tags.push( `<style${attributes.length ? ' ' + attributes.join(' ') : ''}>${style}</style>` ); } /** * @param {string} href * @param {string[]} attributes */ add_stylesheet(href, attributes) { this.#stylesheet_links.push(`<link href="${href}" ${attributes.join(' ')}>`); } /** * @param {string} href * @param {string[]} attributes */ add_link_tag(href, attributes) { this.#link_tags.push(`<link href="${href}" ${attributes.join(' ')}>`); } /** @param {string} tag */ add_http_equiv(tag) { this.#http_equiv.push(tag); } }