From a07a2893ce00ec38752c071fd82aa4ba82dd6350 Mon Sep 17 00:00:00 2001 From: Thomas Foster Date: Thu, 15 May 2025 11:10:26 +0000 Subject: [PATCH 1/7] Initial Proof of Concept --- .changeset/ten-pens-develop.md | 5 + .../generate_manifest/find_server_assets.js | 52 ++ .../kit/src/core/generate_manifest/index.js | 141 +++- .../vite/build/build_service_worker.js | 309 ++++++++- packages/kit/src/exports/vite/index.js | 16 +- packages/kit/src/runtime/app/server/event.js | 7 +- .../src/runtime/service-worker/ambient.d.ts | 4 + .../src/runtime/service-worker/data/index.js | 236 +++++++ .../src/runtime/service-worker/endpoint.js | 21 + .../src/runtime/service-worker/env_module.js | 29 + .../kit/src/runtime/service-worker/fetch.js | 188 ++++++ .../kit/src/runtime/service-worker/index.js | 95 +++ .../runtime/service-worker/page/actions.js | 324 ++++++++++ .../src/runtime/service-worker/page/crypto.js | 239 +++++++ .../service-worker/page/crypto.spec.js | 24 + .../src/runtime/service-worker/page/csp.js | 366 +++++++++++ .../runtime/service-worker/page/csp.spec.js | 414 ++++++++++++ .../src/runtime/service-worker/page/index.js | 286 ++++++++ .../runtime/service-worker/page/load_data.js | 392 +++++++++++ .../service-worker/page/load_data.spec.js | 74 +++ .../src/runtime/service-worker/page/render.js | 612 ++++++++++++++++++ .../service-worker/page/respond_with_error.js | 112 ++++ .../service-worker/page/serialize_data.js | 107 +++ .../page/serialize_data.spec.js | 103 +++ .../service-worker/page/server_routing.js | 143 ++++ .../runtime/service-worker/page/types.d.ts | 36 ++ .../kit/src/runtime/service-worker/respond.js | 542 ++++++++++++++++ .../kit/src/runtime/service-worker/utils.js | 177 +++++ packages/kit/src/types/ambient-private.d.ts | 9 + packages/kit/src/types/ambient.d.ts | 4 + packages/kit/src/types/internal.d.ts | 214 +++++- 31 files changed, 5240 insertions(+), 41 deletions(-) create mode 100644 .changeset/ten-pens-develop.md create mode 100644 packages/kit/src/runtime/service-worker/ambient.d.ts create mode 100644 packages/kit/src/runtime/service-worker/data/index.js create mode 100644 packages/kit/src/runtime/service-worker/endpoint.js create mode 100644 packages/kit/src/runtime/service-worker/env_module.js create mode 100644 packages/kit/src/runtime/service-worker/fetch.js create mode 100644 packages/kit/src/runtime/service-worker/index.js create mode 100644 packages/kit/src/runtime/service-worker/page/actions.js create mode 100644 packages/kit/src/runtime/service-worker/page/crypto.js create mode 100644 packages/kit/src/runtime/service-worker/page/crypto.spec.js create mode 100644 packages/kit/src/runtime/service-worker/page/csp.js create mode 100644 packages/kit/src/runtime/service-worker/page/csp.spec.js create mode 100644 packages/kit/src/runtime/service-worker/page/index.js create mode 100644 packages/kit/src/runtime/service-worker/page/load_data.js create mode 100644 packages/kit/src/runtime/service-worker/page/load_data.spec.js create mode 100644 packages/kit/src/runtime/service-worker/page/render.js create mode 100644 packages/kit/src/runtime/service-worker/page/respond_with_error.js create mode 100644 packages/kit/src/runtime/service-worker/page/serialize_data.js create mode 100644 packages/kit/src/runtime/service-worker/page/serialize_data.spec.js create mode 100644 packages/kit/src/runtime/service-worker/page/server_routing.js create mode 100644 packages/kit/src/runtime/service-worker/page/types.d.ts create mode 100644 packages/kit/src/runtime/service-worker/respond.js create mode 100644 packages/kit/src/runtime/service-worker/utils.js 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/generate_manifest/find_server_assets.js b/packages/kit/src/core/generate_manifest/find_server_assets.js index b7232e63e0b8..dc27ffc5ce5e 100644 --- a/packages/kit/src/core/generate_manifest/find_server_assets.js +++ b/packages/kit/src/core/generate_manifest/find_server_assets.js @@ -51,3 +51,55 @@ export function find_server_assets(build_data, routes) { return Array.from(server_assets); } + +/** + * Finds all the assets that are imported by server files associated with `routes` + * @param {import('types').BuildData} build_data + * @param {import('types').RouteData[]} routes + */ +export function find_service_worker_assets(build_data, routes) { + /** + * 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]); + + // TODO add hooks.server.js asset imports + /** @type {Set} */ + const server_assets = new Set(); + + /** @param {string} id */ + function add_assets(id) { + if (id in build_data.server_manifest) { + const deps = find_deps(build_data.server_manifest, id, false); + for (const asset of deps.assets) { + server_assets.add(asset); + } + } + } + + 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); + } + + if (route.endpoint) { + add_assets(route.endpoint.file); + } + } + + for (const n of used_nodes) { + const node = build_data.manifest_data.nodes[n]; + if (node?.universal) add_assets(node.universal); + if (node?.server) add_assets(node.server); + } + + if (build_data.manifest_data.hooks.server) { + add_assets(build_data.manifest_data.hooks.server); + } + + return Array.from(server_assets); +} diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js index eaaf9e6cd38e..cd5b6b03a049 100644 --- a/packages/kit/src/core/generate_manifest/index.js +++ b/packages/kit/src/core/generate_manifest/index.js @@ -7,7 +7,7 @@ import { resolve_symlinks } from '../../exports/vite/build/utils.js'; import { compact } from '../../utils/array.js'; import { join_relative } from '../../utils/filesystem.js'; import { dedent } from '../sync/utils.js'; -import { find_server_assets } from './find_server_assets.js'; +import { find_server_assets, find_service_worker_assets } from './find_server_assets.js'; import { uneval } from 'devalue'; /** @@ -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_service_worker_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/exports/vite/build/build_service_worker.js b/packages/kit/src/exports/vite/build/build_service_worker.js index 17eff31066dc..bdb6bf04c77a 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,16 @@ -import fs from 'node:fs'; +import fs, { writeFileSync } from 'node:fs'; +import path, { basename, } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; import * as vite from 'vite'; +import { create_static_module } from '../../../core/env.js'; +import { generate_manifest } from '../../../core/generate_manifest/index.js'; import { dedent } from '../../../core/sync/utils.js'; +import { copy, mkdirp, rimraf } 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 +20,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 +30,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 +47,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 +55,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 +64,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 +78,82 @@ export async function build_service_worker( } }; + const route_data = build_data.manifest_data.routes.filter((route) => route.page); + + + let dest = `${kit.outDir}/service-worker`; + let worker_dest = `${dest}/_worker.js`; + let assets_binding = 'ASSETS'; + + const files = fileURLToPath(new URL('./files', import.meta.url).href); + const tmp = `${kit.outDir}/service-worker`; + + rimraf(dest); + rimraf(worker_dest); + + mkdirp(dest); + mkdirp(tmp); + + // client assets and prerendered pages + const assets_dest = `${dest}${kit.paths.base}`; + + const source = `${kit.outDir}/output/prerendered`; + + copy(`${source}/pages`, assets_dest); copy(`${source}/dependencies`, assets_dest); + + // worker + const worker_dest_dir = path.dirname(worker_dest); + + writeFileSync( + `${tmp}/manifest.js`, + `export const manifest = ${generate_manifest({ + build_data, + prerendered: prerendered.paths, + relative_path: path.posix.relative(tmp, `${kit.outDir}/output/server`), + routes: route_data.filter((route) => prerender_map.get(route.id) !== true) + })};\n\n` + + `export const prerendered = new Set(${JSON.stringify(prerendered.paths)});\n\n` + + `export const base_path = ${JSON.stringify(kit.paths.base)};\n` + ); + + writeFileSync( + `${tmp}/manifest.js`, + 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)}; + ` + ) + + copy(`${files}/worker.js`, worker_dest, { + replace: { + // the paths returned by the Wrangler config might be Windows paths, + // so we need to convert them to POSIX paths or else the backslashes + // will be interpreted as escape characters and create an incorrect import path + SERVER: `${posixify(path.relative(worker_dest_dir, `${kit.outDir}/output/server`))}/index.js`, + MANIFEST: `${posixify(path.relative(worker_dest_dir, tmp))}/manifest.js`, + ASSETS: assets_binding + } + }); + + await vite.build({ build: { modulePreload: false, @@ -117,7 +177,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: "" }] }, experimental: { renderBuiltUrl(filename) { @@ -131,3 +191,184 @@ 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` + ); + }); +} + +/** @param {string} str */ +function posixify(str) { + return str.replace(/\\/g, '/'); +} 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/app/server/event.js b/packages/kit/src/runtime/app/server/event.js index 8351e21e9000..464fb152724e 100644 --- a/packages/kit/src/runtime/app/server/event.js +++ b/packages/kit/src/runtime/app/server/event.js @@ -1,9 +1,10 @@ /** @import { RequestEvent } from '@sveltejs/kit' */ +/** @import { SWRequestEvent } from "types" */ -/** @type {RequestEvent | null} */ +/** @type { RequestEvent | SWRequestEvent | null} */ let request_event = null; -/** @type {import('node:async_hooks').AsyncLocalStorage} */ +/** @type {import('node:async_hooks').AsyncLocalStorage} */ let als; import('node:async_hooks') @@ -40,7 +41,7 @@ export function getRequestEvent() { /** * @template T - * @param {RequestEvent | null} event + * @param {RequestEvent | SWRequestEvent | null} event * @param {() => T} fn */ export function with_event(event, fn) { 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..d7ca2eb2cdb5 --- /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..239c71200fd9 --- /dev/null +++ b/packages/kit/src/runtime/service-worker/data/index.js @@ -0,0 +1,236 @@ +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((n, i) => { + 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; + } + }); + }); + + 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 }} + */ +export 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/endpoint.js b/packages/kit/src/runtime/service-worker/endpoint.js new file mode 100644 index 000000000000..54f29b72846d --- /dev/null +++ b/packages/kit/src/runtime/service-worker/endpoint.js @@ -0,0 +1,21 @@ +import { ENDPOINT_METHODS, PAGE_METHODS } from '../../constants.js'; +import { negotiate } from '../../utils/http.js'; + +/** + * @param {import('types').SWRequestEvent} event + */ +export function is_endpoint_request(event) { + const { method, headers } = event.request; + + // These methods exist exclusively for endpoints + if (ENDPOINT_METHODS.includes(method) && !PAGE_METHODS.includes(method)) { + return true; + } + + // use:enhance uses a custom header to disambiguate + if (method === 'POST' && headers.get('x-sveltekit-action') === 'true') return false; + + // GET/POST requests may be for endpoints or pages. We prefer endpoints if this isn't a text/html request + const accept = event.request.headers.get('accept') ?? '*/*'; + return negotiate(accept, ['*', 'text/html']) !== 'text/html'; +} 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..1151f9104f47 --- /dev/null +++ b/packages/kit/src/runtime/service-worker/fetch.js @@ -0,0 +1,188 @@ +import * as set_cookie_parser from 'set-cookie-parser'; +import { respond } from './respond.js'; +import * as paths from '__sveltekit/paths'; +import { read_implementation } from '__sveltekit/service-worker'; +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) { + const file = is_asset ? filename : filename_html; + + if (state.read) { + const type = is_asset + ? manifest.mimeTypes[filename.slice(filename.lastIndexOf('.'))] + : 'text/html'; + + return new Response(state.read(file), { + headers: type ? { 'content-type': type } : {} + }); + } else if (read_implementation && file in manifest.server_assets) { + const length = manifest.server_assets[file]; + const type = manifest.mimeTypes[file.slice(file.lastIndexOf('.'))]; + + return new Response(read_implementation(file), { + headers: { + 'Content-Length': '' + length, + 'Content-Type': type + } + }); + } + + 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..607814cec704 --- /dev/null +++ b/packages/kit/src/runtime/service-worker/index.js @@ -0,0 +1,95 @@ +import { respond } from './respond.js'; +import { set_private_env, 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_private_env, filter_public_env } from '../../utils/env.js'; +import { set_read_implementation, set_manifest } from '__sveltekit/server'; + +/** @type {Promise} */ +let init_promise; + +export class Server { + /** @type {import('types').SWROptions} */ + #options; + + /** @type {import('@sveltejs/kit').SSRManifest} */ + #manifest; + + /** @param {import('@sveltejs/kit').SSRManifest} manifest */ + constructor(manifest) { + /** @type {import('types').SWROptions} */ + this.#options = options; + this.#manifest = manifest; + + set_manifest(manifest); + } + + /** + * @param {{ + * env: Record; + * read?: (file: string) => ReadableStream; + * }} opts + */ + async init({ env, read }) { + // Take care: Some adapters may have to call `Server.init` per-request to set env vars, + // so anything that shouldn't be rerun should be wrapped in an `if` block to make sure it hasn't + // been done already. + + // 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 private_env = filter_private_env(env, prefixes); + const public_env = filter_public_env(env, prefixes); + + set_private_env(private_env); + set_public_env(public_env); + set_safe_public_env(public_env); + + if (read) { + set_read_implementation(read); + } + + // 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 = { + handleError: module.handleError || (({ error }) => console.error(error)), + reroute: module.reroute || (() => {}), + transport: module.transport || {} + }; + + if (module.init) { + await module.init(); + } + } catch (error) { + if (DEV) { + this.#options.hooks = { + handleError: ({ error }) => console.error(error), + reroute: () => {}, + transport: {} + }; + } else { + throw error; + } + } + })()); + } + + /** + * @param {Request} request + * @param {import('types').RequestOptions} options + */ + async respond(request, options) { + return respond(request, this.#options, this.#manifest, { + ...options, + 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..4bc4a26a39bf --- /dev/null +++ b/packages/kit/src/runtime/service-worker/page/actions.js @@ -0,0 +1,324 @@ +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'; +import { with_event } from '../../app/server/event.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} + */ +async 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 with_event(event, () => 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/crypto.js b/packages/kit/src/runtime/service-worker/page/crypto.js new file mode 100644 index 000000000000..9af02da5121a --- /dev/null +++ b/packages/kit/src/runtime/service-worker/page/crypto.js @@ -0,0 +1,239 @@ +const encoder = new TextEncoder(); + +/** + * SHA-256 hashing function adapted from https://bitwiseshiftleft.github.io/sjcl + * modified and redistributed under BSD license + * @param {string} data + */ +export function sha256(data) { + if (!key[0]) precompute(); + + const out = init.slice(0); + const array = encode(data); + + for (let i = 0; i < array.length; i += 16) { + const w = array.subarray(i, i + 16); + + let tmp; + let a; + let b; + + let out0 = out[0]; + let out1 = out[1]; + let out2 = out[2]; + let out3 = out[3]; + let out4 = out[4]; + let out5 = out[5]; + let out6 = out[6]; + let out7 = out[7]; + + /* Rationale for placement of |0 : + * If a value can overflow is original 32 bits by a factor of more than a few + * million (2^23 ish), there is a possibility that it might overflow the + * 53-bit mantissa and lose precision. + * + * To avoid this, we clamp back to 32 bits by |'ing with 0 on any value that + * propagates around the loop, and on the hash state out[]. I don't believe + * that the clamps on out4 and on out0 are strictly necessary, but it's close + * (for out4 anyway), and better safe than sorry. + * + * The clamps on out[] are necessary for the output to be correct even in the + * common case and for short inputs. + */ + + for (let i = 0; i < 64; i++) { + // load up the input word for this round + + if (i < 16) { + tmp = w[i]; + } else { + a = w[(i + 1) & 15]; + + b = w[(i + 14) & 15]; + + tmp = w[i & 15] = + (((a >>> 7) ^ (a >>> 18) ^ (a >>> 3) ^ (a << 25) ^ (a << 14)) + + ((b >>> 17) ^ (b >>> 19) ^ (b >>> 10) ^ (b << 15) ^ (b << 13)) + + w[i & 15] + + w[(i + 9) & 15]) | + 0; + } + + tmp = + tmp + + out7 + + ((out4 >>> 6) ^ (out4 >>> 11) ^ (out4 >>> 25) ^ (out4 << 26) ^ (out4 << 21) ^ (out4 << 7)) + + (out6 ^ (out4 & (out5 ^ out6))) + + key[i]; // | 0; + + // shift register + out7 = out6; + out6 = out5; + out5 = out4; + + out4 = (out3 + tmp) | 0; + + out3 = out2; + out2 = out1; + out1 = out0; + + out0 = + (tmp + + ((out1 & out2) ^ (out3 & (out1 ^ out2))) + + ((out1 >>> 2) ^ + (out1 >>> 13) ^ + (out1 >>> 22) ^ + (out1 << 30) ^ + (out1 << 19) ^ + (out1 << 10))) | + 0; + } + + out[0] = (out[0] + out0) | 0; + out[1] = (out[1] + out1) | 0; + out[2] = (out[2] + out2) | 0; + out[3] = (out[3] + out3) | 0; + out[4] = (out[4] + out4) | 0; + out[5] = (out[5] + out5) | 0; + out[6] = (out[6] + out6) | 0; + out[7] = (out[7] + out7) | 0; + } + + const bytes = new Uint8Array(out.buffer); + reverse_endianness(bytes); + + return base64(bytes); +} + +/** The SHA-256 initialization vector */ +const init = new Uint32Array(8); + +/** The SHA-256 hash key */ +const key = new Uint32Array(64); + +/** Function to precompute init and key. */ +function precompute() { + /** @param {number} x */ + function frac(x) { + return (x - Math.floor(x)) * 0x100000000; + } + + let prime = 2; + + for (let i = 0; i < 64; prime++) { + let is_prime = true; + + for (let factor = 2; factor * factor <= prime; factor++) { + if (prime % factor === 0) { + is_prime = false; + + break; + } + } + + if (is_prime) { + if (i < 8) { + init[i] = frac(prime ** (1 / 2)); + } + + key[i] = frac(prime ** (1 / 3)); + + i++; + } + } +} + +/** @param {Uint8Array} bytes */ +function reverse_endianness(bytes) { + for (let i = 0; i < bytes.length; i += 4) { + const a = bytes[i + 0]; + const b = bytes[i + 1]; + const c = bytes[i + 2]; + const d = bytes[i + 3]; + + bytes[i + 0] = d; + bytes[i + 1] = c; + bytes[i + 2] = b; + bytes[i + 3] = a; + } +} + +/** @param {string} str */ +function encode(str) { + const encoded = encoder.encode(str); + const length = encoded.length * 8; + + // result should be a multiple of 512 bits in length, + // with room for a 1 (after the data) and two 32-bit + // words containing the original input bit length + const size = 512 * Math.ceil((length + 65) / 512); + const bytes = new Uint8Array(size / 8); + bytes.set(encoded); + + // append a 1 + bytes[encoded.length] = 0b10000000; + + reverse_endianness(bytes); + + // add the input bit length + const words = new Uint32Array(bytes.buffer); + words[words.length - 2] = Math.floor(length / 0x100000000); // this will always be zero for us + words[words.length - 1] = length; + + return words; +} + +/* + Based on https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727 + + MIT License + Copyright (c) 2020 Egor Nepomnyaschih + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +*/ +const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.split(''); + +/** @param {Uint8Array} bytes */ +export function base64(bytes) { + const l = bytes.length; + + let result = ''; + let i; + + for (i = 2; i < l; i += 3) { + result += chars[bytes[i - 2] >> 2]; + result += chars[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)]; + result += chars[((bytes[i - 1] & 0x0f) << 2) | (bytes[i] >> 6)]; + result += chars[bytes[i] & 0x3f]; + } + + if (i === l + 1) { + // 1 octet yet to write + result += chars[bytes[i - 2] >> 2]; + result += chars[(bytes[i - 2] & 0x03) << 4]; + result += '=='; + } + + if (i === l) { + // 2 octets yet to write + result += chars[bytes[i - 2] >> 2]; + result += chars[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)]; + result += chars[(bytes[i - 1] & 0x0f) << 2]; + result += '='; + } + + return result; +} diff --git a/packages/kit/src/runtime/service-worker/page/crypto.spec.js b/packages/kit/src/runtime/service-worker/page/crypto.spec.js new file mode 100644 index 000000000000..5ef48af42300 --- /dev/null +++ b/packages/kit/src/runtime/service-worker/page/crypto.spec.js @@ -0,0 +1,24 @@ +import { webcrypto } from 'node:crypto'; +import { assert, test } from 'vitest'; +import { sha256 } from './crypto.js'; + +const inputs = [ + 'hello world', + '', + 'abcd', + 'the quick brown fox jumps over the lazy dog', + '工欲善其事,必先利其器' +].slice(0); + +inputs.forEach((input) => { + test(input, async () => { + const expected_bytes = await webcrypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(input) + ); + const expected = Buffer.from(expected_bytes).toString('base64'); + + const actual = sha256(input); + assert.equal(actual, expected); + }); +}); diff --git a/packages/kit/src/runtime/service-worker/page/csp.js b/packages/kit/src/runtime/service-worker/page/csp.js new file mode 100644 index 000000000000..1376235b45de --- /dev/null +++ b/packages/kit/src/runtime/service-worker/page/csp.js @@ -0,0 +1,366 @@ +import { escape_html } from '../../../utils/escape.js'; +import { base64, sha256 } from './crypto.js'; + +const array = new Uint8Array(16); + +function generate_nonce() { + crypto.getRandomValues(array); + return base64(array); +} + +const quoted = new Set([ + 'self', + 'unsafe-eval', + 'unsafe-hashes', + 'unsafe-inline', + 'none', + 'strict-dynamic', + 'report-sample', + 'wasm-unsafe-eval', + 'script' +]); + +const crypto_pattern = /^(nonce|sha\d\d\d)-/; + +// CSP and CSP Report Only are extremely similar with a few caveats +// the easiest/DRYest way to express this is with some private encapsulation +class BaseProvider { + /** @type {boolean} */ + #use_hashes; + + /** @type {boolean} */ + #script_needs_csp; + + /** @type {boolean} */ + #script_src_needs_csp; + + /** @type {boolean} */ + #script_src_elem_needs_csp; + + /** @type {boolean} */ + #style_needs_csp; + + /** @type {boolean} */ + #style_src_needs_csp; + + /** @type {boolean} */ + #style_src_attr_needs_csp; + + /** @type {boolean} */ + #style_src_elem_needs_csp; + + /** @type {import('types').CspDirectives} */ + #directives; + + /** @type {import('types').Csp.Source[]} */ + #script_src; + + /** @type {import('types').Csp.Source[]} */ + #script_src_elem; + + /** @type {import('types').Csp.Source[]} */ + #style_src; + + /** @type {import('types').Csp.Source[]} */ + #style_src_attr; + + /** @type {import('types').Csp.Source[]} */ + #style_src_elem; + + /** @type {string} */ + #nonce; + + /** + * @param {boolean} use_hashes + * @param {import('types').CspDirectives} directives + * @param {string} nonce + */ + constructor(use_hashes, directives, nonce) { + this.#use_hashes = use_hashes; + this.#directives = __SVELTEKIT_DEV__ ? { ...directives } : directives; // clone in dev so we can safely mutate + + const d = this.#directives; + + this.#script_src = []; + this.#script_src_elem = []; + this.#style_src = []; + this.#style_src_attr = []; + this.#style_src_elem = []; + + const effective_script_src = d['script-src'] || d['default-src']; + const script_src_elem = d['script-src-elem']; + const effective_style_src = d['style-src'] || d['default-src']; + const style_src_attr = d['style-src-attr']; + const style_src_elem = d['style-src-elem']; + + if (__SVELTEKIT_DEV__) { + // remove strict-dynamic in dev... + // TODO reinstate this if we can figure out how to make strict-dynamic work + // if (d['default-src']) { + // d['default-src'] = d['default-src'].filter((name) => name !== 'strict-dynamic'); + // if (d['default-src'].length === 0) delete d['default-src']; + // } + + // if (d['script-src']) { + // d['script-src'] = d['script-src'].filter((name) => name !== 'strict-dynamic'); + // if (d['script-src'].length === 0) delete d['script-src']; + // } + + // ...and add unsafe-inline so we can inject `; + } + + 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('`, so the script element might be unexpectedly + * kept open until until an unrelated HTML comment in the page. + * + * U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are escaped for the sake of pre-2018 + * browsers. + * + * @see tests for unsafe parsing examples. + * @see https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements + * @see https://html.spec.whatwg.org/multipage/syntax.html#cdata-rcdata-restrictions + * @see https://html.spec.whatwg.org/multipage/parsing.html#script-data-state + * @see https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escaped-state + * @see https://github.com/tc39/proposal-json-superset + * @type {Record} + */ +const replacements = { + '<': '\\u003C', + '\u2028': '\\u2028', + '\u2029': '\\u2029' +}; + +const pattern = new RegExp(`[${Object.keys(replacements).join('')}]`, 'g'); + +/** + * Generates a raw HTML string containing a safe script element carrying data and associated attributes. + * + * It escapes all the special characters needed to guarantee the element is unbroken, but care must + * be taken to ensure it is inserted in the document at an acceptable position for a script element, + * and that the resulting string isn't further modified. + * + * @param {import('./types.js').Fetched} fetched + * @param {(name: string, value: string) => boolean} filter + * @param {boolean} [prerendering] + * @returns {string} The raw HTML of a script element carrying the JSON payload. + * @example const html = serialize_data('/data.json', null, { foo: 'bar' }); + */ +export function serialize_data(fetched, filter, prerendering = false) { + /** @type {Record} */ + const headers = {}; + + let cache_control = null; + let age = null; + let varyAny = false; + + for (const [key, value] of fetched.response.headers) { + if (filter(key, value)) { + headers[key] = value; + } + + if (key === 'cache-control') cache_control = value; + else if (key === 'age') age = value; + else if (key === 'vary' && value.trim() === '*') varyAny = true; + } + + const payload = { + status: fetched.response.status, + statusText: fetched.response.statusText, + headers, + body: fetched.response_body + }; + + const safe_payload = JSON.stringify(payload).replace(pattern, (match) => replacements[match]); + + const attrs = [ + 'type="application/json"', + 'data-sveltekit-fetched', + `data-url="${escape_html(fetched.url, true)}"` + ]; + + if (fetched.is_b64) { + attrs.push('data-b64'); + } + + if (fetched.request_headers || fetched.request_body) { + /** @type {import('types').StrictBody[]} */ + const values = []; + + if (fetched.request_headers) { + values.push([...new Headers(fetched.request_headers)].join(',')); + } + + if (fetched.request_body) { + values.push(fetched.request_body); + } + + attrs.push(`data-hash="${hash(...values)}"`); + } + + // Compute the time the response should be cached, taking into account max-age and age. + // Do not cache at all if a `Vary: *` header is present, as this indicates that the + // cache is likely to get busted. + if (!prerendering && fetched.method === 'GET' && cache_control && !varyAny) { + const match = /s-maxage=(\d+)/g.exec(cache_control) ?? /max-age=(\d+)/g.exec(cache_control); + if (match) { + const ttl = +match[1] - +(age ?? '0'); + attrs.push(`data-ttl="${ttl}"`); + } + } + + return ``; +} diff --git a/packages/kit/src/runtime/service-worker/page/serialize_data.spec.js b/packages/kit/src/runtime/service-worker/page/serialize_data.spec.js new file mode 100644 index 000000000000..3a4c60e620f3 --- /dev/null +++ b/packages/kit/src/runtime/service-worker/page/serialize_data.spec.js @@ -0,0 +1,103 @@ +import { assert, test } from 'vitest'; +import { serialize_data } from './serialize_data.js'; + +test('escapes slashes', () => { + const response_body = '' + ); +}); + +test('escapes exclamation marks', () => { + const response_body = 'alert("xss")'; + + assert.equal( + serialize_data( + { + url: 'foo', + method: 'GET', + request_body: null, + response_body, + response: new Response(response_body) + }, + () => false + ), + '' + ); +}); + +test('escapes the attribute values', () => { + const raw = 'an "attr" & a \ud800'; + const escaped = 'an "attr" & a �'; + const response_body = ''; + assert.equal( + serialize_data( + { + url: raw, + method: 'GET', + request_body: null, + response_body, + response: new Response(response_body) + }, + () => false + ), + `` + ); +}); + +test('computes ttl using cache-control and age headers', () => { + const raw = 'an "attr" & a \ud800'; + const escaped = 'an "attr" & a �'; + const response_body = ''; + assert.equal( + serialize_data( + { + url: raw, + method: 'GET', + request_body: null, + response_body, + response: new Response(response_body, { + headers: { 'cache-control': 'max-age=10', age: '1' } + }) + }, + () => false + ), + `` + ); +}); + +test('doesnt compute ttl when vary * header is present', () => { + const raw = 'an "attr" & a \ud800'; + const escaped = 'an "attr" & a �'; + const response_body = ''; + assert.equal( + serialize_data( + { + url: raw, + method: 'GET', + request_body: null, + response_body, + response: new Response(response_body, { + headers: { 'cache-control': 'max-age=10', vary: '*' } + }) + }, + () => false + ), + `` + ); +}); diff --git a/packages/kit/src/runtime/service-worker/page/server_routing.js b/packages/kit/src/runtime/service-worker/page/server_routing.js new file mode 100644 index 000000000000..3c39a81535e0 --- /dev/null +++ b/packages/kit/src/runtime/service-worker/page/server_routing.js @@ -0,0 +1,143 @@ +import { base, assets, relative } from '__sveltekit/paths'; +import { text } from '../../../exports/index.js'; +import { s } from '../../../utils/misc.js'; +import { exec } from '../../../utils/routing.js'; +import { decode_params } from '../../../utils/url.js'; +import { get_relative_path } from '../../utils.js'; + +/** + * @param {import('types').SWRClientRoute} route + * @param {URL} url + * @param {import('types').SWRManifest} manifest + * @returns {string} + */ +export function generate_route_object(route, url, manifest) { + const { errors, layouts, leaf } = route; + + const nodes = [...errors, ...layouts.map((l) => l?.[1]), leaf[1]] + .filter((n) => typeof n === 'number') + .map((n) => `'${n}': () => ${create_client_import(manifest.client.nodes?.[n], url)}`) + .join(',\n\t\t'); + + // stringified version of + /** @type {import('types').CSRRouteServer} */ + return [ + `{\n\tid: ${s(route.id)}`, + `errors: ${s(route.errors)}`, + `layouts: ${s(route.layouts)}`, + `leaf: ${s(route.leaf)}`, + `nodes: {\n\t\t${nodes}\n\t}\n}` + ].join(',\n\t'); +} + +/** + * @param {string | undefined} import_path + * @param {URL} url + */ +function create_client_import(import_path, url) { + if (!import_path) return 'Promise.resolve({})'; + + // During DEV, Vite will make the paths absolute (e.g. /@fs/...) + if (import_path[0] === '/') { + return `import('${import_path}')`; + } + + // During PROD, they're root-relative + if (assets !== '') { + return `import('${assets}/${import_path}')`; + } + + if (!relative) { + return `import('${base}/${import_path}')`; + } + + // Else we make them relative to the server-side route resolution request + // to support IPFS, the internet archive, etc. + let path = get_relative_path(url.pathname, `${base}/${import_path}`); + if (path[0] !== '.') path = `./${path}`; + return `import('${path}')`; +} + +/** + * @param {string} resolved_path + * @param {URL} url + * @param {import('types').SWRManifest} manifest + * @returns {Promise} + */ +export async function resolve_route(resolved_path, url, manifest) { + if (!manifest.client.routes) { + return text('Server-side route resolution disabled', { status: 400 }); + } + + /** @type {import('types').SSRClientRoute | null} */ + let route = null; + /** @type {Record} */ + let params = {}; + + const matchers = await manifest.matchers(); + + for (const candidate of manifest.client.routes) { + const match = candidate.pattern.exec(resolved_path); + if (!match) continue; + + const matched = exec(match, candidate.params, matchers); + if (matched) { + route = candidate; + params = decode_params(matched); + break; + } + } + + return create_server_routing_response(route, params, url, manifest).response; +} + +/** + * @param {import('types').SWRClientRoute | null} route + * @param {Partial>} params + * @param {URL} url + * @param {import('types').SWRManifest} manifest + * @returns {{response: Response, body: string}} + */ +export function create_server_routing_response(route, params, url, manifest) { + const headers = new Headers({ + 'content-type': 'application/javascript; charset=utf-8' + }); + + if (route) { + const csr_route = generate_route_object(route, url, manifest); + const body = `${create_css_import(route, url, manifest)}\nexport const route = ${csr_route}; export const params = ${JSON.stringify(params)};`; + + return { response: text(body, { headers }), body }; + } else { + return { response: text('', { headers }), body: '' }; + } +} + +/** + * This function generates the client-side import for the CSS files that are + * associated with the current route. Vite takes care of that when using + * client-side route resolution, but for server-side resolution it does + * not know about the CSS files automatically. + * + * @param {import('types').SWRClientRoute} route + * @param {URL} url + * @param {import('types').SWRManifest} manifest + * @returns {string} + */ +function create_css_import(route, url, manifest) { + const { errors, layouts, leaf } = route; + + let css = ''; + + for (const node of [...errors, ...layouts.map((l) => l?.[1]), leaf[1]]) { + if (typeof node !== 'number') continue; + const node_css = manifest.client.css?.[node]; + for (const css_path of node_css ?? []) { + css += `'${assets || base}/${css_path}',`; + } + } + + if (!css) return ''; + + return `${create_client_import(/** @type {string} */ (manifest.client.start), url)}.then(x => x.load_css([${css}]));`; +} diff --git a/packages/kit/src/runtime/service-worker/page/types.d.ts b/packages/kit/src/runtime/service-worker/page/types.d.ts new file mode 100644 index 000000000000..7e501b1ab418 --- /dev/null +++ b/packages/kit/src/runtime/service-worker/page/types.d.ts @@ -0,0 +1,36 @@ +import { CookieSerializeOptions } from 'cookie'; +import { SSRNode, CspDirectives, ServerDataNode } from 'types'; + +export interface Fetched { + url: string; + method: string; + request_body?: string | ArrayBufferView | null; + request_headers?: HeadersInit | undefined; + response_body: string; + response: Response; + is_b64?: boolean; +} + +export type Loaded = { + node: SSRNode; + data: Record | null; + server_data: ServerDataNode | null; +}; + +type CspMode = 'hash' | 'nonce' | 'auto'; + +export interface CspConfig { + mode: CspMode; + directives: CspDirectives; + reportOnly: CspDirectives; +} + +export interface CspOpts { + prerender: boolean; +} + +export interface Cookie { + name: string; + value: string; + options: CookieSerializeOptions & { path: string }; +} diff --git a/packages/kit/src/runtime/service-worker/respond.js b/packages/kit/src/runtime/service-worker/respond.js new file mode 100644 index 000000000000..18d9f295a2cb --- /dev/null +++ b/packages/kit/src/runtime/service-worker/respond.js @@ -0,0 +1,542 @@ +import { app_dir, base } from '__sveltekit/paths'; +import { DEV } from 'esm-env'; +import { json, text } from '../../exports/index.js'; +import { validate_server_exports } from '../../utils/exports.js'; +import { is_form_content_type } from '../../utils/http.js'; +import { PageNodes } from '../../utils/page_nodes.js'; +import { exec } from '../../utils/routing.js'; +import { decode_params, decode_pathname, normalize_path } from '../../utils/url.js'; +import { HttpError, Redirect } from '../control.js'; +import { + add_data_suffix, + add_resolution_suffix, + has_data_suffix, + has_resolution_suffix, + strip_data_suffix, + strip_resolution_suffix +} from '../pathname.js'; +import { add_cookies_to_headers, get_cookies } from '../server/cookie.js'; +import { + handle_fatal_error, + has_prerendered_path, + method_not_allowed, + redirect_response +} from './utils.js'; +import { validateHeaders } from '../server/validate-headers.js'; +import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM } from '../shared.js'; +import { redirect_json_response, render_data } from './data/index.js'; +import { get_public_env } from './env_module.js'; +import { create_fetch } from './fetch.js'; +import { action_json_redirect, is_action_json_request } from './page/actions.js'; +import { render_page } from './page/index.js'; +import { render_response } from './page/render.js'; +import { resolve_route } from './page/server_routing.js'; +import { is_endpoint_request } from './endpoint.js'; + +/* global __SVELTEKIT_DEV__ */ + +/** @type {import('types').RequiredResolveOptions['transformPageChunk']} */ +const default_transform = ({ html }) => html; + +/** @type {import('types').RequiredResolveOptions['filterSerializedResponseHeaders']} */ +const default_filter = () => false; + +/** @type {import('types').RequiredResolveOptions['preload']} */ +const default_preload = ({ type }) => type === 'js' || type === 'css'; + +const page_methods = new Set(['GET', 'HEAD', 'POST']); + +const allowed_page_methods = new Set(['GET', 'HEAD', 'OPTIONS']); + +/** + * @param {Request} request + * @param {import('types').SWROptions} options + * @param {import('types').SWRManifest} manifest + * @param {import('types').SWRState} state + * @returns {Promise} + */ +export async function respond(request, options, manifest, state) { + /** URL but stripped from the potential `/__data.json` suffix and its search param */ + const url = new URL(request.url); + + if (options.csrf_check_origin) { + const forbidden = + is_form_content_type(request) && + (request.method === 'POST' || + request.method === 'PUT' || + request.method === 'PATCH' || + request.method === 'DELETE') && + request.headers.get('origin') !== url.origin; + + if (forbidden) { + const csrf_error = new HttpError( + 403, + `Cross-site ${request.method} form submissions are forbidden` + ); + if (request.headers.get('accept') === 'application/json') { + return json(csrf_error.body, { status: csrf_error.status }); + } + return text(csrf_error.body.message, { status: csrf_error.status }); + } + } + + if (options.hash_routing && url.pathname !== base + '/' && url.pathname !== '/[fallback]') { + return text('Not found', { status: 404 }); + } + + /** @type {boolean[] | undefined} */ + let invalidated_data_nodes; + + /** + * If the request is for a route resolution, first modify the URL, then continue as normal + * for path resolution, then return the route object as a JS file. + */ + const is_route_resolution_request = has_resolution_suffix(url.pathname); + const is_data_request = has_data_suffix(url.pathname); + + if (is_route_resolution_request) { + url.pathname = strip_resolution_suffix(url.pathname); + } else if (is_data_request) { + url.pathname = + strip_data_suffix(url.pathname) + + (url.searchParams.get(TRAILING_SLASH_PARAM) === '1' ? '/' : '') || '/'; + url.searchParams.delete(TRAILING_SLASH_PARAM); + invalidated_data_nodes = url.searchParams + .get(INVALIDATED_PARAM) + ?.split('') + .map((node) => node === '1'); + url.searchParams.delete(INVALIDATED_PARAM); + } + + /** @type {Record} */ + const headers = {}; + + const { cookies, new_cookies, get_cookie_header, set_internal, set_trailing_slash } = get_cookies( + request, + url + ); + + /** @type {import('types').SWRequestEvent} */ + const event = { + cookies, + // @ts-expect-error `fetch` needs to be created after the `event` itself + fetch: null, + params: {}, + request, + route: { id: null }, + setHeaders: (new_headers) => { + if (__SVELTEKIT_DEV__) { + validateHeaders(new_headers); + } + + for (const key in new_headers) { + const lower = key.toLowerCase(); + const value = new_headers[key]; + + if (lower === 'set-cookie') { + throw new Error( + 'Use `event.cookies.set(name, value, options)` instead of `event.setHeaders` to set cookies' + ); + } else if (lower in headers) { + throw new Error(`"${key}" header is already set`); + } else { + headers[lower] = value; + } + } + }, + url + }; + + event.fetch = create_fetch({ + event, + options, + manifest, + state, + get_cookie_header, + set_internal + }); + + let resolved_path; + + try { + // reroute could alter the given URL, so we pass a copy + resolved_path = + (await options.hooks.reroute({ url: new URL(url), fetch: event.fetch })) ?? url.pathname; + } catch { + return text('Internal Server Error', { + status: 500 + }); + } + + try { + resolved_path = decode_pathname(resolved_path); + } catch { + return text('Malformed URI', { status: 400 }); + } + + if (resolved_path !== url.pathname && has_prerendered_path(manifest, resolved_path)) { + const url = new URL(request.url); + url.pathname = is_data_request + ? add_data_suffix(resolved_path) + : is_route_resolution_request + ? add_resolution_suffix(resolved_path) + : resolved_path; + + // `fetch` automatically decodes the body, so we need to delete the related headers to not break the response + // Also see https://github.com/sveltejs/kit/issues/12197 for more info (we should fix this more generally at some point) + const response = await fetch(url, request); + const headers = new Headers(response.headers); + if (headers.has('content-encoding')) { + headers.delete('content-encoding'); + headers.delete('content-length'); + } + + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText + }); + } + + /** @type {import('types').SWRRoute | null} */ + let route = null; + + if (base) { + if (!resolved_path.startsWith(base)) { + return text('Not found', { status: 404 }); + } + resolved_path = resolved_path.slice(base.length) || '/'; + } + + if (is_route_resolution_request) { + return resolve_route(resolved_path, new URL(request.url), manifest); + } + + if (resolved_path === `/${app_dir}/env.js`) { + return get_public_env(request); + } + + if (resolved_path.startsWith(`/${app_dir}`)) { + // Ensure that 404'd static assets are not cached - some adapters might apply caching by default + const headers = new Headers(); + headers.set('cache-control', 'public, max-age=0, must-revalidate'); + return text('Not found', { status: 404, headers }); + } + + // TODO this could theoretically break — should probably be inside a try-catch + const matchers = await manifest.matchers(); + + for (const candidate of manifest.routes) { + const match = candidate.pattern.exec(resolved_path); + if (!match) continue; + + const matched = exec(match, candidate.params, matchers); + if (matched) { + route = candidate; + event.route = { id: route.id }; + event.params = decode_params(matched); + break; + } + } + + /** @type {import('types').RequiredResolveOptions} */ + let resolve_opts = { + transformPageChunk: default_transform, + filterSerializedResponseHeaders: default_filter, + preload: default_preload + }; + + /** @type {import('types').TrailingSlash} */ + let trailing_slash = 'never'; + + try { + /** @type {PageNodes|undefined} */ + const page_nodes = route?.page + ? new PageNodes(await load_page_nodes(route.page, manifest)) + : undefined; + + // determine whether we need to redirect to add/remove a trailing slash + if (route) { + // if `paths.base === '/a/b/c`, then the root route is `/a/b/c/`, + // regardless of the `trailingSlash` route option + if (url.pathname === base || url.pathname === base + '/') { + trailing_slash = 'always'; + } else if (page_nodes) { + if (DEV) { + page_nodes.validate(); + } + trailing_slash = page_nodes.trailing_slash(); + } else if (route.endpoint) { + const node = await route.endpoint(); + trailing_slash = node.trailingSlash ?? 'never'; + if (DEV) { + validate_server_exports(node, /** @type {string} */ (route.endpoint_id)); + } + } + + if (!is_data_request) { + const normalized = normalize_path(url.pathname, trailing_slash); + + if (normalized !== url.pathname) { + return new Response(undefined, { + status: 308, + headers: { + 'x-sveltekit-normalize': '1', + location: + // ensure paths starting with '//' are not treated as protocol-relative + (normalized.startsWith('//') ? url.origin + normalized : normalized) + + (url.search === '?' ? '' : url.search) + } + }); + } + } + + if (state.before_handle) { + let config = {}; + + /** @type {import('types').PrerenderOption} */ + let prerender = false; + + if (route.endpoint) { + const node = await route.endpoint(); + config = node.config ?? config; + } else if (page_nodes) { + config = page_nodes.get_config() ?? config; + prerender = page_nodes.prerender(); + } + + if (state.before_handle) { + state.before_handle(event, config, prerender); + } + } + } + + set_trailing_slash(trailing_slash); + + const response = await resolve(event, page_nodes).then((response) => { + // add headers/cookies here, rather than inside `resolve`, so that we + // can do it once for all responses instead of once per `return` + for (const key in headers) { + const value = headers[key]; + response.headers.set(key, /** @type {string} */ (value)); + } + + add_cookies_to_headers(response.headers, Object.values(new_cookies)); + + return response; + }); + + // respond with 304 if etag matches + if (response.status === 200 && response.headers.has('etag')) { + let if_none_match_value = request.headers.get('if-none-match'); + + // ignore W/ prefix https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match#directives + if (if_none_match_value?.startsWith('W/"')) { + if_none_match_value = if_none_match_value.substring(2); + } + + const etag = /** @type {string} */ (response.headers.get('etag')); + + if (if_none_match_value === etag) { + const headers = new Headers({ etag }); + + // https://datatracker.ietf.org/doc/html/rfc7232#section-4.1 + set-cookie + for (const key of [ + 'cache-control', + 'content-location', + 'date', + 'expires', + 'vary', + 'set-cookie' + ]) { + const value = response.headers.get(key); + if (value) headers.set(key, value); + } + + return new Response(undefined, { + status: 304, + headers + }); + } + } + + // Edge case: If user does `return Response(30x)` in handle hook while processing a data request, + // we need to transform the redirect response to a corresponding JSON response. + if (is_data_request && response.status >= 300 && response.status <= 308) { + const location = response.headers.get('location'); + if (location) { + return redirect_json_response(new Redirect(/** @type {any} */ (response.status), location)); + } + } + + return response; + } catch (e) { + if (e instanceof Redirect) { + const response = is_data_request + ? redirect_json_response(e) + : route?.page && is_action_json_request(event) + ? action_json_redirect(e) + : redirect_response(e.status, e.location); + add_cookies_to_headers(response.headers, Object.values(new_cookies)); + return response; + } + return await handle_fatal_error(event, options, e); + } + + /** + * @param {import('types').SWRequestEvent} event + * @param {PageNodes | undefined} page_nodes + * @param {import('@sveltejs/kit').ResolveOptions} [opts] + */ + async function resolve(event, page_nodes, opts) { + try { + if (opts) { + resolve_opts = { + transformPageChunk: opts.transformPageChunk || default_transform, + filterSerializedResponseHeaders: opts.filterSerializedResponseHeaders || default_filter, + preload: opts.preload || default_preload + }; + } + + if (options.hash_routing) { + return await render_response({ + event, + options, + manifest, + state, + page_config: { ssr: false, csr: true }, + status: 200, + error: null, + branch: [], + fetched: [], + resolve_opts + }); + } + + if (route) { + const method = /** @type {import('types').HttpMethod} */ (event.request.method); + + /** @type {Response} */ + let response; + + if (is_data_request) { + response = await render_data( + event, + route, + options, + invalidated_data_nodes, + trailing_slash + ); + } else if (route.endpoint && (!route.page || is_endpoint_request(event))) { + // We can't handle an endpoint request in a service worker, so we need to + response = await fetch(event.request); + } else if (route.page) { + if (!page_nodes) { + throw new Error('page_nodes not found. This should never happen'); + } else if (page_methods.has(method)) { + response = await render_page( + event, + route.page, + options, + manifest, + state, + page_nodes, + resolve_opts + ); + } else { + const allowed_methods = new Set(allowed_page_methods); + const node = await manifest.nodes[route.page.leaf](); + if (node?.server?.actions) { + allowed_methods.add('POST'); + } + + if (method === 'OPTIONS') { + // This will deny CORS preflight requests implicitly because we don't + // add the required CORS headers to the response. + response = new Response(null, { + status: 204, + headers: { + allow: Array.from(allowed_methods.values()).join(', ') + } + }); + } else { + const mod = [...allowed_methods].reduce((acc, curr) => { + acc[curr] = true; + return acc; + }, /** @type {Record} */ ({})); + response = method_not_allowed(mod, method); + } + } + } else { + // a route will always have a page or an endpoint, but TypeScript doesn't know that + throw new Error('Route is neither page nor endpoint. This should never happen'); + } + + // If the route contains a page and an endpoint, we need to add a + // `Vary: Accept` header to the response because of browser caching + if (request.method === 'GET' && route.page && route.endpoint) { + const vary = response.headers + .get('vary') + ?.split(',') + ?.map((v) => v.trim().toLowerCase()); + if (!(vary?.includes('accept') || vary?.includes('*'))) { + // the returned response might have immutable headers, + // so we have to clone them before trying to mutate them + response = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: new Headers(response.headers) + }); + response.headers.append('Vary', 'Accept'); + } + } + + return response; + } + + if (state.error && event.isSubRequest) { + // avoid overwriting the headers. This could be a same origin fetch request + // to an external service from the root layout while rendering an error page + const headers = new Headers(request.headers); + headers.set('x-sveltekit-error', 'true'); + return await fetch(request, { headers }); + } + + if (state.error) { + return text('Internal Server Error', { + status: 500 + }); + } + + // we can't load the endpoint from our own manifest, + // so we need to make an actual HTTP request + return await fetch(request); + } catch (e) { + // TODO if `e` is instead named `error`, some fucked up Vite transformation happens + // and I don't even know how to describe it. need to investigate at some point + + // HttpError from endpoint can end up here - TODO should it be handled there instead? + return await handle_fatal_error(event, options, e); + } finally { + event.cookies.set = () => { + throw new Error('Cannot use `cookies.set(...)` after the response has been generated'); + }; + + event.setHeaders = () => { + throw new Error('Cannot use `setHeaders(...)` after the response has been generated'); + }; + } + } +} + +/** + * @param {import('types').PageNodeIndexes} page + * @param {import('types').SWRManifest} manifest + */ +export function load_page_nodes(page, manifest) { + return Promise.all([ + // we use == here rather than === because [undefined] serializes as "[null]" + ...page.layouts.map((n) => (n == undefined ? n : manifest.nodes[n]())), + manifest.nodes[page.leaf]() + ]); +} diff --git a/packages/kit/src/runtime/service-worker/utils.js b/packages/kit/src/runtime/service-worker/utils.js new file mode 100644 index 000000000000..23b7544436da --- /dev/null +++ b/packages/kit/src/runtime/service-worker/utils.js @@ -0,0 +1,177 @@ +import { DEV } from 'esm-env'; +import { json, text } from '../../exports/index.js'; +import { coalesce_to_error, get_message, get_status } from '../../utils/error.js'; +import { negotiate } from '../../utils/http.js'; +import { HttpError } from '../control.js'; +import { fix_stack_trace } from '../shared-server.js'; +import { ENDPOINT_METHODS } from '../../constants.js'; +import { escape_html } from '../../utils/escape.js'; + +/** @param {any} body */ +export function is_pojo(body) { + if (typeof body !== 'object') return false; + + if (body) { + if (body instanceof Uint8Array) return false; + if (body instanceof ReadableStream) return false; + } + + return true; +} + +/** + * @param {Partial>} mod + * @param {import('types').HttpMethod} method + */ +export function method_not_allowed(mod, method) { + return text(`${method} method not allowed`, { + status: 405, + 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: allowed_methods(mod).join(', ') + } + }); +} + +/** @param {Partial>} mod */ +export function allowed_methods(mod) { + const allowed = ENDPOINT_METHODS.filter((method) => method in mod); + + if ('GET' in mod || 'HEAD' in mod) allowed.push('HEAD'); + + return allowed; +} + +/** + * Return as a response that renders the error.html + * + * @param {import('types').SWROptions} options + * @param {number} status + * @param {string} message + */ +export function static_error_page(options, status, message) { + let page = options.templates.error({ status, message: escape_html(message) }); + + if (DEV) { + // inject Vite HMR client, for easier debugging + page = page.replace('', ''); + } + + return text(page, { + headers: { 'content-type': 'text/html; charset=utf-8' }, + status + }); +} + +/** + * @param {import('types').SWRequestEvent} event + * @param {import('types').SWROptions} options + * @param {unknown} error + */ +export async function handle_fatal_error(event, options, error) { + error = error instanceof HttpError ? error : coalesce_to_error(error); + const status = get_status(error); + const body = await handle_error_and_jsonify(event, options, error); + + // ideally we'd use sec-fetch-dest instead, but Safari — quelle surprise — doesn't support it + const type = negotiate(event.request.headers.get('accept') || 'text/html', [ + 'application/json', + 'text/html' + ]); + + if (type === 'application/json') { + return json(body, { + status + }); + } + + return static_error_page(options, status, body.message); +} + +/** + * @param {import('types').SWRequestEvent} event + * @param {import('types').SWROptions} options + * @param {any} error + * @returns {Promise} + */ +export async function handle_error_and_jsonify(event, options, error) { + if (error instanceof HttpError) { + return error.body; + } + + if (__SVELTEKIT_DEV__ && typeof error == 'object') { + fix_stack_trace(error); + } + + const status = get_status(error); + const message = get_message(error); + + return (await options.hooks.handleError({ error, event, status, message })) ?? { message }; +} + +/** + * @param {number} status + * @param {string} location + */ +export function redirect_response(status, location) { + const response = new Response(undefined, { + status, + headers: { location } + }); + return response; +} + +/** + * @param {import('types').SWRequestEvent} event + * @param {Error & { path: string }} error + */ +export function clarify_devalue_error(event, error) { + if (error.path) { + return `Data returned from \`load\` while rendering ${event.route.id} is not serializable: ${error.message} (${error.path})`; + } + + if (error.path === '') { + return `Data returned from \`load\` while rendering ${event.route.id} is not a plain object`; + } + + // belt and braces — this should never happen + return error.message; +} + +/** + * @param {import('types').ServerDataNode} node + */ +export function serialize_uses(node) { + const uses = {}; + + if (node.uses && node.uses.dependencies.size > 0) { + uses.dependencies = Array.from(node.uses.dependencies); + } + + if (node.uses && node.uses.search_params.size > 0) { + uses.search_params = Array.from(node.uses.search_params); + } + + if (node.uses && node.uses.params.size > 0) { + uses.params = Array.from(node.uses.params); + } + + if (node.uses?.parent) uses.parent = 1; + if (node.uses?.route) uses.route = 1; + if (node.uses?.url) uses.url = 1; + + return uses; +} + +/** + * Returns `true` if the given path was prerendered + * @param {import('types').SWRManifest} manifest + * @param {string} pathname Should include the base and be decoded + */ +export function has_prerendered_path(manifest, pathname) { + return ( + manifest.prerendered_routes.has(pathname) || + (pathname.at(-1) === '/' && manifest.prerendered_routes.has(pathname.slice(0, -1))) + ); +} diff --git a/packages/kit/src/types/ambient-private.d.ts b/packages/kit/src/types/ambient-private.d.ts index c98af8cb0062..c3fc95f92422 100644 --- a/packages/kit/src/types/ambient-private.d.ts +++ b/packages/kit/src/types/ambient-private.d.ts @@ -27,3 +27,12 @@ declare module '__sveltekit/server' { export function set_manifest(manifest: SSRManifest): void; export function set_read_implementation(fn: (path: string) => ReadableStream): void; } + +declare module '__sveltekit/service-worker' { + import { SWRManifest } from 'types'; + + export let manifest: SWRManifest; + export function read_implementation(path: string): ReadableStream; + export function set_manifest(manifest: SWRManifest): void; + export function set_read_implementation(fn: (path: string) => ReadableStream): void; +} diff --git a/packages/kit/src/types/ambient.d.ts b/packages/kit/src/types/ambient.d.ts index 5ab0c5b80c20..d3c3a85e814a 100644 --- a/packages/kit/src/types/ambient.d.ts +++ b/packages/kit/src/types/ambient.d.ts @@ -78,4 +78,8 @@ declare module '$service-worker' { * See [`config.kit.version`](https://svelte.dev/docs/kit/configuration#version). It's useful for generating unique cache names inside your service worker, so that a later deployment of your app can invalidate old caches. */ export const version: string; + /** + * + */ + export const respond: (event: Request) => Promise; } diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 17e2425e3c17..924119914106 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -20,7 +20,9 @@ import { Adapter, ServerInit, ClientInit, - Transporter + Transporter, + Cookies, + ParamMatcher } from '@sveltejs/kit'; import { HttpMethod, @@ -365,8 +367,26 @@ export interface SSRComponent { }; } +export interface SWRComponent { + default: { + render( + props: Record, + opts: { context: Map } + ): { + html: string; + head: string; + css: { + code: string; + map: any; // TODO + }; + }; + }; +} + export type SSRComponentLoader = () => Promise; +export type SWRComponentLoader = () => Promise; + export interface UniversalNode { load?: Load; prerender?: PrerenderOption; @@ -388,6 +408,17 @@ export interface ServerNode { entries?: PrerenderEntryGenerator; } +export interface SWServerNode { + load?: boolean; + prerender?: PrerenderOption; + ssr?: boolean; + csr?: boolean; + trailingSlash?: TrailingSlash; + actions?: string[]; + config?: any; + entries?: PrerenderEntryGenerator; +} + export interface SSRNode { /** index into the `nodes` array in the generated `client/app.js`. */ index: number; @@ -411,8 +442,33 @@ export interface SSRNode { server?: ServerNode; } +export interface SWRNode { + /** index into the `nodes` array in the generated `client/app.js`. */ + index: number; + /** external JS files that are loaded on the client. `imports[0]` is the entry point (e.g. `client/nodes/0.js`) */ + imports: string[]; + /** external CSS files that are loaded on the client */ + stylesheets: string[]; + /** external font files that are loaded on the client */ + fonts: string[]; + + universal_id?: string; + server_id?: string; + + /** inlined styles. */ + inline_styles?(): MaybePromise>; + /** Svelte component */ + component?: SWRComponentLoader; + /** +page.js or +layout.js */ + universal?: UniversalNode; + /** +page.server.js, +layout.server.js, or +server.js */ + server?: SWServerNode; +} + export type SSRNodeLoader = () => Promise; +export type SWRNodeLoader = () => Promise; + export interface SSROptions { app_template_contains_nonce: boolean; csp: ValidatedConfig['kit']['csp']; @@ -438,6 +494,31 @@ export interface SSROptions { version_hash: string; } +export interface SWROptions { + app_template_contains_nonce: boolean; + csp: ValidatedConfig['kit']['csp']; + csrf_check_origin: boolean; + embedded: boolean; + env_public_prefix: string; + env_private_prefix: string; + hash_routing: boolean; + hooks: ClientHooks; + preload_strategy: ValidatedConfig['kit']['output']['preloadStrategy']; + root: SSRComponent['default']; + service_worker: boolean; + templates: { + app(values: { + head: string; + body: string; + assets: string; + nonce: string; + env: Record; + }): string; + error(values: { message: string; status: number }): string; + }; + version_hash: string; +} + export interface PageNodeIndexes { errors: Array; layouts: Array; @@ -454,6 +535,13 @@ export type SSREndpoint = Partial> & { fallback?: RequestHandler; }; +export type SWREndpoint = Partial> & { + trailingSlash?: TrailingSlash; + config?: any; + entries?: PrerenderEntryGenerator; + fallback?: true; +}; + export interface SSRRoute { id: string; pattern: RegExp; @@ -463,6 +551,15 @@ export interface SSRRoute { endpoint_id?: string; } +export interface SWRRoute { + id: string; + pattern: RegExp; + params: RouteParam[]; + page: PageNodeIndexes | null; + endpoint: (() => Promise) | null; + endpoint_id?: string; +} + export interface SSRClientRoute { id: string; pattern: RegExp; @@ -472,6 +569,15 @@ export interface SSRClientRoute { leaf: [has_server_load: boolean, node_id: number]; } +export interface SWRClientRoute { + id: string; + pattern: RegExp; + params: RouteParam[]; + errors: Array; + layouts: Array<[has_server_load: boolean, node_id: number] | undefined>; + leaf: [has_server_load: boolean, node_id: number]; +} + export interface SSRState { fallback?: string; getClientAddress(): string; @@ -499,6 +605,112 @@ export interface SSRState { emulator?: Emulator; } +export interface SWRState { + fallback?: string; + /** + * True if we're currently attempting to render an error page. + */ + error: boolean; + /** + * Allows us to prevent `event.fetch` from making infinitely looping internal requests. + */ + depth: number; + read?: (file: string) => Buffer; + /** + * Used to setup `__SVELTEKIT_TRACK__` which checks if a used feature is supported. + * E.g. if `read` from `$app/server` is used, it checks whether the route's config is compatible. + */ + before_handle?: (event: SWRequestEvent, config: any, prerender: PrerenderOption) => void; + emulator?: Emulator; +} + +export interface SWRequestEvent< + Params extends Partial> = Partial>, + RouteId extends string | null = string | null +> { + /** + * Get or set cookies related to the current request + */ + cookies: Cookies; + /** + * `fetch` is equivalent to the [native `fetch` web API](https://developer.mozilla.org/en-US/docs/Web/API/fetch), with a few additional features: + * + * - It can be used to make credentialed requests on the server, as it inherits the `cookie` and `authorization` headers for the page request. + * - It can make relative requests on the server (ordinarily, `fetch` requires a URL with an origin when used in a server context). + * - Internal requests (e.g. for `+server.js` routes) go directly to the handler function when running on the server, without the overhead of an HTTP call. + * - During server-side rendering, the response will be captured and inlined into the rendered HTML by hooking into the `text` and `json` methods of the `Response` object. Note that headers will _not_ be serialized, unless explicitly included via [`filterSerializedResponseHeaders`](https://svelte.dev/docs/kit/hooks#Server-hooks-handle) + * - During hydration, the response will be read from the HTML, guaranteeing consistency and preventing an additional network request. + * + * You can learn more about making credentialed requests with cookies [here](https://svelte.dev/docs/kit/load#Cookies). + */ + fetch: typeof fetch; + /** + * The parameters of the current route - e.g. for a route like `/blog/[slug]`, a `{ slug: string }` object. + */ + params: Params; + /** + * The original request object. + */ + request: Request; + /** + * Info about the current route. + */ + route: { + /** + * The ID of the current route - e.g. for `src/routes/blog/[slug]`, it would be `/blog/[slug]`. It is `null` when no route is matched. + */ + id: RouteId; + }; + /** + * If you need to set headers for the response, you can do so using the this method. This is useful if you want the page to be cached, for example: + * + * ```js + * /// file: src/routes/blog/+page.js + * export async function load({ fetch, setHeaders }) { + * const url = `https://cms.example.com/articles.json`; + * const response = await fetch(url); + * + * setHeaders({ + * age: response.headers.get('age'), + * 'cache-control': response.headers.get('cache-control') + * }); + * + * return response.json(); + * } + * ``` + * + * Setting the same header multiple times (even in separate `load` functions) is an error — you can only set a given header once. + * + * You cannot add a `set-cookie` header with `setHeaders` — use the [`cookies`](https://svelte.dev/docs/kit/@sveltejs-kit#Cookies) API instead. + */ + setHeaders: (headers: Record) => void; + /** + * The requested URL. + */ + url: URL; + /** + * `true` for `+server.js` calls coming from SvelteKit without the overhead of actually making an HTTP request. This happens when you make same-origin `fetch` requests on the server. + */ + isSubRequest: boolean; +} + +export interface SWRManifest { + appDir: string; + appPath: string; + /** Static files from `kit.config.files.assets`. */ + assets: Set; + mimeTypes: Record; + + /** private fields */ + client: NonNullable; + nodes: SWRNodeLoader[]; + routes: SWRRoute[]; + prerendered_routes: Set; + matchers: () => Promise>; + /** A `[file]: size` map of all assets imported by server code. */ + server_assets: Record; +} + export type StrictBody = string | ArrayBufferView; export interface Uses { From 623b52280b40771b7386bbc1f1a54b186219d109 Mon Sep 17 00:00:00 2001 From: Thomas Foster Date: Fri, 16 May 2025 10:11:06 +0000 Subject: [PATCH 2/7] Deduplicate crypto + csp + serialize_data --- .../src/runtime/service-worker/page/crypto.js | 239 ---------- .../service-worker/page/crypto.spec.js | 24 - .../src/runtime/service-worker/page/csp.js | 366 ---------------- .../runtime/service-worker/page/csp.spec.js | 414 ------------------ .../src/runtime/service-worker/page/index.js | 17 +- .../runtime/service-worker/page/load_data.js | 315 +++++++++---- .../src/runtime/service-worker/page/render.js | 6 +- .../service-worker/page/serialize_data.js | 107 ----- .../page/serialize_data.spec.js | 103 ----- .../runtime/service-worker/page/types.d.ts | 4 +- packages/kit/src/utils/page_nodes.js | 89 ++++ packages/kit/types/index.d.ts | 77 +++- 12 files changed, 392 insertions(+), 1369 deletions(-) delete mode 100644 packages/kit/src/runtime/service-worker/page/crypto.js delete mode 100644 packages/kit/src/runtime/service-worker/page/crypto.spec.js delete mode 100644 packages/kit/src/runtime/service-worker/page/csp.js delete mode 100644 packages/kit/src/runtime/service-worker/page/csp.spec.js delete mode 100644 packages/kit/src/runtime/service-worker/page/serialize_data.js delete mode 100644 packages/kit/src/runtime/service-worker/page/serialize_data.spec.js diff --git a/packages/kit/src/runtime/service-worker/page/crypto.js b/packages/kit/src/runtime/service-worker/page/crypto.js deleted file mode 100644 index 9af02da5121a..000000000000 --- a/packages/kit/src/runtime/service-worker/page/crypto.js +++ /dev/null @@ -1,239 +0,0 @@ -const encoder = new TextEncoder(); - -/** - * SHA-256 hashing function adapted from https://bitwiseshiftleft.github.io/sjcl - * modified and redistributed under BSD license - * @param {string} data - */ -export function sha256(data) { - if (!key[0]) precompute(); - - const out = init.slice(0); - const array = encode(data); - - for (let i = 0; i < array.length; i += 16) { - const w = array.subarray(i, i + 16); - - let tmp; - let a; - let b; - - let out0 = out[0]; - let out1 = out[1]; - let out2 = out[2]; - let out3 = out[3]; - let out4 = out[4]; - let out5 = out[5]; - let out6 = out[6]; - let out7 = out[7]; - - /* Rationale for placement of |0 : - * If a value can overflow is original 32 bits by a factor of more than a few - * million (2^23 ish), there is a possibility that it might overflow the - * 53-bit mantissa and lose precision. - * - * To avoid this, we clamp back to 32 bits by |'ing with 0 on any value that - * propagates around the loop, and on the hash state out[]. I don't believe - * that the clamps on out4 and on out0 are strictly necessary, but it's close - * (for out4 anyway), and better safe than sorry. - * - * The clamps on out[] are necessary for the output to be correct even in the - * common case and for short inputs. - */ - - for (let i = 0; i < 64; i++) { - // load up the input word for this round - - if (i < 16) { - tmp = w[i]; - } else { - a = w[(i + 1) & 15]; - - b = w[(i + 14) & 15]; - - tmp = w[i & 15] = - (((a >>> 7) ^ (a >>> 18) ^ (a >>> 3) ^ (a << 25) ^ (a << 14)) + - ((b >>> 17) ^ (b >>> 19) ^ (b >>> 10) ^ (b << 15) ^ (b << 13)) + - w[i & 15] + - w[(i + 9) & 15]) | - 0; - } - - tmp = - tmp + - out7 + - ((out4 >>> 6) ^ (out4 >>> 11) ^ (out4 >>> 25) ^ (out4 << 26) ^ (out4 << 21) ^ (out4 << 7)) + - (out6 ^ (out4 & (out5 ^ out6))) + - key[i]; // | 0; - - // shift register - out7 = out6; - out6 = out5; - out5 = out4; - - out4 = (out3 + tmp) | 0; - - out3 = out2; - out2 = out1; - out1 = out0; - - out0 = - (tmp + - ((out1 & out2) ^ (out3 & (out1 ^ out2))) + - ((out1 >>> 2) ^ - (out1 >>> 13) ^ - (out1 >>> 22) ^ - (out1 << 30) ^ - (out1 << 19) ^ - (out1 << 10))) | - 0; - } - - out[0] = (out[0] + out0) | 0; - out[1] = (out[1] + out1) | 0; - out[2] = (out[2] + out2) | 0; - out[3] = (out[3] + out3) | 0; - out[4] = (out[4] + out4) | 0; - out[5] = (out[5] + out5) | 0; - out[6] = (out[6] + out6) | 0; - out[7] = (out[7] + out7) | 0; - } - - const bytes = new Uint8Array(out.buffer); - reverse_endianness(bytes); - - return base64(bytes); -} - -/** The SHA-256 initialization vector */ -const init = new Uint32Array(8); - -/** The SHA-256 hash key */ -const key = new Uint32Array(64); - -/** Function to precompute init and key. */ -function precompute() { - /** @param {number} x */ - function frac(x) { - return (x - Math.floor(x)) * 0x100000000; - } - - let prime = 2; - - for (let i = 0; i < 64; prime++) { - let is_prime = true; - - for (let factor = 2; factor * factor <= prime; factor++) { - if (prime % factor === 0) { - is_prime = false; - - break; - } - } - - if (is_prime) { - if (i < 8) { - init[i] = frac(prime ** (1 / 2)); - } - - key[i] = frac(prime ** (1 / 3)); - - i++; - } - } -} - -/** @param {Uint8Array} bytes */ -function reverse_endianness(bytes) { - for (let i = 0; i < bytes.length; i += 4) { - const a = bytes[i + 0]; - const b = bytes[i + 1]; - const c = bytes[i + 2]; - const d = bytes[i + 3]; - - bytes[i + 0] = d; - bytes[i + 1] = c; - bytes[i + 2] = b; - bytes[i + 3] = a; - } -} - -/** @param {string} str */ -function encode(str) { - const encoded = encoder.encode(str); - const length = encoded.length * 8; - - // result should be a multiple of 512 bits in length, - // with room for a 1 (after the data) and two 32-bit - // words containing the original input bit length - const size = 512 * Math.ceil((length + 65) / 512); - const bytes = new Uint8Array(size / 8); - bytes.set(encoded); - - // append a 1 - bytes[encoded.length] = 0b10000000; - - reverse_endianness(bytes); - - // add the input bit length - const words = new Uint32Array(bytes.buffer); - words[words.length - 2] = Math.floor(length / 0x100000000); // this will always be zero for us - words[words.length - 1] = length; - - return words; -} - -/* - Based on https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727 - - MIT License - Copyright (c) 2020 Egor Nepomnyaschih - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. -*/ -const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.split(''); - -/** @param {Uint8Array} bytes */ -export function base64(bytes) { - const l = bytes.length; - - let result = ''; - let i; - - for (i = 2; i < l; i += 3) { - result += chars[bytes[i - 2] >> 2]; - result += chars[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)]; - result += chars[((bytes[i - 1] & 0x0f) << 2) | (bytes[i] >> 6)]; - result += chars[bytes[i] & 0x3f]; - } - - if (i === l + 1) { - // 1 octet yet to write - result += chars[bytes[i - 2] >> 2]; - result += chars[(bytes[i - 2] & 0x03) << 4]; - result += '=='; - } - - if (i === l) { - // 2 octets yet to write - result += chars[bytes[i - 2] >> 2]; - result += chars[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)]; - result += chars[(bytes[i - 1] & 0x0f) << 2]; - result += '='; - } - - return result; -} diff --git a/packages/kit/src/runtime/service-worker/page/crypto.spec.js b/packages/kit/src/runtime/service-worker/page/crypto.spec.js deleted file mode 100644 index 5ef48af42300..000000000000 --- a/packages/kit/src/runtime/service-worker/page/crypto.spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import { webcrypto } from 'node:crypto'; -import { assert, test } from 'vitest'; -import { sha256 } from './crypto.js'; - -const inputs = [ - 'hello world', - '', - 'abcd', - 'the quick brown fox jumps over the lazy dog', - '工欲善其事,必先利其器' -].slice(0); - -inputs.forEach((input) => { - test(input, async () => { - const expected_bytes = await webcrypto.subtle.digest( - 'SHA-256', - new TextEncoder().encode(input) - ); - const expected = Buffer.from(expected_bytes).toString('base64'); - - const actual = sha256(input); - assert.equal(actual, expected); - }); -}); diff --git a/packages/kit/src/runtime/service-worker/page/csp.js b/packages/kit/src/runtime/service-worker/page/csp.js deleted file mode 100644 index 1376235b45de..000000000000 --- a/packages/kit/src/runtime/service-worker/page/csp.js +++ /dev/null @@ -1,366 +0,0 @@ -import { escape_html } from '../../../utils/escape.js'; -import { base64, sha256 } from './crypto.js'; - -const array = new Uint8Array(16); - -function generate_nonce() { - crypto.getRandomValues(array); - return base64(array); -} - -const quoted = new Set([ - 'self', - 'unsafe-eval', - 'unsafe-hashes', - 'unsafe-inline', - 'none', - 'strict-dynamic', - 'report-sample', - 'wasm-unsafe-eval', - 'script' -]); - -const crypto_pattern = /^(nonce|sha\d\d\d)-/; - -// CSP and CSP Report Only are extremely similar with a few caveats -// the easiest/DRYest way to express this is with some private encapsulation -class BaseProvider { - /** @type {boolean} */ - #use_hashes; - - /** @type {boolean} */ - #script_needs_csp; - - /** @type {boolean} */ - #script_src_needs_csp; - - /** @type {boolean} */ - #script_src_elem_needs_csp; - - /** @type {boolean} */ - #style_needs_csp; - - /** @type {boolean} */ - #style_src_needs_csp; - - /** @type {boolean} */ - #style_src_attr_needs_csp; - - /** @type {boolean} */ - #style_src_elem_needs_csp; - - /** @type {import('types').CspDirectives} */ - #directives; - - /** @type {import('types').Csp.Source[]} */ - #script_src; - - /** @type {import('types').Csp.Source[]} */ - #script_src_elem; - - /** @type {import('types').Csp.Source[]} */ - #style_src; - - /** @type {import('types').Csp.Source[]} */ - #style_src_attr; - - /** @type {import('types').Csp.Source[]} */ - #style_src_elem; - - /** @type {string} */ - #nonce; - - /** - * @param {boolean} use_hashes - * @param {import('types').CspDirectives} directives - * @param {string} nonce - */ - constructor(use_hashes, directives, nonce) { - this.#use_hashes = use_hashes; - this.#directives = __SVELTEKIT_DEV__ ? { ...directives } : directives; // clone in dev so we can safely mutate - - const d = this.#directives; - - this.#script_src = []; - this.#script_src_elem = []; - this.#style_src = []; - this.#style_src_attr = []; - this.#style_src_elem = []; - - const effective_script_src = d['script-src'] || d['default-src']; - const script_src_elem = d['script-src-elem']; - const effective_style_src = d['style-src'] || d['default-src']; - const style_src_attr = d['style-src-attr']; - const style_src_elem = d['style-src-elem']; - - if (__SVELTEKIT_DEV__) { - // remove strict-dynamic in dev... - // TODO reinstate this if we can figure out how to make strict-dynamic work - // if (d['default-src']) { - // d['default-src'] = d['default-src'].filter((name) => name !== 'strict-dynamic'); - // if (d['default-src'].length === 0) delete d['default-src']; - // } - - // if (d['script-src']) { - // d['script-src'] = d['script-src'].filter((name) => name !== 'strict-dynamic'); - // if (d['script-src'].length === 0) delete d['script-src']; - // } - - // ...and add unsafe-inline so we can inject