diff --git a/.changeset/ten-pens-develop.md b/.changeset/ten-pens-develop.md new file mode 100644 index 000000000000..16977ac856fb --- /dev/null +++ b/.changeset/ten-pens-develop.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add service worker rendering diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js index 5a6830bdaa42..a68136ae5125 100644 --- a/packages/kit/src/core/config/index.js +++ b/packages/kit/src/core/config/index.js @@ -94,6 +94,7 @@ function process_config(config, { cwd = process.cwd() } = {}) { if (key === 'hooks') { validated.kit.files.hooks.client = path.resolve(cwd, validated.kit.files.hooks.client); validated.kit.files.hooks.server = path.resolve(cwd, validated.kit.files.hooks.server); + validated.kit.files.hooks.serviceWorker = path.resolve(cwd, validated.kit.files.hooks.serviceWorker); validated.kit.files.hooks.universal = path.resolve(cwd, validated.kit.files.hooks.universal); } else { // @ts-expect-error diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index a2b9bb81759d..14a279302599 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -125,6 +125,7 @@ const options = object( hooks: object({ client: string(join('src', 'hooks.client')), server: string(join('src', 'hooks.server')), + serviceWorker: string(join('src', 'hooks.worker')), universal: string(join('src', 'hooks')) }), lib: string(join('src', 'lib')), diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js index eaaf9e6cd38e..650b88a9601c 100644 --- a/packages/kit/src/core/generate_manifest/index.js +++ b/packages/kit/src/core/generate_manifest/index.js @@ -145,3 +145,142 @@ export function generate_manifest({ build_data, prerendered, relative_path, rout })() `; } + +/** + * Generates the data used to write the service worker manifest.js file. This data is used in the Vite + * build process, to power routing, etc. + * @param {{ + * build_data: import('types').BuildData; + * prerendered: string[]; + * relative_path: string; + * routes: import('types').RouteData[]; + * }} opts + */ +export function generate_service_worker_manifest({ + build_data, + prerendered, + relative_path, + routes +}) { + /** + * @type {Map} The new index of each node in the filtered nodes array + */ + const reindexed = new Map(); + /** + * All nodes actually used in the routes definition (prerendered routes are omitted). + * Root layout/error is always included as they are needed for 404 and root errors. + * @type {Set} + */ + const used_nodes = new Set([0, 1]); + + const server_assets = find_server_assets(build_data, routes); + + for (const route of routes) { + if (route.page) { + for (const i of route.page.layouts) used_nodes.add(i); + for (const i of route.page.errors) used_nodes.add(i); + used_nodes.add(route.page.leaf); + } + } + + const node_paths = compact( + build_data.manifest_data.nodes.map((_, i) => { + if (used_nodes.has(i)) { + reindexed.set(i, reindexed.size); + return join_relative(relative_path, `/nodes/${i}.js`); + } + }) + ); + + /** @type {(path: string) => string} */ + const loader = (path) => `__memo(() => import('${path}'))`; + + const assets = build_data.manifest_data.assets.map((asset) => asset.file); + if (build_data.service_worker) { + assets.push(build_data.service_worker); + } + + // In case of server side route resolution, we need to include all matchers. Prerendered routes are not part + // of the server manifest, and they could reference matchers that then would not be included. + const matchers = new Set( + build_data.client?.nodes ? Object.keys(build_data.manifest_data.matchers) : undefined + ); + + /** @param {Array} indexes */ + function get_nodes(indexes) { + const string = indexes.map((n) => reindexed.get(n) ?? '').join(','); + + // since JavaScript ignores trailing commas, we need to insert a dummy + // comma so that the array has the correct length if the last item + // is undefined + return `[${string},]`; + } + + const mime_types = get_mime_lookup(build_data.manifest_data); + + /** @type {Record} */ + const files = {}; + for (const file of server_assets) { + files[file] = fs.statSync(path.resolve(build_data.out_dir, 'server', file)).size; + + const ext = path.extname(file); + mime_types[ext] ??= mime.lookup(ext) || ''; + } + + // prettier-ignore + // String representation of + /** @template {import('types').SWRManifest} T */ + const manifest_expr = dedent` + { + appDir: ${s(build_data.app_dir)}, + appPath: ${s(build_data.app_path)}, + assets: new Set(${s(assets)}), + mimeTypes: ${s(mime_types)}, + client: ${uneval(build_data.client)}, + nodes: [ + ${(node_paths).map(loader).join(',\n')} + ], + routes: [ + ${routes.map(route => { + if (!route.page && !route.endpoint) return; + + route.params.forEach(param => { + if (param.matcher) matchers.add(param.matcher); + }); + + return dedent` + { + id: ${s(route.id)}, + pattern: ${route.pattern}, + params: ${s(route.params)}, + page: ${route.page ? `{ layouts: ${get_nodes(route.page.layouts)}, errors: ${get_nodes(route.page.errors)}, leaf: ${reindexed.get(route.page.leaf)} }` : 'null'}, + endpoint: ${route.endpoint ? 'true' : 'null'} + } + `; + }).filter(Boolean).join(',\n')} + ], + prerendered_routes: new Set(${s(prerendered)}), + matchers: async () => { + ${Array.from( + matchers, + type => `const { match: ${type} } = await import ('${(join_relative(relative_path, `/entries/matchers/${type}.js`))}')` + ).join('\n')} + return { ${Array.from(matchers).join(', ')} }; + }, + server_assets: ${s(files)} + } + `; + + // Memoize the loaders to prevent Node from doing unnecessary work + // on every dynamic import call + return dedent` + (() => { + function __memo(fn) { + let value; + return () => value ??= (value = fn()); + } + + return ${manifest_expr} + })() + `; +} diff --git a/packages/kit/src/core/sync/sync.js b/packages/kit/src/core/sync/sync.js index a96e40bc0928..b0c5122a367d 100644 --- a/packages/kit/src/core/sync/sync.js +++ b/packages/kit/src/core/sync/sync.js @@ -7,6 +7,7 @@ import { write_types, write_all_types } from './write_types/index.js'; import { write_ambient } from './write_ambient.js'; import { write_non_ambient } from './write_non_ambient.js'; import { write_server } from './write_server.js'; +import { write_service_worker } from './write_service_worker.js'; /** * Initialize SvelteKit's generated files. @@ -30,6 +31,7 @@ export function create(config) { write_client_manifest(config.kit, manifest_data, `${output}/client`); write_server(config, output); + write_service_worker(config, output); write_root(manifest_data, output); write_all_types(config, manifest_data); diff --git a/packages/kit/src/core/sync/write_service_worker.js b/packages/kit/src/core/sync/write_service_worker.js new file mode 100644 index 000000000000..1bc951149b11 --- /dev/null +++ b/packages/kit/src/core/sync/write_service_worker.js @@ -0,0 +1,127 @@ +import path from 'node:path'; +import process from 'node:process'; +import { hash } from '../../runtime/hash.js'; +import { posixify, resolve_entry } from '../../utils/filesystem.js'; +import { s } from '../../utils/misc.js'; +import { load_error_page, load_template } from '../config/index.js'; +import { runtime_directory } from '../utils.js'; +import { isSvelte5Plus, write_if_changed } from './utils.js'; +import colors from 'kleur'; + +/** + * @param {{ + * worker_hooks: string | null; + * universal_hooks: string | null; + * config: import('types').ValidatedConfig; + * runtime_directory: string; + * template: string; + * error_page: string; + * }} opts + */ +const service_worker_template = ({ + config, + worker_hooks, + universal_hooks, + runtime_directory, + template, + error_page +}) => ` +import root from '../root.${isSvelte5Plus() ? 'js' : 'svelte'}'; +import { set_building, set_prerendering } from '__sveltekit/environment'; +import { set_assets } from '__sveltekit/paths'; +import { set_manifest, set_read_implementation } from '__sveltekit/service-worker'; +import { set_private_env, set_public_env, set_safe_public_env } from '${runtime_directory}/shared-server.js'; + +export const options = { + app_template_contains_nonce: ${template.includes('%sveltekit.nonce%')}, + csp: ${s(config.kit.csp)}, + csrf_check_origin: ${s(config.kit.csrf.checkOrigin)}, + embedded: ${config.kit.embedded}, + env_public_prefix: '${config.kit.env.publicPrefix}', + env_private_prefix: '${config.kit.env.privatePrefix}', + hash_routing: ${s(config.kit.router.type === 'hash')}, + hooks: null, // added lazily, via \`get_hooks\` + preload_strategy: ${s(config.kit.output.preloadStrategy)}, + root, + templates: { + app: ({ head, body, assets, nonce, env }) => ${s(template) + .replace('%sveltekit.head%', '" + head + "') + .replace('%sveltekit.body%', '" + body + "') + .replace(/%sveltekit\.assets%/g, '" + assets + "') + .replace(/%sveltekit\.nonce%/g, '" + nonce + "') + .replace( + /%sveltekit\.env\.([^%]+)%/g, + (_match, capture) => `" + (env[${s(capture)}] ?? "") + "` + )}, + error: ({ status, message }) => ${s(error_page) + .replace(/%sveltekit\.status%/g, '" + status + "') + .replace(/%sveltekit\.error\.message%/g, '" + message + "')} + }, + version_hash: ${s(hash(config.kit.version.name))} +}; + +export async function get_hooks() { + let handle; + let handleFetch; + let handleError; + ${worker_hooks ? `({ handle, handleFetch, handleError } = await import(${s(worker_hooks)}));` : ''} + + let reroute; + let transport; + ${universal_hooks ? `({ reroute, transport } = await import(${s(universal_hooks)}));` : ''} + + return { + handle, + handleFetch, + handleError, + reroute, + transport + }; +} + +export { set_assets, set_building, set_manifest, set_prerendering, set_private_env, set_public_env, set_read_implementation, set_safe_public_env }; +`; + +// TODO need to re-run this whenever src/app.html or src/error.html are +// created or changed. Also, need to check that updating hooks.worker.js works + +/** + * Write service worker configuration to disk + * @param {import('types').ValidatedConfig} config + * @param {string} output + */ +export function write_service_worker(config, output) { + const service_worker_hooks_file = resolve_entry(config.kit.files.hooks.serviceWorker); + const universal_hooks_file = resolve_entry(config.kit.files.hooks.universal); + + const typo = resolve_entry('src/+hooks.worker'); + if (typo) { + console.log( + colors + .bold() + .yellow( + `Unexpected + prefix. Did you mean ${typo.split('/').at(-1)?.slice(1)}?` + + ` at ${path.resolve(typo)}` + ) + ); + } + + /** @param {string} file */ + function relative(file) { + return posixify(path.relative(`${output}/service-worker`, file)); + } + + // Contains the stringified version of + /** @type {import('types').SSROptions} */ + write_if_changed( + `${output}/service-worker/internal.js`, + service_worker_template({ + config, + worker_hooks: service_worker_hooks_file ? relative(service_worker_hooks_file) : null, + universal_hooks: universal_hooks_file ? relative(universal_hooks_file) : null, + runtime_directory: relative(runtime_directory), + template: load_template(process.cwd(), config), + error_page: load_error_page(config) + }) + ); +} diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 684f18981610..eddb2f72feed 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -17,7 +17,7 @@ import { RequestOptions, RouteSegment } from '../types/private.js'; -import { BuildData, SSRNodeLoader, SSRRoute, ValidatedConfig } from 'types'; +import { BuildData, SSRNodeLoader, SSRRoute, SWRequestEvent, ValidatedConfig } from 'types'; import type { PluginOptions } from '@sveltejs/vite-plugin-svelte'; export { PrerenderOption } from '../types/private.js'; @@ -424,6 +424,11 @@ export interface KitConfig { * @default "src/hooks.client" */ client?: string; + /** + * The location of the your service worker [hooks](https://svelte.dev/docs/kit/hooks). + * @default "src/hooks.worker" + */ + serviceWorker?: string; /** * The location of your server [hooks](https://svelte.dev/docs/kit/hooks). * @default "src/hooks.server" @@ -776,6 +781,13 @@ export type HandleServerError = (input: { message: string; }) => MaybePromise; +export type HandleServiceWorkerError = (input: { + error: unknown; + event: SWRequestEvent; + status: number; + message: string; +}) => MaybePromise; + /** * The client-side [`handleError`](https://svelte.dev/docs/kit/hooks#Shared-hooks-handleError) hook runs when an unexpected error is thrown while navigating. * diff --git a/packages/kit/src/exports/vite/build/build_service_worker.js b/packages/kit/src/exports/vite/build/build_service_worker.js index 17eff31066dc..99f919a3ff8f 100644 --- a/packages/kit/src/exports/vite/build/build_service_worker.js +++ b/packages/kit/src/exports/vite/build/build_service_worker.js @@ -1,10 +1,15 @@ -import fs from 'node:fs'; +import fs, { writeFileSync } from 'node:fs'; +import path, { basename, } from 'node:path'; +import process from 'node:process'; import * as vite from 'vite'; +import { create_static_module } from '../../../core/env.js'; +import { generate_service_worker_manifest } from '../../../core/generate_manifest/index.js'; import { dedent } from '../../../core/sync/utils.js'; +import { mkdirp } from '../../../utils/filesystem.js'; import { s } from '../../../utils/misc.js'; -import { get_config_aliases, strip_virtual_prefix, get_env, normalize_id } from '../utils.js'; -import { create_static_module } from '../../../core/env.js'; -import { env_static_public, service_worker } from '../module_ids.js'; +import { env_static_public } from '../module_ids.js'; +import { get_config_aliases, get_env, normalize_id, strip_virtual_prefix } from '../utils.js'; +import { filter_fonts, find_deps, resolve_symlinks } from './utils.js'; /** * @param {string} out @@ -14,6 +19,8 @@ import { env_static_public, service_worker } from '../module_ids.js'; * @param {string} service_worker_entry_file * @param {import('types').Prerendered} prerendered * @param {import('vite').Manifest} client_manifest + * @param {import('types').BuildData} build_data + * @param {import("types").PrerenderMap} prerender_map */ export async function build_service_worker( out, @@ -22,9 +29,12 @@ export async function build_service_worker( manifest_data, service_worker_entry_file, prerendered, - client_manifest + client_manifest, + build_data, + prerender_map ) { const build = new Set(); + for (const key in client_manifest) { const { file, css = [], assets = [] } = client_manifest[key]; build.add(file); @@ -36,29 +46,6 @@ export async function build_service_worker( // which is guaranteed to be `/service-worker.js` const base = "location.pathname.split('/').slice(0, -1).join('/')"; - const service_worker_code = dedent` - export const base = /*@__PURE__*/ ${base}; - - export const build = [ - ${Array.from(build) - .map((file) => `base + ${s(`/${file}`)}`) - .join(',\n')} - ]; - - export const files = [ - ${manifest_data.assets - .filter((asset) => kit.serviceWorker.files(asset.file)) - .map((asset) => `base + ${s(`/${asset.file}`)}`) - .join(',\n')} - ]; - - export const prerendered = [ - ${prerendered.paths.map((path) => `base + ${s(path.replace(kit.paths.base, ''))}`).join(',\n')} - ]; - - export const version = ${s(kit.version.name)}; - `; - const env = get_env(kit.env, vite_config.mode); /** @@ -67,7 +54,7 @@ export async function build_service_worker( const sw_virtual_modules = { name: 'service-worker-build-virtual-modules', resolveId(id) { - if (id.startsWith('$env/') || id.startsWith('$app/') || id === '$service-worker') { + if (id.startsWith('$env/') || id.startsWith('$app/')) { // ids with :$ don't work with reverse proxies like nginx return `\0virtual:${id.substring(1)}`; } @@ -76,10 +63,6 @@ export async function build_service_worker( load(id) { if (!id.startsWith('\0virtual:')) return; - if (id === service_worker) { - return service_worker_code; - } - if (id === env_static_public) { return create_static_module('$env/static/public', env.public); } @@ -94,6 +77,47 @@ export async function build_service_worker( } }; + const route_data = build_data.manifest_data.routes.filter((route) => route.page); + + writeFileSync( + `${out}/service-worker/index.js`, + dedent` + const manifest = ${generate_service_worker_manifest({ + build_data, + prerendered: prerendered.paths, + relative_path: path.posix.relative(`${out}/service-worker`, `${out}/service-worker`), + routes: route_data.filter((route) => prerender_map.get(route.id) !== true) + })}; + + export const base = /*@__PURE__*/ ${base}; + + export const build = [ + ${Array.from(build) + .map((file) => `base + ${s(`/${file}`)}`) + .join(',\n')} + ]; + + export const files = [ + ${manifest_data.assets + .filter((asset) => kit.serviceWorker.files(asset.file)) + .map((asset) => `base + ${s(`/${asset.file}`)}`) + .join(',\n')} + ]; + + export const prerendered = [ + ${prerendered.paths.map((path) => `base + ${s(path.replace(kit.paths.base, ''))}`).join(',\n')} + ]; + + export const version = ${s(kit.version.name)}; + + let server; + + export function respond(event) { + return "Hello World!"; + } + ` + ) + await vite.build({ build: { modulePreload: false, @@ -117,7 +141,7 @@ export async function build_service_worker( publicDir: false, plugins: [sw_virtual_modules], resolve: { - alias: [...get_config_aliases(kit)] + alias: [...get_config_aliases(kit), { find: "$service-worker", replacement: `${out}/service-worker/index.js` }] }, experimental: { renderBuiltUrl(filename) { @@ -131,3 +155,180 @@ export async function build_service_worker( // rename .mjs to .js to avoid incorrect MIME types with ancient webservers fs.renameSync(`${out}/client/service-worker.mjs`, `${out}/client/service-worker.js`); } + +/** + * @param {string} out + * @param {import('types').ValidatedKitConfig} kit + * @param {import('types').ManifestData} manifest_data + * @param {import('vite').Manifest} service_worker_manifest + * @param {import('vite').Manifest | null} client_manifest + * @param {import('vite').Rollup.OutputAsset[] | null} css + * @param {import('types').RecursiveRequired} output_config + */ +export function build_service_worker_nodes(out, kit, manifest_data, service_worker_manifest, client_manifest, css, output_config) { + mkdirp(`${out}/service-worker/nodes`); + mkdirp(`${out}/service-worker/stylesheets`); + + /** @type {Map} */ + const stylesheet_lookup = new Map(); + + if (css) { + /** @type {Set} */ + const client_stylesheets = new Set(); + for (const key in client_manifest) { + client_manifest[key].css?.forEach((filename) => { + client_stylesheets.add(filename); + }); + } + + /** @type {Map} */ + const service_worker_stylesheets = new Map(); + manifest_data.nodes.forEach((node, i) => { + if (!node.component || !service_worker_manifest[node.component]) return; + + const { stylesheets } = find_deps(service_worker_manifest, node.component, false); + + if (stylesheets.length) { + service_worker_stylesheets.set(i, stylesheets); + } + }); + + for (const asset of css) { + // ignore dynamically imported stylesheets since we don't need to inline those + if (!client_stylesheets.has(asset.fileName) || asset.source.length >= kit.inlineStyleThreshold) { + continue; + } + + // We know that the names for entry points are numbers. + const [index] = basename(asset.fileName).split('.'); + // There can also be other CSS files from shared components + // for example, which we need to ignore here. + if (isNaN(+index)) continue; + + const file = `${out}/service-worker/stylesheets/${index}.js`; + + // we need to inline the server stylesheet instead of the client one + // so that asset paths are correct on document load + const filenames = service_worker_stylesheets.get(+index); + + if (!filenames) { + throw new Error('This should never happen, but if it does, it means we failed to find the server stylesheet for a node.'); + } + + const sources = filenames.map((filename) => { + return fs.readFileSync(`${out}/service-worker/${filename}`, 'utf-8'); + }); + + fs.writeFileSync(file, `// ${filenames.join(', ')}\nexport default ${s(sources.join('\n'))};`); + + stylesheet_lookup.set(asset.fileName, index); + } + } + + manifest_data.nodes.forEach((node, i) => { + /** @type {string[]} */ + const imports = []; + + // String representation of + /** @type {import('types').SSRNode} */ + /** @type {string[]} */ + const exports = [`export const index = ${i};`]; + + /** @type {string[]} */ + let imported = []; + + /** @type {string[]} */ + let stylesheets = []; + + /** @type {string[]} */ + let fonts = []; + + if (node.component && client_manifest) { + exports.push( + 'let component_cache;', + `export const component = async () => component_cache ??= (await import('../${ + resolve_symlinks(service_worker_manifest, node.component).chunk.file + }')).default;` + ); + } + + if (node.universal) { + imports.push( + `import * as universal from '../${ + resolve_symlinks(service_worker_manifest, node.universal).chunk.file + }';` + ); + exports.push('export { universal };'); + exports.push(`export const universal_id = ${s(node.universal)};`); + } + + if (client_manifest && (node.universal || node.component) && output_config.bundleStrategy === 'split') { + const entry_path = `${vite.normalizePath(kit.outDir)}/generated/client-optimized/nodes/${i}.js`; + const entry = find_deps(client_manifest, entry_path, true); + + // eagerly load stylesheets and fonts imported by the SSR-ed page to avoid FOUC. + // If it is not used during SSR, it can be lazily loaded in the browser. + + /** @type {import('types').AssetDependencies | undefined} */ + let component; + if (node.component) { + component = find_deps(service_worker_manifest, node.component, true); + } + + /** @type {import('types').AssetDependencies | undefined} */ + let universal; + if (node.universal) { + universal = find_deps(service_worker_manifest, node.universal, true); + } + + /** @type {Set} */ + const css_used_by_server = new Set(); + /** @type {Set} */ + const assets_used_by_server = new Set(); + + entry.stylesheet_map.forEach((value, key) => { + // pages and layouts are named as node indexes in the client manifest + // so we need to use the original filename when checking against the server manifest + if (key === entry_path) { + key = node.component ?? key; + } + + if (component?.stylesheet_map.has(key) || universal?.stylesheet_map.has(key)) { + value.css.forEach(file => css_used_by_server.add(file)); + value.assets.forEach(file => assets_used_by_server.add(file)); + } + }); + + imported = entry.imports; + stylesheets = Array.from(css_used_by_server); + fonts = filter_fonts(Array.from(assets_used_by_server)); + } + + exports.push( + `export const imports = ${s(imported)};`, + `export const stylesheets = ${s(stylesheets)};`, + `export const fonts = ${s(fonts)};` + ); + + /** @type {string[]} */ + const styles = []; + + stylesheets.forEach((file) => { + if (stylesheet_lookup.has(file)) { + const index = stylesheet_lookup.get(file); + const name = `stylesheet_${index}`; + imports.push(`import ${name} from '../stylesheets/${index}.js';`); + styles.push(`\t${s(file)}: ${name}`); + } + }); + + if (styles.length > 0) { + exports.push(`export const inline_styles = () => ({\n${styles.join(',\n')}\n});`); + } + + fs.writeFileSync( + `${out}/service-worker/nodes/${i}.js`, + `${imports.join('\n')}\n\n${exports.join('\n')}\n` + ); + }); +} diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 8f1f7fe00254..de0efbacf314 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -12,7 +12,7 @@ import { runtime_directory, logger } from '../../core/utils.js'; import { load_config } from '../../core/config/index.js'; import { generate_manifest } from '../../core/generate_manifest/index.js'; import { build_server_nodes } from './build/build_server.js'; -import { build_service_worker } from './build/build_service_worker.js'; +import { build_service_worker, build_service_worker_nodes } from './build/build_service_worker.js'; import { assets_base, find_deps, resolve_symlinks } from './build/utils.js'; import { dev } from './dev/index.js'; import { is_illegal, module_guard } from './graph_analysis/index.js'; @@ -1021,6 +1021,16 @@ Tips: log.info('Building service worker'); + build_service_worker_nodes( + out, + kit, + manifest_data, + server_manifest, + client_manifest, + css, + svelte_config.kit.output + ); + await build_service_worker( out, kit, @@ -1034,7 +1044,9 @@ Tips: manifest_data, service_worker_entry_file, prerendered, - client_manifest + client_manifest, + build_data, + prerender_map ); } diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js index d480d344036b..8f1f0cfbd081 100644 --- a/packages/kit/src/runtime/server/endpoint.js +++ b/packages/kit/src/runtime/server/endpoint.js @@ -89,7 +89,7 @@ export async function render_endpoint(event, mod, state) { } /** - * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('@sveltejs/kit').RequestEvent | import('types').SWRequestEvent} event */ export function is_endpoint_request(event) { const { method, headers } = event.request; diff --git a/packages/kit/src/runtime/service-worker/ambient.d.ts b/packages/kit/src/runtime/service-worker/ambient.d.ts new file mode 100644 index 000000000000..cdfe325f3adf --- /dev/null +++ b/packages/kit/src/runtime/service-worker/ambient.d.ts @@ -0,0 +1,4 @@ +declare module '__SERVICE_WORKER__/internal.js' { + export const options: import('types').SWROptions; + export const get_hooks: () => Promise>; +} diff --git a/packages/kit/src/runtime/service-worker/data/index.js b/packages/kit/src/runtime/service-worker/data/index.js new file mode 100644 index 000000000000..c4e834c02009 --- /dev/null +++ b/packages/kit/src/runtime/service-worker/data/index.js @@ -0,0 +1,237 @@ +import { HttpError, SvelteKitError, Redirect } from '../../control.js'; +import { normalize_error } from '../../../utils/error.js'; +import { once } from '../../../utils/functions.js'; +import { clarify_devalue_error, handle_error_and_jsonify, serialize_uses } from '../utils.js'; +import { normalize_path } from '../../../utils/url.js'; +import { text } from '../../../exports/index.js'; +import * as devalue from 'devalue'; +import { create_async_iterator } from '../../../utils/streaming.js'; + +const encoder = new TextEncoder(); + +/** + * @param {import('types').SWRequestEvent} event + * @param {import('types').SWRRoute} route + * @param {import('types').SWROptions} options + * @param {boolean[] | undefined} invalidated_data_nodes + * @param {import('types').TrailingSlash} trailing_slash + * @returns {Promise} + */ +export async function render_data(event, route, options, invalidated_data_nodes, trailing_slash) { + if (!route.page) { + // requesting /__data.json should fail for a +server.js + return new Response(undefined, { + status: 404 + }); + } + + try { + const node_ids = [...route.page.layouts, route.page.leaf]; + const invalidated = invalidated_data_nodes ?? node_ids.map(() => true); + + let aborted = false; + + const url = new URL(event.url); + url.pathname = normalize_path(url.pathname, trailing_slash); + + const functions = node_ids.map(() => { + return once(() => { + try { + if (aborted) { + return /** @type {import('types').ServerDataSkippedNode} */ ({ + type: 'skip' + }); + } + // load this. for the child, return as is. for the final result, stream things + return null; + } catch (e) { + aborted = true; + throw e; + } + }); + }); + + // eslint-disable-next-line @typescript-eslint/require-await + const promises = functions.map(async (fn, i) => { + if (!invalidated[i]) { + return /** @type {import('types').ServerDataSkippedNode} */ ({ + type: 'skip' + }); + } + + return fn(); + }); + + let length = promises.length; + const nodes = await Promise.all( + promises.map((p, i) => + p.catch(async (error) => { + if (error instanceof Redirect) { + throw error; + } + + // Math.min because array isn't guaranteed to resolve in order + length = Math.min(length, i + 1); + + return /** @type {import('types').ServerErrorNode} */ ({ + type: 'error', + error: await handle_error_and_jsonify(event, options, error), + status: + error instanceof HttpError || error instanceof SvelteKitError + ? error.status + : undefined + }); + }) + ) + ); + + const { data, chunks } = get_data_json(event, options, nodes); + + if (!chunks) { + // use a normal JSON response where possible, so we get `content-length` + // and can use browser JSON devtools for easier inspecting + return json_response(data); + } + + return new Response( + new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(data)); + for await (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + + type: 'bytes' + }), + { + headers: { + // we use a proprietary content type to prevent buffering. + // the `text` prefix makes it inspectable + 'content-type': 'text/sveltekit-data', + 'cache-control': 'private, no-store' + } + } + ); + } catch (e) { + const error = normalize_error(e); + + if (error instanceof Redirect) { + return redirect_json_response(error); + } else { + return json_response(await handle_error_and_jsonify(event, options, error), 500); + } + } +} + +/** + * @param {Record | string} json + * @param {number} [status] + */ +function json_response(json, status = 200) { + return text(typeof json === 'string' ? json : JSON.stringify(json), { + status, + headers: { + 'content-type': 'application/json', + 'cache-control': 'private, no-store' + } + }); +} + +/** + * @param {Redirect} redirect + */ +export function redirect_json_response(redirect) { + return json_response({ + type: 'redirect', + location: redirect.location + }); +} + +/** + * If the serialized data contains promises, `chunks` will be an + * async iterable containing their resolutions + * @param {import('types').SWRequestEvent} event + * @param {import('types').SWROptions} options + * @param {Array} nodes + * @returns {{ data: string, chunks: AsyncIterable | null }} + */ +function get_data_json(event, options, nodes) { + let promise_id = 1; + let count = 0; + + const { iterator, push, done } = create_async_iterator(); + + const reducers = { + ...Object.fromEntries( + Object.entries(options.hooks.transport).map(([key, value]) => [key, value.encode]) + ), + /** @param {any} thing */ + Promise: (thing) => { + if (typeof thing?.then === 'function') { + const id = promise_id++; + count += 1; + + /** @type {'data' | 'error'} */ + let key = 'data'; + + thing + .catch( + /** @param {any} e */ async (e) => { + key = 'error'; + return handle_error_and_jsonify(event, options, /** @type {any} */ (e)); + } + ) + .then( + /** @param {any} value */ + async (value) => { + let str; + try { + str = devalue.stringify(value, reducers); + } catch { + const error = await handle_error_and_jsonify( + event, + options, + new Error(`Failed to serialize promise while rendering ${event.route.id}`) + ); + + key = 'error'; + str = devalue.stringify(error, reducers); + } + + count -= 1; + + push(`{"type":"chunk","id":${id},"${key}":${str}}\n`); + if (count === 0) done(); + } + ); + + return id; + } + } + }; + + try { + const strings = nodes.map((node) => { + if (!node) return 'null'; + + if (node.type === 'error' || node.type === 'skip') { + return JSON.stringify(node); + } + + return `{"type":"data","data":${devalue.stringify(node.data, reducers)},"uses":${JSON.stringify( + serialize_uses(node) + )}${node.slash ? `,"slash":${JSON.stringify(node.slash)}` : ''}}`; + }); + + return { + data: `{"type":"data","nodes":[${strings.join(',')}]}\n`, + chunks: count > 0 ? iterator : null + }; + } catch (e) { + // @ts-expect-error + e.path = 'data' + e.path; + throw new Error(clarify_devalue_error(event, /** @type {any} */ (e))); + } +} diff --git a/packages/kit/src/runtime/service-worker/env_module.js b/packages/kit/src/runtime/service-worker/env_module.js new file mode 100644 index 000000000000..7d37ac6a1481 --- /dev/null +++ b/packages/kit/src/runtime/service-worker/env_module.js @@ -0,0 +1,29 @@ +import { public_env } from '../shared-server.js'; + +/** @type {string} */ +let body; + +/** @type {string} */ +let etag; + +/** @type {Headers} */ +let headers; + +/** + * @param {Request} request + * @returns {Response} + */ +export function get_public_env(request) { + body ??= `export const env=${JSON.stringify(public_env)}`; + etag ??= `W/${Date.now()}`; + headers ??= new Headers({ + 'content-type': 'application/javascript; charset=utf-8', + etag + }); + + if (request.headers.get('if-none-match') === etag) { + return new Response(undefined, { status: 304, headers }); + } + + return new Response(body, { headers }); +} diff --git a/packages/kit/src/runtime/service-worker/fetch.js b/packages/kit/src/runtime/service-worker/fetch.js new file mode 100644 index 000000000000..13645e87588c --- /dev/null +++ b/packages/kit/src/runtime/service-worker/fetch.js @@ -0,0 +1,165 @@ +import * as set_cookie_parser from 'set-cookie-parser'; +import { respond } from './respond.js'; +import * as paths from '__sveltekit/paths'; +import { has_prerendered_path } from './utils.js'; + +/** + * @param {{ + * event: import('types').SWRequestEvent; + * options: import('types').SWROptions; + * manifest: import('types').SWRManifest; + * state: import('types').SWRState; + * get_cookie_header: (url: URL, header: string | null) => string; + * set_internal: (name: string, value: string, opts: import('./page/types.js').Cookie['options']) => void; + * }} opts + * @returns {typeof fetch} + */ +export function create_fetch({ event, options, manifest, state, get_cookie_header, set_internal }) { + /** + * @type {typeof fetch} + */ + const server_fetch = async (info, init) => { + const original_request = normalize_fetch_input(info, init, event.url); + + // some runtimes (e.g. Cloudflare) error if you access `request.mode`, + // annoyingly, so we need to read the value from the `init` object instead + let mode = (info instanceof Request ? info.mode : init?.mode) ?? 'cors'; + let credentials = + (info instanceof Request ? info.credentials : init?.credentials) ?? 'same-origin'; + + const request = normalize_fetch_input(info, init, event.url); + + const url = new URL(request.url); + + if (!request.headers.has('origin')) { + request.headers.set('origin', event.url.origin); + } + + if (info !== original_request) { + mode = (info instanceof Request ? info.mode : init?.mode) ?? 'cors'; + credentials = + (info instanceof Request ? info.credentials : init?.credentials) ?? 'same-origin'; + } + + // Remove Origin, according to https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin#description + if ( + (request.method === 'GET' || request.method === 'HEAD') && + ((mode === 'no-cors' && url.origin !== event.url.origin) || url.origin === event.url.origin) + ) { + request.headers.delete('origin'); + } + + if (url.origin !== event.url.origin) { + // Allow cookie passthrough for "credentials: same-origin" and "credentials: include" + // if SvelteKit is serving my.domain.com: + // - domain.com WILL NOT receive cookies + // - my.domain.com WILL receive cookies + // - api.domain.dom WILL NOT receive cookies + // - sub.my.domain.com WILL receive cookies + // ports do not affect the resolution + // leading dot prevents mydomain.com matching domain.com + // Do not forward other cookies for "credentials: include" because we don't know + // which cookie belongs to which domain (browser does not pass this info) + if (`.${url.hostname}`.endsWith(`.${event.url.hostname}`) && credentials !== 'omit') { + const cookie = get_cookie_header(url, request.headers.get('cookie')); + if (cookie) request.headers.set('cookie', cookie); + } + + return fetch(request); + } + + // handle fetch requests for static assets. e.g. prebaked data, etc. + // we need to support everything the browser's fetch supports + const prefix = paths.assets || paths.base; + const decoded = decodeURIComponent(url.pathname); + const filename = (decoded.startsWith(prefix) ? decoded.slice(prefix.length) : decoded).slice(1); + const filename_html = `${filename}/index.html`; // path may also match path/index.html + + const is_asset = manifest.assets.has(filename) || filename in manifest.server_assets; + const is_asset_html = + manifest.assets.has(filename_html) || filename_html in manifest.server_assets; + + if (is_asset || is_asset_html) { + return await fetch(request); + } + + if (has_prerendered_path(manifest, paths.base + decoded)) { + // The path of something prerendered could match a different route + // that is still in the manifest, leading to the wrong route being loaded. + // We therefore bail early here. The prerendered logic is different for + // each adapter, (except maybe for prerendered redirects) + // so we need to make an actual HTTP request. + return await fetch(request); + } + + if (credentials !== 'omit') { + const cookie = get_cookie_header(url, request.headers.get('cookie')); + if (cookie) { + request.headers.set('cookie', cookie); + } + + const authorization = event.request.headers.get('authorization'); + if (authorization && !request.headers.has('authorization')) { + request.headers.set('authorization', authorization); + } + } + + if (!request.headers.has('accept')) { + request.headers.set('accept', '*/*'); + } + + if (!request.headers.has('accept-language')) { + request.headers.set( + 'accept-language', + /** @type {string} */ (event.request.headers.get('accept-language')) + ); + } + + const response = await respond(request, options, manifest, { + ...state, + depth: state.depth + 1 + }); + + const set_cookie = response.headers.get('set-cookie'); + if (set_cookie) { + for (const str of set_cookie_parser.splitCookiesString(set_cookie)) { + const { name, value, ...options } = set_cookie_parser.parseString(str, { + decodeValues: false + }); + + const path = options.path ?? (url.pathname.split('/').slice(0, -1).join('/') || '/'); + + // options.sameSite is string, something more specific is required - type cast is safe + set_internal(name, value, { + path, + encode: (value) => value, + .../** @type {import('cookie').CookieSerializeOptions} */ (options) + }); + } + } + + return response; + }; + + // Don't make this function `async`! Otherwise, the user has to `catch` promises they use for streaming responses or else + // it will be an unhandled rejection. Instead, we add a `.catch(() => {})` ourselves below to prevent this from happening. + return (input, init) => { + // See docs in fetch.js for why we need to do this + const response = server_fetch(input, init); + response.catch(() => {}); + return response; + }; +} + +/** + * @param {RequestInfo | URL} info + * @param {RequestInit | undefined} init + * @param {URL} url + */ +function normalize_fetch_input(info, init, url) { + if (info instanceof Request) { + return info; + } + + return new Request(typeof info === 'string' ? new URL(info, url) : info, init); +} diff --git a/packages/kit/src/runtime/service-worker/index.js b/packages/kit/src/runtime/service-worker/index.js new file mode 100644 index 000000000000..1f99dfaffd3b --- /dev/null +++ b/packages/kit/src/runtime/service-worker/index.js @@ -0,0 +1,89 @@ +import { respond } from './respond.js'; +import { set_public_env, set_safe_public_env } from '../shared-server.js'; +import { options, get_hooks } from '__SERVICE_WORKER__/internal.js'; +import { DEV } from 'esm-env'; +import { filter_public_env } from '../../utils/env.js'; +import { set_manifest } from '__sveltekit/service-worker'; + +/** @type {Promise} */ +let init_promise; + +export class Server { + /** @type {import('types').SWROptions} */ + #options; + + /** @type {import('types').SWRManifest} */ + #manifest; + + /** @param {import('types').SWRManifest} manifest */ + constructor(manifest) { + /** @type {import('types').SWROptions} */ + this.#options = options; + this.#manifest = manifest; + + set_manifest(manifest); + } + + /** + * @param {{ + * env: Record; + * }} opts + */ + async init({ env }) { + // 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. + + // set env, in case it's used in initialisation + const prefixes = { + public_prefix: this.#options.env_public_prefix, + private_prefix: this.#options.env_private_prefix + }; + + const public_env = filter_public_env(env, prefixes); + + set_public_env(public_env); + set_safe_public_env(public_env); + + // 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(); + + this.#options.hooks = { + handle: module.handle || (({ event, resolve }) => resolve(event)), + handleError: module.handleError || (({ error }) => console.error(error)), + handleFetch: module.handleFetch || (({ request, fetch }) => fetch(request)), + reroute: module.reroute || (() => {}), + transport: module.transport || {} + }; + } catch (error) { + if (DEV) { + this.#options.hooks = { + handle: () => { + throw error; + }, + handleError: ({ error }) => console.error(error), + handleFetch: ({ request, fetch }) => fetch(request), + reroute: () => {}, + transport: {} + }; + } else { + throw error; + } + } + })()); + } + + /** + * @param {Request} request + * @returns {Promise} + */ + async respond(request) { + return respond(request, this.#options, this.#manifest, { + error: false, + depth: 0 + }); + } +} diff --git a/packages/kit/src/runtime/service-worker/page/actions.js b/packages/kit/src/runtime/service-worker/page/actions.js new file mode 100644 index 000000000000..3b5ed0c81ebd --- /dev/null +++ b/packages/kit/src/runtime/service-worker/page/actions.js @@ -0,0 +1,323 @@ +import * as devalue from 'devalue'; +import { DEV } from 'esm-env'; +import { json } from '../../../exports/index.js'; +import { get_status, normalize_error } from '../../../utils/error.js'; +import { is_form_content_type, negotiate } from '../../../utils/http.js'; +import { HttpError, Redirect, ActionFailure, SvelteKitError } from '../../control.js'; +import { handle_error_and_jsonify } from '../utils.js'; + +/** @param {import('types').SWRequestEvent} event */ +export function is_action_json_request(event) { + const accept = negotiate(event.request.headers.get('accept') ?? '*/*', [ + 'application/json', + 'text/html' + ]); + + return accept === 'application/json' && event.request.method === 'POST'; +} + +/** + * @param {import('types').SWRequestEvent} event + * @param {import('types').SWROptions} options + * @param {import('types').SWRNode['server'] | undefined} server + */ +export async function handle_action_json_request(event, options, server) { + const actions = server?.actions; + + if (!actions) { + const no_actions_error = new SvelteKitError( + 405, + 'Method Not Allowed', + `POST method not allowed. No form actions exist for ${DEV ? `the page at ${event.route.id}` : 'this page'}` + ); + + return action_json( + { + type: 'error', + error: await handle_error_and_jsonify(event, options, no_actions_error) + }, + { + status: no_actions_error.status, + headers: { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405 + // "The server must generate an Allow header field in a 405 status code response" + allow: 'GET' + } + } + ); + } + + check_named_default_separate(actions); + + try { + const data = await call_action(event, actions); + + if (__SVELTEKIT_DEV__) { + validate_action_return(data); + } + + if (data instanceof ActionFailure) { + return action_json({ + type: 'failure', + status: data.status, + // @ts-expect-error we assign a string to what is supposed to be an object. That's ok + // because we don't use the object outside, and this way we have better code navigation + // through knowing where the related interface is used. + data: stringify_action_response( + data.data, + /** @type {string} */ (event.route.id), + options.hooks.transport + ) + }); + } else { + return action_json({ + type: 'success', + status: data ? 200 : 204, + // @ts-expect-error see comment above + data: stringify_action_response( + data, + /** @type {string} */ (event.route.id), + options.hooks.transport + ) + }); + } + } catch (e) { + const err = normalize_error(e); + + if (err instanceof Redirect) { + return action_json_redirect(err); + } + + return action_json( + { + type: 'error', + error: await handle_error_and_jsonify(event, options, check_incorrect_fail_use(err)) + }, + { + status: get_status(err) + } + ); + } +} + +/** + * @param {HttpError | Error} error + */ +function check_incorrect_fail_use(error) { + return error instanceof ActionFailure + ? new Error('Cannot "throw fail()". Use "return fail()"') + : error; +} + +/** + * @param {import('@sveltejs/kit').Redirect} redirect + */ +export function action_json_redirect(redirect) { + return action_json({ + type: 'redirect', + status: redirect.status, + location: redirect.location + }); +} + +/** + * @param {import('@sveltejs/kit').ActionResult} data + * @param {ResponseInit} [init] + */ +function action_json(data, init) { + return json(data, init); +} + +/** + * @param {import('types').SWRequestEvent} event + */ +export function is_action_request(event) { + return event.request.method === 'POST'; +} + +/** + * @param {import('types').SWRequestEvent} event + * @param {import('types').SWRNode['server'] | undefined} server + * @returns {Promise} + */ +export async function handle_action_request(event, server) { + const actions = server?.actions; + + if (!actions) { + // TODO should this be a different error altogether? + event.setHeaders({ + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405 + // "The server must generate an Allow header field in a 405 status code response" + allow: 'GET' + }); + return { + type: 'error', + error: new SvelteKitError( + 405, + 'Method Not Allowed', + `POST method not allowed. No form actions exist for ${DEV ? `the page at ${event.route.id}` : 'this page'}` + ) + }; + } + + check_named_default_separate(actions); + + try { + const data = await call_action(event, actions); + + if (__SVELTEKIT_DEV__) { + validate_action_return(data); + } + + if (data instanceof ActionFailure) { + return { + type: 'failure', + status: data.status, + data: data.data + }; + } else { + return { + type: 'success', + status: 200, + data + }; + } + } catch (e) { + const err = normalize_error(e); + + if (err instanceof Redirect) { + return { + type: 'redirect', + status: err.status, + location: err.location + }; + } + + return { + type: 'error', + error: check_incorrect_fail_use(err) + }; + } +} + +/** + * @param {string[]} actions + */ +function check_named_default_separate(actions) { + if (actions.includes('default') && actions.length > 1) { + throw new Error( + 'When using named actions, the default action cannot be used. See the docs for more info: https://svelte.dev/docs/kit/form-actions#named-actions' + ); + } +} + +/** + * @param {import('types').SWRequestEvent} event + * @param {NonNullable} actions + * @throws {Redirect | HttpError | SvelteKitError | Error} + */ +function call_action(event, actions) { + const url = new URL(event.request.url); + + let name = 'default'; + for (const param of url.searchParams) { + if (param[0].startsWith('/')) { + name = param[0].slice(1); + if (name === 'default') { + throw new Error('Cannot use reserved action name "default"'); + } + break; + } + } + + const action = actions.includes(name); + if (!action) { + throw new SvelteKitError(404, 'Not Found', `No action with name '${name}' found`); + } + + if (!is_form_content_type(event.request)) { + throw new SvelteKitError( + 415, + 'Unsupported Media Type', + `Form actions expect form-encoded data — received ${event.request.headers.get( + 'content-type' + )}` + ); + } + + return fetch(event.request); +} + +/** @param {any} data */ +function validate_action_return(data) { + if (data instanceof Redirect) { + throw new Error('Cannot `return redirect(...)` — use `redirect(...)` instead'); + } + + if (data instanceof HttpError) { + throw new Error('Cannot `return error(...)` — use `error(...)` or `return fail(...)` instead'); + } +} + +/** + * Try to `devalue.uneval` the data object, and if it fails, return a proper Error with context + * @param {any} data + * @param {string} route_id + * @param {import('types').ServerHooks['transport']} transport + */ +export function uneval_action_response(data, route_id, transport) { + const replacer = (/** @type {any} */ thing) => { + for (const key in transport) { + const encoded = transport[key].encode(thing); + if (encoded) { + return `app.decode('${key}', ${devalue.uneval(encoded, replacer)})`; + } + } + }; + + return try_serialize(data, (value) => devalue.uneval(value, replacer), route_id); +} + +/** + * Try to `devalue.stringify` the data object, and if it fails, return a proper Error with context + * @param {any} data + * @param {string} route_id + * @param {import('types').ServerHooks['transport']} transport + */ +function stringify_action_response(data, route_id, transport) { + const encoders = Object.fromEntries( + Object.entries(transport).map(([key, value]) => [key, value.encode]) + ); + + return try_serialize(data, (value) => devalue.stringify(value, encoders), route_id); +} + +/** + * @param {any} data + * @param {(data: any) => string} fn + * @param {string} route_id + */ +function try_serialize(data, fn, route_id) { + try { + return fn(data); + } catch (e) { + // If we're here, the data could not be serialized with devalue + const error = /** @type {any} */ (e); + + // if someone tries to use `json()` in their action + if (data instanceof Response) { + throw new Error( + `Data returned from action inside ${route_id} is not serializable. Form actions need to return plain objects or fail(). E.g. return { success: true } or return fail(400, { message: "invalid" });` + ); + } + + // if devalue could not serialize a property on the object, etc. + if ('path' in error) { + let message = `Data returned from action inside ${route_id} is not serializable: ${error.message}`; + if (error.path !== '') message += ` (data.${error.path})`; + throw new Error(message); + } + + throw error; + } +} diff --git a/packages/kit/src/runtime/service-worker/page/index.js b/packages/kit/src/runtime/service-worker/page/index.js new file mode 100644 index 000000000000..6fab9ec21a8a --- /dev/null +++ b/packages/kit/src/runtime/service-worker/page/index.js @@ -0,0 +1,278 @@ +import { text } from '../../../exports/index.js'; +import { compact } from '../../../utils/array.js'; +import { get_status, normalize_error } from '../../../utils/error.js'; +import { Redirect } from '../../control.js'; +import { redirect_response, static_error_page, handle_error_and_jsonify } from '../utils.js'; +import { + handle_action_json_request, + handle_action_request, + is_action_json_request, + is_action_request +} from './actions.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 { SWPageNodes } from '../../../utils/page_nodes.js'; + +/** + * The maximum request depth permitted before assuming we're stuck in an infinite loop + */ +const MAX_DEPTH = 10; + +/** + * @param {import('types').SWRequestEvent} event + * @param {import('types').PageNodeIndexes} page + * @param {import('types').SWROptions} options + * @param {import('types').SWRManifest} manifest + * @param {import('types').SWRState} state + * @param {import('../../../utils/page_nodes.js').SWPageNodes} nodes + * @param {import('types').RequiredResolveOptions} resolve_opts + * @returns {Promise} + */ +export async function render_page(event, page, options, manifest, state, nodes, resolve_opts) { + if (state.depth > MAX_DEPTH) { + // infinite request cycle detected + return text(`Not found: ${event.url.pathname}`, { + status: 404 // TODO in some cases this should be 500. not sure how to differentiate + }); + } + + if (is_action_json_request(event)) { + const node = await manifest.nodes[page.leaf](); + return handle_action_json_request(event, options, node?.server); + } + + try { + const leaf_node = /** @type {import('types').SWRNode} */ (nodes.page()); + + let status = 200; + + /** @type {import('@sveltejs/kit').ActionResult | undefined} */ + let action_result = undefined; + + if (is_action_request(event)) { + // for action requests, first call handler in +page.server.js + // (this also determines status code) + action_result = await handle_action_request(event, leaf_node.server); + if (action_result?.type === 'redirect') { + return redirect_response(action_result.status, action_result.location); + } + if (action_result?.type === 'error') { + status = get_status(action_result.error); + } + if (action_result?.type === 'failure') { + status = action_result.status; + } + } + + // it's crucial that we do this before returning the non-SSR response, otherwise + // SvelteKit will erroneously believe that the path has been prerendered, + // causing functions to be omitted from the manifest generated later + const should_prerender = nodes.prerender(); + if (should_prerender) { + const mod = leaf_node.server; + if (mod?.actions) { + throw new Error('Cannot prerender pages with actions'); + } + } + + /** @type {import('./types.js').Fetched[]} */ + const fetched = []; + + const ssr = nodes.ssr(); + const csr = nodes.csr(); + + // renders an empty 'shell' page if SSR is turned off and if there is + // no server data to prerender. As a result, the load functions and rendering + // only occur client-side. + if (ssr === false) { + // if the user makes a request through a non-enhanced form, the returned value is lost + // because there is no SSR or client-side handling of the response + if (DEV && action_result && !event.request.headers.has('x-sveltekit-action')) { + if (action_result.type === 'error') { + console.warn( + "The form action returned an error, but +error.svelte wasn't rendered because SSR is off. To get the error page with CSR, enhance your form with `use:enhance`. See https://svelte.dev/docs/kit/form-actions#progressive-enhancement-use-enhance" + ); + } else if (action_result.data) { + /// case: lost data + console.warn( + "The form action returned a value, but it isn't available in `page.form`, because SSR is off. To handle the returned value in CSR, enhance your form with `use:enhance`. See https://svelte.dev/docs/kit/form-actions#progressive-enhancement-use-enhance" + ); + } + } + + return await render_response({ + branch: [], + fetched, + page_config: { + ssr: false, + csr + }, + status, + error: null, + event, + options, + manifest, + state, + resolve_opts + }); + } + + /** @type {Array} */ + const branch = []; + + /** @type {Error | null} */ + let load_error = null; + + /** @type {Array>} */ + const server_promises = nodes.data.map((node) => { + if (load_error) { + // if an error happens immediately, don't bother with the rest of the nodes + throw load_error; + } + + return Promise.resolve().then(async () => { + try { + if (node === leaf_node && action_result?.type === 'error') { + // we wait until here to throw the error so that we can use + // any nested +error.svelte components that were defined + throw action_result.error; + } + + return await load_server_data({ + event, + options, + node + }); + } catch (e) { + load_error = /** @type {Error} */ (e); + throw load_error; + } + }); + }); + + /** @type {Array | null>>} */ + const load_promises = nodes.data.map((node, i) => { + if (load_error) throw load_error; + return Promise.resolve().then(async () => { + try { + return await load_data({ + event, + fetched, + node, + parent: async () => { + const data = {}; + for (let j = 0; j < i; j += 1) { + Object.assign(data, await load_promises[j]); + } + return data; + }, + resolve_opts, + server_data_promise: server_promises[i], + csr + }); + } catch (e) { + load_error = /** @type {Error} */ (e); + throw load_error; + } + }); + }); + + // if we don't do this, rejections will be unhandled + for (const p of server_promises) p.catch(() => {}); + for (const p of load_promises) p.catch(() => {}); + + for (let i = 0; i < nodes.data.length; i += 1) { + const node = nodes.data[i]; + + if (node) { + try { + const server_data = await server_promises[i]; + const data = await load_promises[i]; + + branch.push({ node, server_data, data }); + } catch (e) { + const err = normalize_error(e); + + if (err instanceof Redirect) { + return redirect_response(err.status, err.location); + } + + const status = get_status(err); + const error = await handle_error_and_jsonify(event, options, err); + + while (i--) { + if (page.errors[i]) { + const index = /** @type {number} */ (page.errors[i]); + const node = await manifest.nodes[index](); + + let j = i; + while (!branch[j]) j -= 1; + + const layouts = compact(branch.slice(0, j + 1)); + const nodes = new SWPageNodes(layouts.map((layout) => layout.node)); + + return await render_response({ + event, + options, + manifest, + state, + resolve_opts, + page_config: { + ssr: nodes.ssr(), + csr: nodes.csr() + }, + status, + error, + branch: layouts.concat({ + node, + data: null, + server_data: null + }), + fetched + }); + } + } + + // if we're still here, it means the error happened in the root layout, + // which means we have to fall back to error.html + return static_error_page(options, status, error.message); + } + } else { + // push an empty slot so we can rewind past gaps to the + // layout that corresponds with an +error.svelte page + branch.push(null); + } + } + + return await render_response({ + event, + options, + manifest, + state, + resolve_opts, + page_config: { + csr, + ssr + }, + status, + error: null, + branch: compact(branch), + action_result, + fetched + }); + } catch (e) { + // if we end up here, it means the data loaded successfully + // but the page failed to render, or that a prerendering error occurred + return await respond_with_error({ + event, + options, + manifest, + state, + status: 500, + error: e, + resolve_opts + }); + } +} diff --git a/packages/kit/src/runtime/service-worker/page/load_data.js b/packages/kit/src/runtime/service-worker/page/load_data.js new file mode 100644 index 000000000000..0b6374cc4a20 --- /dev/null +++ b/packages/kit/src/runtime/service-worker/page/load_data.js @@ -0,0 +1,412 @@ +import { DEV } from 'esm-env'; +import { make_trackable } from '../../../utils/url.js'; +import { b64_encode } from '../../utils.js'; +import * as devalue from 'devalue'; +import { HttpError } from '../../control.js'; +import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM } from '../../shared.js'; +import { add_data_suffix } from '../../pathname.js'; + +/** + * Calls the user's server `load` function. + * @param {{ + * event: import('types').SWRequestEvent; + * options: import('types').SWROptions; + * node: import('types').SWRNode | undefined; + * }} opts + * @returns {Promise} + */ +export async function load_server_data({ event, options, node }) { + if (!node?.server) return null; + + const uses = { + dependencies: new Set(), + params: new Set(), + parent: false, + route: false, + url: false, + search_params: new Set() + }; + + const load = node.server.load; + const slash = node.server.trailingSlash; + + if (!load) { + return { type: 'data', data: null, uses, slash }; + } + + const url = make_trackable( + event.url, + () => { + if (DEV && done && !uses.url) { + console.warn( + `${node.server_id}: Accessing URL properties in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the URL changes` + ); + } + + uses.url = true; + }, + (param) => { + if (DEV && done && !uses.search_params.has(param)) { + console.warn( + `${node.server_id}: Accessing URL properties in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the URL changes` + ); + } + + uses.search_params.add(param); + } + ); + + const done = false; + const data_url = new URL(event.url); + data_url.pathname = add_data_suffix(event.url.pathname); + + if (url.pathname.endsWith('/')) { + data_url.searchParams.append(TRAILING_SLASH_PARAM, '1'); + } + + if (DEV && url.searchParams.has(INVALIDATED_PARAM)) { + throw new Error(`Cannot used reserved query parameter "${INVALIDATED_PARAM}"`); + } + + // use self.fetch directly to allow using a 3rd party-patched fetch implementation + const fetcher = self.fetch; + const res = await fetcher(data_url.href, {}); + + if (!res.ok) { + // error message is a JSON-stringified string which devalue can't handle at the top level + // turn it into a HttpError to not call handleError on the client again (was already handled on the server) + // if `__data.json` doesn't exist or the server has an internal error, + // avoid parsing the HTML error page as a JSON + /** @type {string | undefined} */ + let message; + if (res.headers.get('content-type')?.includes('application/json')) { + message = await res.json(); + } else if (res.status === 404) { + message = 'Not Found'; + } else if (res.status === 500) { + message = 'Internal Error'; + } + throw new HttpError(res.status, message); + } + + // TODO: fix eslint error / figure out if it actually applies to our situation + // eslint-disable-next-line + return new Promise(async (resolve) => { + /** + * Map of deferred promises that will be resolved by a subsequent chunk of data + * @type {Map} + */ + const deferreds = new Map(); + const reader = /** @type {ReadableStream} */ (res.body).getReader(); + const decoder = new TextDecoder(); + + const decoders = Object.fromEntries( + Object.entries(options.hooks.transport).map(([key, value]) => [key, value.decode]) + ); + + /** + * @param {any} data + */ + function deserialize(data) { + return devalue.unflatten(data, { + ...decoders, + Promise: (id) => { + return new Promise((fulfil, reject) => { + deferreds.set(id, { fulfil, reject }); + }); + } + }); + } + + let text = ''; + + while (true) { + // Format follows ndjson (each line is a JSON object) or regular JSON spec + const { done, value } = await reader.read(); + if (done && !text) break; + + text += !value && text ? '\n' : decoder.decode(value, { stream: true }); // no value -> final chunk -> add a new line to trigger the last parse + + while (true) { + const split = text.indexOf('\n'); + if (split === -1) { + break; + } + + const node = JSON.parse(text.slice(0, split)); + text = text.slice(split + 1); + + if (node.type === 'redirect') { + return resolve(node); + } + + if (node.type === 'data') { + // This is the first (and possibly only, if no pending promises) chunk + node.nodes?.forEach((/** @type {any} */ node) => { + if (node?.type === 'data') { + node.uses = deserialize_uses(node.uses); + node.data = deserialize(node.data); + } + }); + + resolve(node); + } else if (node.type === 'chunk') { + // This is a subsequent chunk containing deferred data + const { id, data, error } = node; + const deferred = /** @type {import('types').Deferred} */ (deferreds.get(id)); + deferreds.delete(id); + + if (error) { + deferred.reject(deserialize(error)); + } else { + deferred.fulfil(deserialize(data)); + } + } + } + } + }); +} + +/** + * Calls the user's `load` function. + * @param {{ + * event: import('types').SWRequestEvent; + * fetched: import('./types.js').Fetched[]; + * node: import('types').SWRNode | undefined; + * parent: () => Promise>; + * resolve_opts: import('types').RequiredResolveOptions; + * server_data_promise: Promise; + * csr: boolean; + * }} opts + * @returns {Promise> | null>} + */ +export async function load_data({ + event, + fetched, + node, + parent, + server_data_promise, + resolve_opts, + csr +}) { + const server_data_node = await server_data_promise; + + if (!node?.universal?.load) { + return server_data_node?.data ?? null; + } + + const result = await node.universal.load.call(null, { + url: event.url, + params: event.params, + data: server_data_node?.data ?? null, + route: event.route, + fetch: create_universal_fetch(event, fetched, csr, resolve_opts), + setHeaders: event.setHeaders, + depends: () => {}, + parent, + untrack: (fn) => fn() + }); + + if (__SVELTEKIT_DEV__) { + validate_load_response(result, node.universal_id); + } + + return result ?? null; +} + +/** + * @param {Pick} event + * @param {import('./types.js').Fetched[]} fetched + * @param {boolean} csr + * @param {Pick, 'filterSerializedResponseHeaders'>} resolve_opts + * @returns {typeof fetch} + */ +export function create_universal_fetch(event, fetched, csr, resolve_opts) { + /** + * @param {URL | RequestInfo} input + * @param {RequestInit} [init] + */ + const universal_fetch = async (input, init) => { + const cloned_body = input instanceof Request && input.body ? input.clone().body : null; + + const cloned_headers = + input instanceof Request && [...input.headers].length + ? new Headers(input.headers) + : init?.headers; + + let response = await event.fetch(input, init); + + const url = new URL(input instanceof Request ? input.url : input, event.url); + const same_origin = url.origin === event.url.origin; + + if (!same_origin && (url.protocol === 'https:' || url.protocol === 'http:')) { + // simulate CORS errors and "no access to body in no-cors mode" server-side for consistency with client-side behaviour + const mode = input instanceof Request ? input.mode : (init?.mode ?? 'cors'); + if (mode === 'no-cors') { + response = new Response('', { + status: response.status, + statusText: response.statusText, + headers: response.headers + }); + } else { + const acao = response.headers.get('access-control-allow-origin'); + if (!acao || (acao !== event.url.origin && acao !== '*')) { + throw new Error( + `CORS error: ${ + acao ? 'Incorrect' : 'No' + } 'Access-Control-Allow-Origin' header is present on the requested resource` + ); + } + } + } + + const proxy = new Proxy(response, { + get(response, key, _receiver) { + /** + * @param {string} body + * @param {boolean} is_b64 + */ + async function push_fetched(body, is_b64) { + const status_number = Number(response.status); + if (isNaN(status_number)) { + throw new Error( + `response.status is not a number. value: "${ + response.status + }" type: ${typeof response.status}` + ); + } + + fetched.push({ + url: same_origin ? url.href.slice(event.url.origin.length) : url.href, + method: event.request.method, + request_body: /** @type {string | ArrayBufferView | undefined} */ ( + input instanceof Request && cloned_body + ? await stream_to_string(cloned_body) + : init?.body + ), + request_headers: cloned_headers, + response_body: body, + response, + is_b64 + }); + } + + if (key === 'arrayBuffer') { + return async () => { + const buffer = await response.arrayBuffer(); + + if (buffer instanceof ArrayBuffer) { + await push_fetched(b64_encode(buffer), true); + } + + return buffer; + }; + } + + async function text() { + const body = await response.text(); + + if (!body || typeof body === 'string') { + await push_fetched(body, false); + } + + return body; + } + + if (key === 'text') { + return text; + } + + if (key === 'json') { + return async () => { + return JSON.parse(await text()); + }; + } + + return Reflect.get(response, key, response); + } + }); + + if (csr) { + // ensure that excluded headers can't be read + const get = response.headers.get; + response.headers.get = (key) => { + const lower = key.toLowerCase(); + const value = get.call(response.headers, lower); + if (value && !lower.startsWith('x-sveltekit-')) { + const included = resolve_opts.filterSerializedResponseHeaders(lower, value); + if (!included) { + throw new Error( + `Failed to get response header "${lower}" — it must be included by the \`filterSerializedResponseHeaders\` option: https://svelte.dev/docs/kit/hooks#Server-hooks-handle (at ${event.route.id})` + ); + } + } + + return value; + }; + } + + return proxy; + }; + + // Don't make this function `async`! Otherwise, the user has to `catch` promises they use for streaming responses or else + // it will be an unhandled rejection. Instead, we add a `.catch(() => {})` ourselves below to this from happening. + return (input, init) => { + // See docs in fetch.js for why we need to do this + const response = universal_fetch(input, init); + response.catch(() => {}); + return response; + }; +} + +/** + * @param {ReadableStream} stream + */ +async function stream_to_string(stream) { + let result = ''; + const reader = stream.getReader(); + const decoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + result += decoder.decode(value); + } + return result; +} + +/** + * @param {any} data + * @param {string} [id] + */ +function validate_load_response(data, id) { + if (data != null && Object.getPrototypeOf(data) !== Object.prototype) { + throw new Error( + `a load function in ${id} returned ${ + typeof data !== 'object' + ? `a ${typeof data}` + : data instanceof Response + ? 'a Response object' + : Array.isArray(data) + ? 'an array' + : 'a non-plain object' + }, but must return a plain object at the top level (i.e. \`return {...}\`)` + ); + } +} + +/** + * @param {any} uses + * @return {import('types').Uses} + */ +function deserialize_uses(uses) { + return { + dependencies: new Set(uses?.dependencies ?? []), + params: new Set(uses?.params ?? []), + parent: !!uses?.parent, + route: !!uses?.route, + url: !!uses?.url, + search_params: new Set(uses?.search_params ?? []) + }; +} diff --git a/packages/kit/src/runtime/service-worker/page/load_data.spec.js b/packages/kit/src/runtime/service-worker/page/load_data.spec.js new file mode 100644 index 000000000000..eac8a758914e --- /dev/null +++ b/packages/kit/src/runtime/service-worker/page/load_data.spec.js @@ -0,0 +1,74 @@ +import { assert, expect, test } from 'vitest'; +import { create_universal_fetch } from './load_data.js'; + +/** + * @param {Partial>} event + */ +function create_fetch(event) { + // eslint-disable-next-line @typescript-eslint/require-await + event.fetch = event.fetch || (async () => new Response('foo')); + event.request = event.request || new Request('doesnt:matter'); + event.route = event.route || { id: 'foo' }; + event.url = event.url || new URL('https://domain-a.com'); + return create_universal_fetch( + /** @type {Pick} */ ( + event + ), + [], + true, + { + filterSerializedResponseHeaders: () => false + } + ); +} + +test('sets body to empty when mode is no-cors', async () => { + const fetch = create_fetch({}); + const response = await fetch('https://domain-b.com', { mode: 'no-cors' }); + const text = await response.text(); + assert.equal(text, ''); +}); + +test('keeps body when mode isnt no-cors on same domain', async () => { + const fetch = create_fetch({}); + const response = await fetch('https://domain-a.com'); + const text = await response.text(); + assert.equal(text, 'foo'); +}); + +test('succeeds when acao header present on cors', async () => { + const fetch = create_fetch({ + // eslint-disable-next-line @typescript-eslint/require-await + fetch: async () => new Response('foo', { headers: { 'access-control-allow-origin': '*' } }) + }); + const response = await fetch('https://domain-a.com'); + const text = await response.text(); + assert.equal(text, 'foo'); +}); + +test('errors when no acao header present on cors', async () => { + const fetch = create_fetch({}); + + await expect(async () => { + const response = await fetch('https://domain-b.com'); + await response.text(); + }).rejects.toThrowError( + "CORS error: No 'Access-Control-Allow-Origin' header is present on the requested resource" + ); +}); + +test('succeeds when fetching from local scheme', async () => { + const fetch = create_fetch({}); + const response = await fetch('data:text/plain;foo'); + const text = await response.text(); + assert.equal(text, 'foo'); +}); + +test('errors when trying to access non-serialized request headers on the server', async () => { + const fetch = create_fetch({}); + const response = await fetch('https://domain-a.com'); + assert.throws( + () => response.headers.get('content-type'), + /Failed to get response header "content-type" — it must be included by the `filterSerializedResponseHeaders` option/ + ); +}); diff --git a/packages/kit/src/runtime/service-worker/page/render.js b/packages/kit/src/runtime/service-worker/page/render.js new file mode 100644 index 000000000000..e8c80e4faf0e --- /dev/null +++ b/packages/kit/src/runtime/service-worker/page/render.js @@ -0,0 +1,612 @@ +import * as devalue from 'devalue'; +import { readable, writable } from 'svelte/store'; +import { DEV } from 'esm-env'; +import * as paths from '__sveltekit/paths'; +import { hash } from '../../hash.js'; +import { serialize_data } from '../../server/page/serialize_data.js'; +import { s } from '../../../utils/misc.js'; +import { Csp } from '../../server/page/csp.js'; +import { uneval_action_response } from './actions.js'; +import { clarify_devalue_error, handle_error_and_jsonify, serialize_uses } from '../utils.js'; +import { public_env, safe_public_env } from '../../shared-server.js'; +import { text } from '../../../exports/index.js'; +import { create_async_iterator } from '../../../utils/streaming.js'; +import { SVELTE_KIT_ASSETS } from '../../../constants.js'; +import { SCHEME } from '../../../utils/url.js'; +import { generate_route_object } from './server_routing.js'; + +// TODO rename this function/module + +const updated = { + ...readable(false), + check: () => false +}; + +const encoder = new TextEncoder(); + +/** + * Creates the HTML response. + * @param {{ + * branch: Array; + * fetched: Array; + * options: import('types').SWROptions; + * manifest: import('types').SWRManifest; + * state: import('types').SWRState; + * page_config: { ssr: boolean; csr: boolean }; + * status: number; + * error: App.Error | null; + * event: import('types').SWRequestEvent; + * resolve_opts: import('types').RequiredResolveOptions; + * action_result?: import('@sveltejs/kit').ActionResult; + * }} opts + */ +export async function render_response({ + branch, + fetched, + options, + manifest, + page_config, + status, + error = null, + event, + resolve_opts, + action_result +}) { + const { client } = manifest; + + const modulepreloads = new Set(client.imports); + const stylesheets = new Set(client.stylesheets); + const fonts = new Set(client.fonts); + + /** @type {Set} */ + const link_header_preloads = new Set(); + + /** @type {Map} */ + // 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(); + + 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); + + // if appropriate, use relative paths for greater portability + if (paths.relative) { + const segments = event.url.pathname.slice(paths.base.length).split('/').slice(2); + + base = segments.map(() => '..').join('/') || '.'; + + // resolve e.g. '../..' against current location, then remove trailing slash + base_expression = `new URL(${s(base)}, location).pathname.slice(0, -1)`; + + if (!paths.assets || (paths.assets[0] === '/' && paths.assets !== SVELTE_KIT_ASSETS)) { + assets = base; + } + } + + if (page_config.ssr) { + /** @type {Record} */ + const props = { + stores: { + page: writable(null), + navigating: writable(null), + updated + }, + constructors: await Promise.all( + branch.map(({ node }) => { + if (!node.component) { + // Can only be the leaf, layouts have a fallback component generated + throw new Error(`Missing +page.svelte component for route ${event.route.id}`); + } + return node.component(); + }) + ), + form: form_value + }; + + let data = {}; + + // props_n (instead of props[n]) makes it easy to avoid + // unnecessary updates for layout components + for (let i = 0; i < branch.length; i += 1) { + data = { ...data, ...branch[i].data }; + props[`data_${i}`] = data; + } + + props.page = { + error, + params: /** @type {Record} */ (event.params), + route: event.route, + status, + url: event.url, + data, + form: form_value, + state: {} + }; + + // use relative paths during rendering, so that the resulting HTML is as + // portable as possible, but reset afterwards + if (paths.relative) paths.override({ base, assets }); + + const render_opts = { + context: new Map([ + [ + '__request__', + { + page: props.page + } + ] + ]) + }; + + if (__SVELTEKIT_DEV__) { + const fetch = globalThis.fetch; + let warned = false; + globalThis.fetch = (info, init) => { + if (typeof info === 'string' && !SCHEME.test(info)) { + 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) { + console.warn( + 'Avoid calling `fetch` eagerly during server side rendering — put your `fetch` calls inside `onMount` or a `load` function instead' + ); + warned = true; + } + + return fetch(info, init); + }; + + try { + rendered = options.root.render(props, render_opts); + } finally { + globalThis.fetch = fetch; + paths.reset(); + } + } else { + try { + rendered = options.root.render(props, render_opts); + } finally { + paths.reset(); + } + } + + 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 url of node.fonts) fonts.add(url); + + if (node.inline_styles && !client.inline) { + Object.entries(await node.inline_styles()).forEach(([k, v]) => inline_styles.set(k, v)); + } + } + } else { + rendered = { head: '', html: '', css: { code: '', map: null } }; + } + + let head = ''; + let body = rendered.html; + + const csp = new Csp(options.csp, { + prerender: false + }); + + /** @param {string} path */ + const prefixed = (path) => { + if (path.startsWith('/')) { + // Vite makes the start script available through the base path and without it. + // We load it via the base path in order to support remote IDE environments which proxy + // all URLs under the base path during development. + return paths.base + path; + } + return `${assets}/${path}`; + }; + + // inline styles can come from `bundleStrategy: 'inline'` or `inlineStyleThreshold` + const style = client.inline + ? client.inline?.style + : Array.from(inline_styles.values()).join('\n'); + + if (style) { + const attributes = __SVELTEKIT_DEV__ ? [' data-sveltekit'] : []; + if (csp.style_needs_nonce) attributes.push(` nonce="${csp.nonce}"`); + + csp.add_style(style); + + head += `\n\t${style}`; + } + + 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 (resolve_opts.preload({ type: 'css', path })) { + const preload_atts = ['rel="preload"', 'as="style"']; + link_header_preloads.add(`<${encodeURI(path)}>; ${preload_atts.join(';')}; nopush`); + } + } + + head += `\n\t\t`; + } + + for (const dep of fonts) { + const path = prefixed(dep); + + if (resolve_opts.preload({ type: 'font', path })) { + const ext = dep.slice(dep.lastIndexOf('.') + 1); + const attributes = [ + 'rel="preload"', + 'as="font"', + `type="font/${ext}"`, + `href="${path}"`, + 'crossorigin' + ]; + + head += `\n\t\t`; + } + } + + const global = __SVELTEKIT_DEV__ ? '__sveltekit_dev' : `__sveltekit_${options.version_hash}`; + + const { data, chunks } = get_data( + event, + options, + branch.map((b) => b.server_data), + csp, + global + ); + + if (page_config.ssr && page_config.csr) { + body += `\n\t\t\t${fetched + .map((item) => serialize_data(item, resolve_opts.filterSerializedResponseHeaders, false)) + .join('\n\t\t\t')}`; + } + + if (page_config.csr) { + const route = manifest.client.routes?.find((r) => r.id === event.route.id) ?? null; + + if (!client.inline) { + const included_modulepreloads = Array.from(modulepreloads, (dep) => prefixed(dep)).filter( + (path) => resolve_opts.preload({ type: 'js', path }) + ); + + for (const path of included_modulepreloads) { + // see the kit.output.preloadStrategy option for details on why we have multiple options here + link_header_preloads.add(`<${encodeURI(path)}>; rel="modulepreload"; nopush`); + if (options.preload_strategy !== 'modulepreload') { + head += `\n\t\t`; + } + } + } + + const blocks = []; + + // when serving a prerendered page in an app that uses $env/dynamic/public, 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 = false; + + const properties = [`base: ${base_expression}`]; + + if (paths.assets) { + properties.push(`assets: ${s(paths.assets)}`); + } + + if (client.uses_env_dynamic_public) { + properties.push(`env: ${load_env_eagerly ? 'null' : s(public_env)}`); + } + + if (chunks) { + blocks.push('const deferred = new Map();'); + + properties.push(`defer: (id) => new Promise((fulfil, reject) => { + deferred.set(id, { fulfil, reject }); + })`); + + // 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: ({ id, data, error }) => { + 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), + options.hooks.transport + ); + } + + 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) { + hydrate.push(`status: ${status}`); + } + + if (manifest.client.routes) { + if (route) { + const stringified = generate_route_object(route, event.url, manifest).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 (options.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}}`); + } + + // `client.app` is a proxy for `bundleStrategy === 'split'` + const boot = client.inline + ? `${client.inline.script} + + __sveltekit_${options.version_hash}.app.start(${args.join(', ')});` + : client.app + ? `Promise.all([ + import(${s(prefixed(client.start))}), + import(${s(prefixed(client.app))}) + ]).then(([kit, app]) => { + kit.start(app, ${args.join(', ')}); + });` + : `import(${s(prefixed(client.start))}).then((app) => { + 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 (options.service_worker) { + const opts = __SVELTEKIT_DEV__ ? ", { type: 'module' }" : ''; + + // 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) { + addEventListener('load', function () { + navigator.serviceWorker.register('${prefixed('service-worker.js')}'${opts}); + }); + }`); + } + + const init_app = ` + { + ${blocks.join('\n\n\t\t\t\t\t')} + } + `; + csp.add_script(init_app); + + body += `\n\t\t\t${init_app}\n\t\t`; + } + + const headers = new Headers({ + 'x-sveltekit-page': 'true', + 'content-type': 'text/html' + }); + + 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 (link_header_preloads.size) { + headers.set('link', Array.from(link_header_preloads).join(', ')); + } + + // add the content after the script/css links so the link elements are parsed first + head += rendered.head; + + const html = options.templates.app({ + head, + body, + assets, + nonce: /** @type {string} */ (csp.nonce), + env: safe_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 (transformed.split('