diff --git a/.changeset/nine-camels-begin.md b/.changeset/nine-camels-begin.md new file mode 100644 index 000000000000..deec8e67f569 --- /dev/null +++ b/.changeset/nine-camels-begin.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-vercel': minor +--- + +feat: generate edge function dedicated to server side route resolution when using that option in SvelteKit diff --git a/.changeset/slimy-foxes-travel.md b/.changeset/slimy-foxes-travel.md new file mode 100644 index 000000000000..5baba717e82a --- /dev/null +++ b/.changeset/slimy-foxes-travel.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: support server-side route resolution diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4637e191726..efca6e492d16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,6 +145,40 @@ jobs: retention-days: 3 name: test-failure-cross-platform-${{ matrix.mode }}-${{ github.run_id }}-${{ matrix.os }}-${{ matrix.node-version }}-${{ matrix.e2e-browser }} path: test-results-cross-platform-${{ matrix.mode }}.tar.gz + test-kit-server-side-route-resolution: + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - mode: 'dev' + - mode: 'build' + steps: + - run: git config --global core.autocrlf false + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4.0.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm playwright install chromium + - run: pnpm run sync-all + - run: pnpm test:server-side-route-resolution:${{ matrix.mode }} + - name: Print flaky test report + run: node scripts/print-flaky-test-report.js + - name: Archive test results + if: failure() + shell: bash + run: find packages -type d -name test-results -not -empty | tar -czf test-results-server-side-route-resolution-${{ matrix.mode }}.tar.gz --files-from=- + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + retention-days: 3 + name: test-failure-server-side-route-resolution-${{ matrix.mode }}-${{ github.run_id }} + path: test-results-server-side-route-resolution-${{ matrix.mode }}.tar.gz test-others: runs-on: ubuntu-latest steps: diff --git a/package.json b/package.json index 398c40d37a1e..aa6651bc4c65 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "test:kit": "pnpm run --dir packages/kit test", "test:cross-platform:dev": "pnpm run --dir packages/kit test:cross-platform:dev", "test:cross-platform:build": "pnpm run --dir packages/kit test:cross-platform:build", + "test:server-side-route-resolution:dev": "pnpm run --dir packages/kit test:server-side-route-resolution:dev", + "test:server-side-route-resolution:build": "pnpm run --dir packages/kit test:server-side-route-resolution:build", "test:vite-ecosystem-ci": "pnpm test --dir packages/kit", "test:others": "pnpm test -r --filter=./packages/* --filter=!./packages/kit/ --workspace-concurrency=1", "check": "pnpm -r prepublishOnly && pnpm -r check", diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 3fdd2f027b66..c10eee9ae623 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -390,6 +390,26 @@ const plugin = function (defaults = {}) { ); } + // optional chaining to support older versions that don't have this setting yet + if (builder.config.kit.router?.resolution === 'server') { + // Create a separate edge function just for server-side route resolution. + // By omitting all routes we're ensuring it's small (the routes will still be available + // to the route resolution, becaue it does not rely on the server routing manifest) + await generate_edge_function( + `${builder.config.kit.appDir}/routes`, + { + external: 'external' in defaults ? defaults.external : undefined, + runtime: 'edge' + }, + [] + ); + + static_config.routes.push({ + src: `${builder.config.kit.paths.base}/${builder.config.kit.appDir}/routes(\\.js|/.*)`, + dest: `${builder.config.kit.paths.base}/${builder.config.kit.appDir}/routes` + }); + } + // Catch-all route must come at the end, otherwise it will swallow all other routes, // including ISR aliases if there is only one function static_config.routes.push({ src: '/.*', dest: `/${DEFAULT_FUNCTION_NAME}` }); diff --git a/packages/kit/kit.vitest.config.js b/packages/kit/kit.vitest.config.js index 4f10eb4d2b6f..aa93595569df 100644 --- a/packages/kit/kit.vitest.config.js +++ b/packages/kit/kit.vitest.config.js @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; // this file needs a custom name so that the numerous test subprojects don't all pick it up @@ -8,6 +9,9 @@ export default defineConfig({ } }, test: { + alias: { + '__sveltekit/paths': fileURLToPath(new URL('./test/mocks/path.js', import.meta.url)) + }, // shave a couple seconds off the tests isolate: false, poolOptions: { diff --git a/packages/kit/package.json b/packages/kit/package.json index c361f509f320..d19848a57331 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -69,6 +69,8 @@ "test:integration": "pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test", "test:cross-platform:dev": "pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test:cross-platform:dev", "test:cross-platform:build": "pnpm test:unit && pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test:cross-platform:build", + "test:server-side-route-resolution:dev": "pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test:server-side-route-resolution:dev", + "test:server-side-route-resolution:build": "pnpm test:unit && pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test:server-side-route-resolution:build", "test:unit": "vitest --config kit.vitest.config.js run", "prepublishOnly": "pnpm generate:types", "generate:version": "node scripts/generate-version.js", diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js index b387cb2f0a44..5a6830bdaa42 100644 --- a/packages/kit/src/core/config/index.js +++ b/packages/kit/src/core/config/index.js @@ -115,5 +115,20 @@ export function validate_config(config) { ); } - return options(config, 'config'); + const validated = options(config, 'config'); + + if (validated.kit.router.resolution === 'server') { + if (validated.kit.router.type === 'hash') { + throw new Error( + "The `router.resolution` option cannot be 'server' if `router.type` is 'hash'" + ); + } + if (validated.kit.output.bundleStrategy !== 'split') { + throw new Error( + "The `router.resolution` option cannot be 'server' if `output.bundleStrategy` is 'inline' or 'single'" + ); + } + } + + return validated; } diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index d6f0df13ebbc..9c577f5425c0 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -95,7 +95,8 @@ const get_defaults = (prefix = '') => ({ output: { preloadStrategy: 'modulepreload', bundleStrategy: 'split' }, outDir: join(prefix, '.svelte-kit'), router: { - type: 'pathname' + type: 'pathname', + resolution: 'client' }, serviceWorker: { register: true diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index 472779ac0ded..a2b9bb81759d 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -261,7 +261,8 @@ const options = object( }), router: object({ - type: list(['pathname', 'hash']) + type: list(['pathname', 'hash']), + resolution: list(['client', 'server']) }), serviceWorker: object({ diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js index c4f4a5935f4f..5db569b5c8d8 100644 --- a/packages/kit/src/core/generate_manifest/index.js +++ b/packages/kit/src/core/generate_manifest/index.js @@ -8,6 +8,7 @@ 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 { uneval } from 'devalue'; /** * Generates the data used to write the server-side manifest.js file. This data is used in the Vite @@ -26,10 +27,12 @@ export function generate_manifest({ build_data, prerendered, relative_path, rout 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. + * If `routes` is empty, it means that this manifest is only used for server-side resolution + * and the root layout/error is therefore not needed. + * Else, 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 used_nodes = new Set(routes.length > 0 ? [0, 1] : []); const server_assets = find_server_assets(build_data, routes); @@ -58,7 +61,11 @@ export function generate_manifest({ build_data, prerendered, relative_path, rout assets.push(build_data.service_worker); } - const matchers = new Set(); + // 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) { @@ -91,7 +98,7 @@ export function generate_manifest({ build_data, prerendered, relative_path, rout assets: new Set(${s(assets)}), mimeTypes: ${s(mime_types)}, _: { - client: ${s(build_data.client)}, + client: ${uneval(build_data.client)}, nodes: [ ${(node_paths).map(loader).join(',\n')} ], diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index d45e6a506713..2484aea4831d 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -13,7 +13,7 @@ import { forked } from '../../utils/fork.js'; import { installPolyfills } from '../../exports/node/polyfills.js'; import { ENDPOINT_METHODS } from '../../constants.js'; import { filter_private_env, filter_public_env } from '../../utils/env.js'; -import { resolve_route } from '../../utils/routing.js'; +import { has_server_load, resolve_route } from '../../utils/routing.js'; import { get_page_config } from '../../utils/route_config.js'; import { check_feature } from '../../utils/features.js'; import { createReadableStream } from '@sveltejs/kit/node'; @@ -88,7 +88,7 @@ async function analyse({ } metadata.nodes[node.index] = { - has_server_load: node.server?.load !== undefined || node.server?.trailingSlash !== undefined + has_server_load: has_server_load(node) }; } diff --git a/packages/kit/src/core/sync/write_client_manifest.js b/packages/kit/src/core/sync/write_client_manifest.js index 00a161b57b7d..c27c61add43c 100644 --- a/packages/kit/src/core/sync/write_client_manifest.js +++ b/packages/kit/src/core/sync/write_client_manifest.js @@ -10,9 +10,11 @@ import colors from 'kleur'; * @param {import('types').ValidatedKitConfig} kit * @param {import('types').ManifestData} manifest_data * @param {string} output - * @param {Array<{ has_server_load: boolean }>} [metadata] + * @param {import('types').ServerMetadata['nodes']} [metadata] If this is omitted, we have to assume that all routes with a `+layout/page.server.js` file have a server load function */ export function write_client_manifest(kit, manifest_data, output, metadata) { + const client_routing = kit.router.resolution === 'client'; + /** * Creates a module that exports a `CSRPageNode` * @param {import('types').PageNode} node @@ -47,11 +49,14 @@ export function write_client_manifest(kit, manifest_data, output, metadata) { write_if_changed(`${output}/nodes/${i}.js`, generate_node(node)); return `() => import('./nodes/${i}')`; }) + // If route resolution happens on the server, we only need the root layout and root error page + // upfront, the rest is loaded on demand as the user navigates the app + .slice(0, client_routing ? manifest_data.nodes.length : 2) .join(',\n'); const layouts_with_server_load = new Set(); - const dictionary = dedent` + let dictionary = dedent` { ${manifest_data.routes .map((route) => { @@ -108,6 +113,13 @@ export function write_client_manifest(kit, manifest_data, output, metadata) { } `; + if (!client_routing) { + dictionary = '{}'; + const root_layout = layouts_with_server_load.has(0); + layouts_with_server_load.clear(); + if (root_layout) layouts_with_server_load.add(0); + } + const client_hooks_file = resolve_entry(kit.files.hooks.client); const universal_hooks_file = resolve_entry(kit.files.hooks.universal); @@ -123,6 +135,8 @@ export function write_client_manifest(kit, manifest_data, output, metadata) { ); } + // Stringified version of + /** @type {import('../../runtime/client/types.js').SvelteKitApp} */ write_if_changed( `${output}/app.js`, dedent` @@ -137,7 +151,7 @@ export function write_client_manifest(kit, manifest_data, output, metadata) { : '' } - export { matchers } from './matchers.js'; + ${client_routing ? "export { matchers } from './matchers.js';" : 'export const matchers = {};'} export const nodes = [ ${nodes} @@ -158,7 +172,7 @@ export function write_client_manifest(kit, manifest_data, output, metadata) { export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode])); - export const hash = ${JSON.stringify(kit.router.type === 'hash')}; + export const hash = ${s(kit.router.type === 'hash')}; export const decode = (type, value) => decoders[type](value); @@ -166,21 +180,23 @@ export function write_client_manifest(kit, manifest_data, output, metadata) { ` ); - // write matchers to a separate module so that we don't - // need to worry about name conflicts - const imports = []; - const matchers = []; + if (client_routing) { + // write matchers to a separate module so that we don't + // need to worry about name conflicts + const imports = []; + const matchers = []; - for (const key in manifest_data.matchers) { - const src = manifest_data.matchers[key]; + for (const key in manifest_data.matchers) { + const src = manifest_data.matchers[key]; - imports.push(`import { match as ${key} } from ${s(relative_path(output, src))};`); - matchers.push(key); - } + imports.push(`import { match as ${key} } from ${s(relative_path(output, src))};`); + matchers.push(key); + } - const module = imports.length - ? `${imports.join('\n')}\n\nexport const matchers = { ${matchers.join(', ')} };` - : 'export const matchers = {};'; + const module = imports.length + ? `${imports.join('\n')}\n\nexport const matchers = { ${matchers.join(', ')} };` + : 'export const matchers = {};'; - write_if_changed(`${output}/matchers.js`, module); + write_if_changed(`${output}/matchers.js`, module); + } } diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index 4de916176e85..5e93d5c1cd25 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -35,7 +35,6 @@ import { set_manifest, set_read_implementation } from '__sveltekit/server'; import { set_private_env, set_public_env, set_safe_public_env } from '${runtime_directory}/shared-server.js'; export const options = { - app_dir: ${s(config.kit.appDir)}, app_template_contains_nonce: ${template.includes('%sveltekit.nonce%')}, csp: ${s(config.kit.csp)}, csrf_check_origin: ${s(config.kit.csrf.checkOrigin)}, @@ -118,6 +117,8 @@ export function write_server(config, output) { return posixify(path.relative(`${output}/server`, file)); } + // Contains the stringified version of + /** @type {import('types').SSROptions} */ write_if_changed( `${output}/server/internal.js`, server_template({ diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 192d28b33f45..06bb38fd768c 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -659,6 +659,28 @@ export interface KitConfig { * @since 2.14.0 */ type?: 'pathname' | 'hash'; + /** + * How to determine which route to load when navigating to a new page. + * + * By default, SvelteKit will serve a route manifest to the browser. + * When navigating, this manifest is used (along with the `reroute` hook, if it exists) to determine which components to load and which `load` functions to run. + * Because everything happens on the client, this decision can be made immediately. The drawback is that the manifest needs to be + * loaded and parsed before the first navigation can happen, which may have an impact if your app contains many routes. + * + * Alternatively, SvelteKit can determine the route on the server. This means that for every navigation to a path that has not yet been visited, the server will be asked to determine the route. + * This has several advantages: + * - The client does not need to load the routing manifest upfront, which can lead to faster initial page loads + * - The list of routes is hidden from public view + * - The server has an opportunity to intercept each navigation (for example through a middleware), enabling (for example) A/B testing opaque to SvelteKit + + * The drawback is that for unvisited paths, resolution will take slightly longer (though this is mitigated by [preloading](https://svelte.dev/docs/kit/link-options#data-sveltekit-preload-data)). + * + * > [!NOTE] When using server-side route resolution and prerendering, the resolution is prerendered along with the route itself. + * + * @default "client" + * @since 2.17.0 + */ + resolution?: 'client' | 'server'; }; serviceWorker?: { /** diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 38d224871933..d9f2476e6bc7 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -136,7 +136,34 @@ export async function dev(vite, vite_config, svelte_config) { imports: [], stylesheets: [], fonts: [], - uses_env_dynamic_public: true + uses_env_dynamic_public: true, + nodes: + svelte_config.kit.router.resolution === 'client' + ? undefined + : manifest_data.nodes.map((node, i) => { + if (node.component || node.universal) { + return `${svelte_config.kit.paths.base}${to_fs(svelte_config.kit.outDir)}/generated/client/nodes/${i}.js`; + } + }), + routes: + svelte_config.kit.router.resolution === 'client' + ? undefined + : compact( + manifest_data.routes.map((route) => { + if (!route.page) return; + + return { + id: route.id, + pattern: route.pattern, + params: route.params, + layouts: route.page.layouts.map((l) => + l !== undefined ? [!!manifest_data.nodes[l].server, l] : undefined + ), + errors: route.page.errors, + leaf: [!!manifest_data.nodes[route.page.leaf].server, route.page.leaf] + }; + }) + ) }, server_assets: new Proxy( {}, diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index bd153e2ca7a1..4885d000ec15 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -13,7 +13,7 @@ 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 { assets_base, find_deps } from './build/utils.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'; import { preview } from './preview/index.js'; @@ -35,6 +35,7 @@ import { sveltekit_server } from './module_ids.js'; import { resolve_peer_dependency } from '../../utils/import.js'; +import { compact } from '../../utils/array.js'; const cwd = process.cwd(); @@ -319,7 +320,8 @@ async function kit({ svelte_config }) { __SVELTEKIT_APP_VERSION_FILE__: s(`${kit.appDir}/version.json`), __SVELTEKIT_APP_VERSION_POLL_INTERVAL__: s(kit.version.pollInterval), __SVELTEKIT_DEV__: 'false', - __SVELTEKIT_EMBEDDED__: kit.embedded ? 'true' : 'false' + __SVELTEKIT_EMBEDDED__: kit.embedded ? 'true' : 'false', + __SVELTEKIT_CLIENT_ROUTING__: kit.router.resolution === 'client' ? 'true' : 'false' }; if (!secondary_build_started) { @@ -329,7 +331,8 @@ async function kit({ svelte_config }) { new_config.define = { __SVELTEKIT_APP_VERSION_POLL_INTERVAL__: '0', __SVELTEKIT_DEV__: 'true', - __SVELTEKIT_EMBEDDED__: kit.embedded ? 'true' : 'false' + __SVELTEKIT_EMBEDDED__: kit.embedded ? 'true' : 'false', + __SVELTEKIT_CLIENT_ROUTING__: kit.router.resolution === 'client' ? 'true' : 'false' }; // These Kit dependencies are packaged as CommonJS, which means they must always be externalized. @@ -479,12 +482,14 @@ Tips: return dedent` export const base = ${global}?.base ?? ${s(base)}; export const assets = ${global}?.assets ?? ${assets ? s(assets) : 'base'}; + export const app_dir = ${s(kit.appDir)}; `; } return dedent` export let base = ${s(base)}; export let assets = ${assets ? s(assets) : 'base'}; + export const app_dir = ${s(kit.appDir)}; export const relative = ${svelte_config.kit.paths.relative}; @@ -878,6 +883,37 @@ Tips: (chunk) => chunk.type === 'chunk' && chunk.modules[env_dynamic_public] ) }; + + // In case of server-side route resolution, we create a purpose-built route manifest that is + // similar to that on the client, with as much information computed upfront so that we + // don't need to include any code of the actual routes in the server bundle. + if (svelte_config.kit.router.resolution === 'server') { + build_data.client.nodes = manifest_data.nodes.map((node, i) => { + if (node.component || node.universal) { + return resolve_symlinks( + client_manifest, + `${kit.outDir}/generated/client-optimized/nodes/${i}.js` + ).chunk.file; + } + }); + + build_data.client.routes = compact( + manifest_data.routes.map((route) => { + if (!route.page) return; + + return { + id: route.id, + pattern: route.pattern, + params: route.params, + layouts: route.page.layouts.map((l) => + l !== undefined ? [metadata.nodes[l].has_server_load, l] : undefined + ), + errors: route.page.errors, + leaf: [metadata.nodes[route.page.leaf].has_server_load, route.page.leaf] + }; + }) + ); + } } else { const start = deps_of(`${runtime_directory}/client/bundle.js`); diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 573a9d2d381b..8a72afb87d91 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -1,7 +1,6 @@ import { BROWSER, DEV } from 'esm-env'; import { onMount, tick } from 'svelte'; import { - add_data_suffix, decode_params, decode_pathname, strip_hash, @@ -9,7 +8,7 @@ import { normalize_path } from '../../utils/url.js'; import { dev_fetch, initial_fetch, lock_fetch, subsequent_fetch, unlock_fetch } from './fetcher.js'; -import { parse } from './parse.js'; +import { parse, parse_server_route } from './parse.js'; import * as storage from './session-storage.js'; import { find_anchor, @@ -40,6 +39,7 @@ import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM, validate_depends } from '../sh import { get_message, get_status } from '../../utils/error.js'; import { writable } from 'svelte/store'; import { page, update, navigating } from './state.svelte.js'; +import { add_data_suffix, add_resolution_prefix } from '../pathname.js'; const ICON_REL_ATTRIBUTES = new Set(['icon', 'shortcut icon', 'apple-touch-icon']); @@ -158,7 +158,7 @@ async function update_service_worker() { function noop() {} -/** @type {import('types').CSRRoute[]} */ +/** @type {import('types').CSRRoute[]} All routes of the app. Only available when kit.router.resolution=client */ let routes; /** @type {import('types').CSRPageNodeLoader} */ let default_layout_loader; @@ -265,7 +265,7 @@ export async function start(_app, _target, hydrate) { await _app.hooks.init?.(); - routes = parse(_app); + routes = __SVELTEKIT_CLIENT_ROUTING__ ? parse(_app) : []; container = __SVELTEKIT_EMBEDDED__ ? _target : document.documentElement; target = _target; @@ -322,7 +322,8 @@ async function _invalidate() { if (!pending_invalidate) return; pending_invalidate = null; - const intent = get_navigation_intent(current.url, true); + const nav_token = (token = {}); + const intent = await get_navigation_intent(current.url, true); // Clear preload, it might be affected by the invalidation. // Also solves an edge case where a preload is triggered, the navigation for it @@ -330,7 +331,6 @@ async function _invalidate() { // at which point the invalidation should take over and "win". load_cache = null; - const nav_token = (token = {}); const navigation_result = intent && (await load_route(intent)); if (!navigation_result || nav_token !== token) return; @@ -433,10 +433,7 @@ async function _preload_data(intent) { * @returns {Promise} */ async function _preload_code(url) { - const rerouted = get_rerouted_url(url); - if (!rerouted) return; - - const route = routes.find((route) => route.exec(get_url_path(rerouted))); + const route = (await get_navigation_intent(url, false))?.route; if (route) { await Promise.all([...route.layouts, route.leaf].map((load) => load?.[1]())); @@ -587,8 +584,7 @@ function get_navigation_result_from_branch({ url, params, branch, status, error, } /** - * Call the load function of the given node, if it exists. - * If `server_data` is passed, this is treated as the initial run and the page endpoint is not requested. + * Call the universal load function of the given node, if it exists. * * @param {{ * loader: import('types').CSRPageNodeLoader; @@ -1240,31 +1236,47 @@ function get_rerouted_url(url) { * returns undefined. * @param {URL | undefined} url * @param {boolean} invalidating + * @returns {Promise} */ -function get_navigation_intent(url, invalidating) { +async function get_navigation_intent(url, invalidating) { if (!url) return; if (is_external_url(url, base, app.hash)) return; - const rerouted = get_rerouted_url(url); - if (!rerouted) return; + if (__SVELTEKIT_CLIENT_ROUTING__) { + const rerouted = get_rerouted_url(url); + if (!rerouted) return; - const path = get_url_path(rerouted); + const path = get_url_path(rerouted); - for (const route of routes) { - const params = route.exec(path); + for (const route of routes) { + const params = route.exec(path); - if (params) { - const id = get_page_key(url); - /** @type {import('./types.js').NavigationIntent} */ - const intent = { - id, - invalidating, - route, - params: decode_params(params), - url - }; - return intent; + if (params) { + return { + id: get_page_key(url), + invalidating, + route, + params: decode_params(params), + url + }; + } } + } else { + /** @type {{ route?: import('types').CSRRouteServer, params: Record}} */ + const { route, params } = await import( + /* @vite-ignore */ + add_resolution_prefix(url.pathname) + ); + + if (!route) return; + + return { + id: get_page_key(url), + invalidating, + route: parse_server_route(route, app.nodes), + params, + url + }; } } @@ -1347,11 +1359,15 @@ async function navigate({ accept = noop, block = noop }) { - const intent = get_navigation_intent(url, false); + const prev_token = token; + token = nav_token; + + const intent = await get_navigation_intent(url, false); const nav = _before_navigate({ url, type, delta: popped?.delta, intent }); if (!nav) { block(); + if (token === nav_token) token = prev_token; return; } @@ -1367,7 +1383,6 @@ async function navigate({ stores.navigating.set((navigating.current = nav.navigation)); } - token = nav_token; let navigation_result = intent && (await load_route(intent)); if (!navigation_result) { @@ -1621,6 +1636,8 @@ if (import.meta.hot) { function setup_preload() { /** @type {NodeJS.Timeout} */ let mousemove_timeout; + /** @type {Element} */ + let current_a; container.addEventListener('mousemove', (event) => { const target = /** @type {Element} */ (event.target); @@ -1656,9 +1673,11 @@ function setup_preload() { * @param {Element} element * @param {number} priority */ - function preload(element, priority) { + async function preload(element, priority) { const a = find_anchor(element, container); - if (!a) return; + if (!a || a === current_a) return; + + current_a = a; const { url, external, download } = get_link_info(a, base, app.hash); if (external || download) return; @@ -1670,7 +1689,7 @@ function setup_preload() { if (!options.reload && !same_url) { if (priority <= options.preload_data) { - const intent = get_navigation_intent(url, false); + const intent = await get_navigation_intent(url, false); if (intent) { if (DEV) { _preload_data(intent).then((result) => { @@ -1924,7 +1943,7 @@ export async function preloadData(href) { } const url = resolve_url(href); - const intent = get_navigation_intent(url, false); + const intent = await get_navigation_intent(url, false); if (!intent) { throw new Error(`Attempted to preload a URL that does not belong to this app: ${url}`); @@ -1954,7 +1973,7 @@ export async function preloadData(href) { * @param {string} pathname * @returns {Promise} */ -export function preloadCode(pathname) { +export async function preloadCode(pathname) { if (!BROWSER) { throw new Error('Cannot call preloadCode(...) on the server'); } @@ -1974,9 +1993,11 @@ export function preloadCode(pathname) { ); } - const rerouted = get_rerouted_url(url); - if (!rerouted || !routes.find((route) => route.exec(get_url_path(rerouted)))) { - throw new Error(`'${pathname}' did not match any routes`); + if (__SVELTEKIT_CLIENT_ROUTING__) { + const rerouted = get_rerouted_url(url); + if (!rerouted || !routes.find((route) => route.exec(get_url_path(rerouted)))) { + throw new Error(`'${pathname}' did not match any routes`); + } } } @@ -2474,16 +2495,31 @@ function _start_router() { */ async function _hydrate( target, - { status = 200, error, node_ids, params, route, data: server_data_nodes, form } + { status = 200, error, node_ids, params, route, server_route, data: server_data_nodes, form } ) { hydrated = true; const url = new URL(location.href); - if (!__SVELTEKIT_EMBEDDED__) { - // See https://github.com/sveltejs/kit/pull/4935#issuecomment-1328093358 for one motivation - // of determining the params on the client side. - ({ params = {}, route = { id: null } } = get_navigation_intent(url, false) || {}); + /** @type {import('types').CSRRoute | undefined} */ + let parsed_route; + + if (__SVELTEKIT_CLIENT_ROUTING__) { + if (!__SVELTEKIT_EMBEDDED__) { + // See https://github.com/sveltejs/kit/pull/4935#issuecomment-1328093358 for one motivation + // of determining the params on the client side. + ({ params = {}, route = { id: null } } = (await get_navigation_intent(url, false)) || {}); + } + + parsed_route = routes.find(({ id }) => id === route.id); + } else { + // undefined in case of 404 + if (server_route) { + parsed_route = route = parse_server_route(server_route, app.nodes); + } else { + route = { id: null }; + params = {}; + } } /** @type {import('./types.js').NavigationFinished | undefined} */ @@ -2517,8 +2553,6 @@ async function _hydrate( /** @type {Array} */ const branch = await Promise.all(branch_promises); - const parsed_route = routes.find(({ id }) => id === route.id); - // server-side will have compacted the branch, reinstate empty slots // so that error boundaries can be lined up correctly if (parsed_route) { diff --git a/packages/kit/src/runtime/client/parse.js b/packages/kit/src/runtime/client/parse.js index 5abf1a7e38d3..20445fc9cef0 100644 --- a/packages/kit/src/runtime/client/parse.js +++ b/packages/kit/src/runtime/client/parse.js @@ -10,6 +10,7 @@ export function parse({ nodes, server_loads, dictionary, matchers }) { return Object.entries(dictionary).map(([id, [leaf, layouts, errors]]) => { const { pattern, params } = parse_route_id(id); + /** @type {import('types').CSRRoute} */ const route = { id, /** @param {string} path */ @@ -55,3 +56,22 @@ export function parse({ nodes, server_loads, dictionary, matchers }) { return id === undefined ? id : [layouts_with_server_load.has(id), nodes[id]]; } } + +/** + * @param {import('types').CSRRouteServer} input + * @param {import('types').CSRPageNodeLoader[]} app_nodes Will be modified if a new node is loaded that's not already in the array + * @returns {import('types').CSRRoute} + */ +export function parse_server_route({ nodes, id, leaf, layouts, errors }, app_nodes) { + return { + id, + exec: () => ({}), // dummy function; exec already happened on the server + // By writing to app_nodes only when a loader at that index is not already defined, + // we ensure that loaders have referential equality when they load the same node. + // Code elsewhere in client.js relies on this referential equality to determine + // if a loader is different and should therefore (re-)run. + errors: errors.map((n) => (n ? (app_nodes[n] ||= nodes[n]) : undefined)), + layouts: layouts.map((n) => (n ? [n[0], (app_nodes[n[1]] ||= nodes[n[1]])] : undefined)), + leaf: [leaf[0], (app_nodes[leaf[1]] ||= nodes[leaf[1]])] + }; +} diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index 5d0766f44cc7..4b32f56b7350 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -4,6 +4,7 @@ import { CSRPageNode, CSRPageNodeLoader, CSRRoute, + CSRRouteServer, ServerDataNode, TrailingSlash, Uses @@ -12,13 +13,18 @@ import { Page, ParamMatcher } from '@sveltejs/kit'; export interface SvelteKitApp { /** - * A list of all the error/layout/page nodes used in the app + * A list of all the error/layout/page nodes used in the app. + * - In case of router.resolution=client, this is filled completely upfront. + * - In case of router.resolution=server, this is filled with the root layout and root error page + * at the beginning and then filled up as the user navigates around the app, loading new nodes */ nodes: CSRPageNodeLoader[]; /** * A list of all layout node ids that have a server load function. * Pages are not present because it's shorter to encode it on the leaf itself. + * + * In case of router.resolution=server, this only contains one entry for the root layout. */ server_loads: number[]; @@ -27,9 +33,16 @@ export interface SvelteKitApp { * is parsed into an array of routes on startup. The numbers refer to the indices in `nodes`. * If the leaf number is negative, it means it does use a server load function and the complement is the node index. * The route layout and error nodes are not referenced, they are always number 0 and 1 and always apply. + * + * In case of router.resolution=server, this object is empty, as resolution happens on the server. */ dictionary: Record; + /** + * A map of `[matcherName: string]: (..) => boolean`, which is used to match route parameters. + * + * In case of router.resolution=server, this object is empty, as resolution happens on the server. + */ matchers: Record; hooks: ClientHooks; @@ -108,6 +121,8 @@ export interface HydrateOptions { node_ids: number[]; params: Record; route: { id: string | null }; + /** Only used when `router.resolution=server`; can then still be undefined in case of 404 */ + server_route?: CSRRouteServer; data: Array; form: Record | null; } diff --git a/packages/kit/src/runtime/pathname.js b/packages/kit/src/runtime/pathname.js new file mode 100644 index 000000000000..f9a2d3b23d2f --- /dev/null +++ b/packages/kit/src/runtime/pathname.js @@ -0,0 +1,54 @@ +import { base, app_dir } from '__sveltekit/paths'; + +const DATA_SUFFIX = '/__data.json'; +const HTML_DATA_SUFFIX = '.html__data.json'; + +/** @param {string} pathname */ +export function has_data_suffix(pathname) { + return pathname.endsWith(DATA_SUFFIX) || pathname.endsWith(HTML_DATA_SUFFIX); +} + +/** @param {string} pathname */ +export function add_data_suffix(pathname) { + if (pathname.endsWith('.html')) return pathname.replace(/\.html$/, HTML_DATA_SUFFIX); + return pathname.replace(/\/$/, '') + DATA_SUFFIX; +} + +/** @param {string} pathname */ +export function strip_data_suffix(pathname) { + if (pathname.endsWith(HTML_DATA_SUFFIX)) { + return pathname.slice(0, -HTML_DATA_SUFFIX.length) + '.html'; + } + + return pathname.slice(0, -DATA_SUFFIX.length); +} + +const ROUTE_PREFIX = `${base}/${app_dir}/route`; + +/** + * @param {string} pathname + * @returns {boolean} + */ +export function has_resolution_prefix(pathname) { + return pathname === `${ROUTE_PREFIX}.js` || pathname.startsWith(`${ROUTE_PREFIX}/`); +} + +/** + * Convert a regular URL to a route to send to SvelteKit's server-side route resolution endpoint + * @param {string} pathname + * @returns {string} + */ +export function add_resolution_prefix(pathname) { + let normalized = pathname.slice(base.length); + if (normalized.endsWith('/')) normalized = normalized.slice(0, -1); + + return `${ROUTE_PREFIX}${normalized}.js`; +} + +/** + * @param {string} pathname + * @returns {string} + */ +export function strip_resolution_prefix(pathname) { + return base + (pathname.slice(ROUTE_PREFIX.length, -3) || '/'); +} diff --git a/packages/kit/src/runtime/server/cookie.js b/packages/kit/src/runtime/server/cookie.js index 0dc74f5a613b..b5bc4d29563b 100644 --- a/packages/kit/src/runtime/server/cookie.js +++ b/packages/kit/src/runtime/server/cookie.js @@ -1,5 +1,6 @@ import { parse, serialize } from 'cookie'; -import { add_data_suffix, normalize_path, resolve } from '../../utils/url.js'; +import { normalize_path, resolve } from '../../utils/url.js'; +import { add_data_suffix } from '../pathname.js'; // eslint-disable-next-line no-control-regex -- control characters are invalid in cookie names const INVALID_COOKIE_CHARACTER_REGEX = /[\x00-\x1F\x7F()<>@,;:"/[\]?={} \t]/; diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index 83f30cb29d71..07f24f4e2561 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -1,7 +1,7 @@ import { text } from '../../../exports/index.js'; import { compact } from '../../../utils/array.js'; import { get_status, normalize_error } from '../../../utils/error.js'; -import { add_data_suffix } from '../../../utils/url.js'; +import { add_data_suffix } from '../../pathname.js'; import { Redirect } from '../../control.js'; import { redirect_response, static_error_page, handle_error_and_jsonify } from '../utils.js'; import { diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index d8fbe32a7ed8..3885b9cf986b 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -13,6 +13,8 @@ import { text } from '../../../exports/index.js'; import { create_async_iterator } from '../../../utils/streaming.js'; import { SVELTE_KIT_ASSETS } from '../../../constants.js'; import { SCHEME } from '../../../utils/url.js'; +import { create_server_routing_response, generate_route_object } from './server_routing.js'; +import { add_resolution_prefix } from '../../pathname.js'; // TODO rename this function/module @@ -297,8 +299,10 @@ export async function render_response({ } if (page_config.csr) { + const route = manifest._.client.routes?.find((r) => r.id === event.route.id) ?? null; + if (client.uses_env_dynamic_public && state.prerendering) { - modulepreloads.add(`${options.app_dir}/env.js`); + modulepreloads.add(`${paths.app_dir}/env.js`); } if (!client.inline) { @@ -317,6 +321,16 @@ export async function render_response({ } } + // prerender a `/_app/route/path/to/page.js` module + if (manifest._.client.routes && state.prerendering && !state.prerendering.fallback) { + const pathname = add_resolution_prefix(event.url.pathname); + + state.prerendering.dependencies.set( + pathname, + create_server_routing_response(route, event.params, new URL(pathname, event.url), manifest) + ); + } + const blocks = []; // when serving a prerendered page in an app that uses $env/dynamic/public, we must @@ -394,7 +408,15 @@ export async function render_response({ hydrate.push(`status: ${status}`); } - if (options.embedded) { + 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)}`); } @@ -419,7 +441,7 @@ export async function render_response({ });`; if (load_env_eagerly) { - blocks.push(`import(${s(`${base}/${options.app_dir}/env.js`)}).then(({ env }) => { + blocks.push(`import(${s(`${base}/${paths.app_dir}/env.js`)}).then(({ env }) => { ${global}.env = env; ${boot.replace(/\n/g, '\n\t')} diff --git a/packages/kit/src/runtime/server/page/server_routing.js b/packages/kit/src/runtime/server/page/server_routing.js new file mode 100644 index 000000000000..1caf4cbbb4b2 --- /dev/null +++ b/packages/kit/src/runtime/server/page/server_routing.js @@ -0,0 +1,110 @@ +import { base, assets } 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').SSRClientRoute} route + * @param {URL} url + * @param {import('@sveltejs/kit').SSRManifest} 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}')`; + } + + // 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('@sveltejs/kit').SSRManifest} 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').SSRClientRoute | null} route + * @param {Partial>} params + * @param {URL} url + * @param {import('@sveltejs/kit').SSRManifest} 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 = `export const route = ${csr_route}; export const params = ${JSON.stringify(params)};`; + + return { response: text(body, { headers }), body }; + } else { + return { response: text('', { headers }), body: '' }; + } +} diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index d95ba3514166..429d523c3715 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -1,19 +1,12 @@ import { DEV } from 'esm-env'; -import { base } from '__sveltekit/paths'; +import { base, app_dir } from '__sveltekit/paths'; import { is_endpoint_request, render_endpoint } from './endpoint.js'; import { render_page } from './page/index.js'; import { render_response } from './page/render.js'; import { respond_with_error } from './page/respond_with_error.js'; import { is_form_content_type } from '../../utils/http.js'; import { handle_fatal_error, method_not_allowed, redirect_response } from './utils.js'; -import { - decode_pathname, - decode_params, - disable_search, - has_data_suffix, - normalize_path, - strip_data_suffix -} from '../../utils/url.js'; +import { decode_pathname, decode_params, disable_search, normalize_path } from '../../utils/url.js'; import { exec } from '../../utils/routing.js'; import { redirect_json_response, render_data } from './data/index.js'; import { add_cookies_to_headers, get_cookies } from './cookie.js'; @@ -33,7 +26,14 @@ import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM } from '../shared.js'; import { get_public_env } from './env_module.js'; import { load_page_nodes } from './page/load_page_nodes.js'; import { get_page_config } from '../../utils/route_config.js'; +import { resolve_route } from './page/server_routing.js'; import { validateHeaders } from './validate-headers.js'; +import { + has_data_suffix, + has_resolution_prefix, + strip_data_suffix, + strip_resolution_prefix +} from '../pathname.js'; /* global __SVELTEKIT_ADAPTER_NAME__ */ /* global __SVELTEKIT_DEV__ */ @@ -87,10 +87,19 @@ export async function respond(request, options, manifest, state) { return text('Not found', { status: 404 }); } - const is_data_request = has_data_suffix(url.pathname); /** @type {boolean[] | undefined} */ let invalidated_data_nodes; - if (is_data_request) { + + /** + * 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_prefix(url.pathname); + const is_data_request = has_data_suffix(url.pathname); + + if (is_route_resolution_request) { + url.pathname = strip_resolution_prefix(url.pathname); + } else if (is_data_request) { url.pathname = strip_data_suffix(url.pathname) + (url.searchParams.get(TRAILING_SLASH_PARAM) === '1' ? '/' : '') || '/'; @@ -102,19 +111,19 @@ export async function respond(request, options, manifest, state) { url.searchParams.delete(INVALIDATED_PARAM); } - // reroute could alter the given URL, so we pass a copy - let rerouted_path; + let resolved_path; + try { - rerouted_path = options.hooks.reroute({ url: new URL(url) }) ?? url.pathname; + // reroute could alter the given URL, so we pass a copy + resolved_path = options.hooks.reroute({ url: new URL(url) }) ?? url.pathname; } catch { return text('Internal Server Error', { status: 500 }); } - let decoded; try { - decoded = decode_pathname(rerouted_path); + resolved_path = decode_pathname(resolved_path); } catch { return text('Malformed URI', { status: 400 }); } @@ -126,17 +135,21 @@ export async function respond(request, options, manifest, state) { let params = {}; if (base && !state.prerendering?.fallback) { - if (!decoded.startsWith(base)) { + if (!resolved_path.startsWith(base)) { return text('Not found', { status: 404 }); } - decoded = decoded.slice(base.length) || '/'; + resolved_path = resolved_path.slice(base.length) || '/'; + } + + if (is_route_resolution_request) { + return resolve_route(resolved_path, new URL(request.url), manifest); } - if (decoded === `/${options.app_dir}/env.js`) { + if (resolved_path === `/${app_dir}/env.js`) { return get_public_env(request); } - if (decoded.startsWith(`/${options.app_dir}`)) { + 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'); @@ -148,7 +161,7 @@ export async function respond(request, options, manifest, state) { const matchers = await manifest._.matchers(); for (const candidate of manifest._.routes) { - const match = candidate.pattern.exec(decoded); + const match = candidate.pattern.exec(resolved_path); if (!match) continue; const matched = exec(match, candidate.params, matchers); diff --git a/packages/kit/src/runtime/utils.js b/packages/kit/src/runtime/utils.js index f73f5372a91e..2da4498d9b7f 100644 --- a/packages/kit/src/runtime/utils.js +++ b/packages/kit/src/runtime/utils.js @@ -32,3 +32,24 @@ export function b64_encode(buffer) { ) ); } + +/** + * Like node's path.relative, but without using node + * @param {string} from + * @param {string} to + */ +export function get_relative_path(from, to) { + const from_parts = from.split(/[/\\]/); + const to_parts = to.split(/[/\\]/); + from_parts.pop(); // get dirname + + while (from_parts[0] === to_parts[0]) { + from_parts.shift(); + to_parts.shift(); + } + + let i = from_parts.length; + while (i--) from_parts[i] = '..'; + + return from_parts.concat(to_parts).join('/'); +} diff --git a/packages/kit/src/types/ambient-private.d.ts b/packages/kit/src/types/ambient-private.d.ts index 4f1491475355..c98af8cb0062 100644 --- a/packages/kit/src/types/ambient-private.d.ts +++ b/packages/kit/src/types/ambient-private.d.ts @@ -11,6 +11,7 @@ declare module '__sveltekit/environment' { declare module '__sveltekit/paths' { export let base: '' | `/${string}`; export let assets: '' | `https://${string}` | `http://${string}` | '/_svelte_kit_assets'; + export let app_dir: string; export let relative: boolean; export function reset(): void; export function override(paths: { base: string; assets: string }): void; diff --git a/packages/kit/src/types/global-private.d.ts b/packages/kit/src/types/global-private.d.ts index 66e1d41edd1f..ab659a8db5c5 100644 --- a/packages/kit/src/types/global-private.d.ts +++ b/packages/kit/src/types/global-private.d.ts @@ -4,6 +4,8 @@ declare global { const __SVELTEKIT_APP_VERSION_POLL_INTERVAL__: number; const __SVELTEKIT_DEV__: boolean; const __SVELTEKIT_EMBEDDED__: boolean; + /** True if `config.kit.router.resolution === 'client'` */ + const __SVELTEKIT_CLIENT_ROUTING__: boolean; /** * This makes the use of specific features visible at both dev and build time, in such a * way that we can error when they are not supported by the target platform. diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index ba4355a8db3f..c5d2609fc006 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -68,12 +68,28 @@ export interface BuildData { out_dir: string; service_worker: string | null; client: { + /** Path to the client entry point */ start: string; + /** Path to the generated `app.js` file that contains the client manifest. Only set in case of `bundleStrategy === 'split'` */ app?: string; + /** JS files that the client entry point relies on */ imports: string[]; + /** + * JS files that represent the entry points of the layouts/pages. + * An entry is undefined if the layout/page has no component or universal file (i.e. only has a `.server.js` file). + * Only set in case of `router.resolution === 'server'`. + */ + nodes?: (string | undefined)[]; + /** + * Contains the client route manifest in a form suitable for the server which is used for server side route resolution. + * Notably, it contains all routes, regardless of whether they are prerendered or not (those are missing in the optimized server route manifest). + * Only set in case of `router.resolution === 'server'`. + */ + routes?: SSRClientRoute[]; stylesheets: string[]; fonts: string[]; uses_env_dynamic_public: boolean; + /** Only set in case of `bundleStrategy === 'inline'` */ inline?: { script: string; style: string | undefined; @@ -104,6 +120,17 @@ export type CSRRoute = { leaf: [has_server_load: boolean, node_loader: CSRPageNodeLoader]; }; +/** + * Definition of a client side route as transported via `_app/route/...` when using server-side route resolution. + */ +export type CSRRouteServer = { + id: string; + errors: Array; + layouts: Array<[has_server_load: boolean, node_id: number] | undefined>; + leaf: [has_server_load: boolean, node_id: number]; + nodes: Record; +}; + export interface Deferred { fulfil: (value: any) => void; reject: (error: Error) => void; @@ -160,11 +187,11 @@ export interface ManifestData { export interface PageNode { depth: number; - /** The +page.svelte */ + /** The +page/layout.svelte */ component?: string; // TODO supply default component if it's missing (bit of an edge case) - /** The +page.js/.ts */ + /** The +page/layout.js/.ts */ universal?: string; - /** The +page.server.js/ts */ + /** The +page/layout.server.js/ts */ server?: string; parent_id?: string; parent?: PageNode; @@ -304,7 +331,10 @@ export interface ServerMetadataRoute { } export interface ServerMetadata { - nodes: Array<{ has_server_load: boolean }>; + nodes: Array<{ + /** Also `true` when using `trailingSlash`, because we need to do a server request in that case to get its value */ + has_server_load: boolean; + }>; routes: Map; } @@ -328,13 +358,13 @@ export type SSRComponentLoader = () => Promise; export interface SSRNode { component: SSRComponentLoader; - /** index into the `components` array in client/manifest.js */ + /** index into the `nodes` array in the generated `client/app.js` */ index: number; - /** external JS files */ + /** 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 */ + /** external CSS files that are loaded on the client */ stylesheets: string[]; - /** external font files */ + /** external font files that are loaded on the client */ fonts: string[]; /** inlined styles */ inline_styles?(): MaybePromise>; @@ -367,7 +397,6 @@ export interface SSRNode { export type SSRNodeLoader = () => Promise; export interface SSROptions { - app_dir: string; app_template_contains_nonce: boolean; csp: ValidatedConfig['kit']['csp']; csrf_check_origin: boolean; @@ -417,6 +446,15 @@ export interface SSRRoute { endpoint_id?: string; } +export interface SSRClientRoute { + 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; diff --git a/packages/kit/src/utils/routing.js b/packages/kit/src/utils/routing.js index 7d988317a058..9442b2f3b1da 100644 --- a/packages/kit/src/utils/routing.js +++ b/packages/kit/src/utils/routing.js @@ -265,3 +265,11 @@ export function resolve_route(id, params) { .join('/') ); } + +/** + * @param {import('types').SSRNode} node + * @returns {boolean} + */ +export function has_server_load(node) { + return node.server?.load !== undefined || node.server?.trailingSlash !== undefined; +} diff --git a/packages/kit/src/utils/url.js b/packages/kit/src/utils/url.js index 0d0af684f6a2..7ea2c9d100f1 100644 --- a/packages/kit/src/utils/url.js +++ b/packages/kit/src/utils/url.js @@ -199,26 +199,3 @@ function allow_nodejs_console_log(url) { }; } } - -const DATA_SUFFIX = '/__data.json'; -const HTML_DATA_SUFFIX = '.html__data.json'; - -/** @param {string} pathname */ -export function has_data_suffix(pathname) { - return pathname.endsWith(DATA_SUFFIX) || pathname.endsWith(HTML_DATA_SUFFIX); -} - -/** @param {string} pathname */ -export function add_data_suffix(pathname) { - if (pathname.endsWith('.html')) return pathname.replace(/\.html$/, HTML_DATA_SUFFIX); - return pathname.replace(/\/$/, '') + DATA_SUFFIX; -} - -/** @param {string} pathname */ -export function strip_data_suffix(pathname) { - if (pathname.endsWith(HTML_DATA_SUFFIX)) { - return pathname.slice(0, -HTML_DATA_SUFFIX.length) + '.html'; - } - - return pathname.slice(0, -DATA_SUFFIX.length); -} diff --git a/packages/kit/test/apps/basics/package.json b/packages/kit/test/apps/basics/package.json index 41a9d4795f9a..ce33b20dd9e1 100644 --- a/packages/kit/test/apps/basics/package.json +++ b/packages/kit/test/apps/basics/package.json @@ -12,7 +12,9 @@ "test:dev": "node -e \"fs.rmSync('test/errors.json', { force: true })\" && cross-env DEV=true playwright test", "test:build": "node -e \"fs.rmSync('test/errors.json', { force: true })\" && cross-env PUBLIC_PRERENDERING=false playwright test", "test:cross-platform:dev": "node test/setup.js && node -e \"fs.rmSync('test/errors.json', { force: true })\" && cross-env DEV=true playwright test test/cross-platform/", - "test:cross-platform:build": "node test/setup.js && node -e \"fs.rmSync('test/errors.json', { force: true })\" && playwright test test/cross-platform/" + "test:cross-platform:build": "node test/setup.js && node -e \"fs.rmSync('test/errors.json', { force: true })\" && playwright test test/cross-platform/", + "test:server-side-route-resolution:dev": "node test/setup.js && node -e \"fs.rmSync('test/errors.json', { force: true })\" && cross-env DEV=true ROUTER_RESOLUTION=server playwright test", + "test:server-side-route-resolution:build": "node test/setup.js && node -e \"fs.rmSync('test/errors.json', { force: true })\" && cross-env PUBLIC_PRERENDERING=false ROUTER_RESOLUTION=server playwright test" }, "devDependencies": { "@sveltejs/kit": "workspace:^", diff --git a/packages/kit/test/apps/basics/playwright.config.js b/packages/kit/test/apps/basics/playwright.config.js index c6f0b8fa473e..7866297cf20e 100644 --- a/packages/kit/test/apps/basics/playwright.config.js +++ b/packages/kit/test/apps/basics/playwright.config.js @@ -5,8 +5,8 @@ export default { ...config, webServer: { command: process.env.DEV - ? 'cross-env PUBLIC_PRERENDERING=false pnpm dev' - : 'cross-env PUBLIC_PRERENDERING=true pnpm build && pnpm preview', + ? `cross-env PUBLIC_PRERENDERING=false ROUTER_RESOLUTION=${process.env.ROUTER_RESOLUTION ?? 'client'} pnpm dev` + : `cross-env PUBLIC_PRERENDERING=true ROUTER_RESOLUTION=${process.env.ROUTER_RESOLUTION ?? 'client'} pnpm build && pnpm preview`, port: process.env.DEV ? 5173 : 4173 } }; diff --git a/packages/kit/test/apps/basics/svelte.config.js b/packages/kit/test/apps/basics/svelte.config.js index f7986e3e3ab9..bca05e5376ee 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -1,3 +1,5 @@ +import process from 'node:process'; + /** @type {import('@sveltejs/kit').Config} */ const config = { kit: { @@ -28,6 +30,9 @@ const config = { version: { name: 'TEST_VERSION' + }, + router: { + resolution: /** @type {'client' | 'server'} */ (process.env.ROUTER_RESOLUTION) || 'client' } } }; diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 65ead7cb3e1e..72e4973a95d5 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -228,13 +228,14 @@ test.describe('Load', () => { await clicknav('[href="/load/fetch-cache-control"]'); // 3. Come back to the original page (client side) + /** @type {string[]} */ const requests = []; - page.on('request', (request) => requests.push(request)); + page.on('request', (request) => requests.push(request.url())); await clicknav('[href="/load/fetch-cache-control/headers-diff"]'); - // 4. We expect the same data and no new request because it was cached. + // 4. We expect the same data and no new request (except a navigation request in case of server-side route resolution) because it was cached. expect(await page.textContent('h2')).toBe('a / b'); - expect(requests).toEqual([]); + expect(requests.filter((r) => !r.includes('_app/route'))).toEqual([]); }); test('permits 3rd party patching of fetch in universal load functions', async ({ page }) => { diff --git a/packages/kit/test/apps/basics/test/cross-platform/client.test.js b/packages/kit/test/apps/basics/test/cross-platform/client.test.js index 5ac76c9b724c..c164a1593747 100644 --- a/packages/kit/test/apps/basics/test/cross-platform/client.test.js +++ b/packages/kit/test/apps/basics/test/cross-platform/client.test.js @@ -863,7 +863,8 @@ test.describe('Routing', () => { await page.locator('input').fill('updated'); await page.locator('button').click(); - expect(requests).toEqual([]); + // Filter out server-side route resolution request + expect(requests.filter((r) => !r.includes('_app/route'))).toEqual([]); expect(await page.textContent('h1')).toBe('updated'); expect(await page.textContent('h2')).toBe('form'); expect(await page.textContent('h3')).toBe('bar'); diff --git a/packages/kit/test/apps/options/package.json b/packages/kit/test/apps/options/package.json index c53a63b72c62..4974a6fe3d53 100644 --- a/packages/kit/test/apps/options/package.json +++ b/packages/kit/test/apps/options/package.json @@ -10,7 +10,9 @@ "check": "svelte-kit sync && tsc && svelte-check", "test": "pnpm test:dev && pnpm test:build", "test:dev": "cross-env DEV=true playwright test", - "test:build": "playwright test" + "test:build": "playwright test", + "test:server-side-route-resolution:dev": "node -e \"fs.rmSync('test/errors.json', { force: true })\" && cross-env DEV=true ROUTER_RESOLUTION=server playwright test", + "test:server-side-route-resolution:build": "node -e \"fs.rmSync('test/errors.json', { force: true })\" && cross-env ROUTER_RESOLUTION=server playwright test" }, "devDependencies": { "@fontsource/libre-barcode-128-text": "^5.1.0", diff --git a/packages/kit/test/apps/options/playwright.config.js b/packages/kit/test/apps/options/playwright.config.js index 33d36b651014..9f039939abd5 100644 --- a/packages/kit/test/apps/options/playwright.config.js +++ b/packages/kit/test/apps/options/playwright.config.js @@ -1 +1,12 @@ -export { config as default } from '../../utils.js'; +import process from 'node:process'; +import { config } from '../../utils.js'; + +export default { + ...config, + webServer: { + ...config.webServer, + command: process.env.DEV + ? `cross-env ROUTER_RESOLUTION=${process.env.ROUTER_RESOLUTION ?? 'client'} pnpm dev` + : `cross-env ROUTER_RESOLUTION=${process.env.ROUTER_RESOLUTION ?? 'client'} pnpm build && pnpm preview` + } +}; diff --git a/packages/kit/test/apps/options/svelte.config.js b/packages/kit/test/apps/options/svelte.config.js index e32d72250a91..3c10a2826669 100644 --- a/packages/kit/test/apps/options/svelte.config.js +++ b/packages/kit/test/apps/options/svelte.config.js @@ -1,3 +1,5 @@ +import process from 'node:process'; + /** @type {import('@sveltejs/kit').Config} */ const config = { extensions: ['.jesuslivesineveryone', '.whokilledthemuffinman', '.svelte.md', '.svelte'], @@ -35,6 +37,9 @@ const config = { dir: './env-dir', publicPrefix: 'GO_AWAY_', privatePrefix: 'TOP_SECRET_SHH' + }, + router: { + resolution: /** @type {'client' | 'server'} */ (process.env.ROUTER_RESOLUTION) || 'client' } } }; diff --git a/packages/kit/test/mocks/path.js b/packages/kit/test/mocks/path.js new file mode 100644 index 000000000000..5919601b2122 --- /dev/null +++ b/packages/kit/test/mocks/path.js @@ -0,0 +1,3 @@ +export const base = ''; +export const assets = ''; +export const app_dir = '_app'; diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 673872faac9d..39247ec06dd7 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -641,6 +641,28 @@ declare module '@sveltejs/kit' { * @since 2.14.0 */ type?: 'pathname' | 'hash'; + /** + * How to determine which route to load when navigating to a new page. + * + * By default, SvelteKit will serve a route manifest to the browser. + * When navigating, this manifest is used (along with the `reroute` hook, if it exists) to determine which components to load and which `load` functions to run. + * Because everything happens on the client, this decision can be made immediately. The drawback is that the manifest needs to be + * loaded and parsed before the first navigation can happen, which may have an impact if your app contains many routes. + * + * Alternatively, SvelteKit can determine the route on the server. This means that for every navigation to a path that has not yet been visited, the server will be asked to determine the route. + * This has several advantages: + * - The client does not need to load the routing manifest upfront, which can lead to faster initial page loads + * - The list of routes is hidden from public view + * - The server has an opportunity to intercept each navigation (for example through a middleware), enabling (for example) A/B testing opaque to SvelteKit + + * The drawback is that for unvisited paths, resolution will take slightly longer (though this is mitigated by [preloading](https://svelte.dev/docs/kit/link-options#data-sveltekit-preload-data)). + * + * > [!NOTE] When using server-side route resolution and prerendering, the resolution is prerendered along with the route itself. + * + * @default "client" + * @since 2.17.0 + */ + resolution?: 'client' | 'server'; }; serviceWorker?: { /** @@ -1693,12 +1715,28 @@ declare module '@sveltejs/kit' { out_dir: string; service_worker: string | null; client: { + /** Path to the client entry point */ start: string; + /** Path to the generated `app.js` file that contains the client manifest. Only set in case of `bundleStrategy === 'split'` */ app?: string; + /** JS files that the client entry point relies on */ imports: string[]; + /** + * JS files that represent the entry points of the layouts/pages. + * An entry is undefined if the layout/page has no component or universal file (i.e. only has a `.server.js` file). + * Only set in case of `router.resolution === 'server'`. + */ + nodes?: (string | undefined)[]; + /** + * Contains the client route manifest in a form suitable for the server which is used for server side route resolution. + * Notably, it contains all routes, regardless of whether they are prerendered or not (those are missing in the optimized server route manifest). + * Only set in case of `router.resolution === 'server'`. + */ + routes?: SSRClientRoute[]; stylesheets: string[]; fonts: string[]; uses_env_dynamic_public: boolean; + /** Only set in case of `bundleStrategy === 'inline'` */ inline?: { script: string; style: string | undefined; @@ -1721,11 +1759,11 @@ declare module '@sveltejs/kit' { interface PageNode { depth: number; - /** The +page.svelte */ + /** The +page/layout.svelte */ component?: string; // TODO supply default component if it's missing (bit of an edge case) - /** The +page.js/.ts */ + /** The +page/layout.js/.ts */ universal?: string; - /** The +page.server.js/ts */ + /** The +page/layout.server.js/ts */ server?: string; parent_id?: string; parent?: PageNode; @@ -1797,13 +1835,13 @@ declare module '@sveltejs/kit' { interface SSRNode { component: SSRComponentLoader; - /** index into the `components` array in client/manifest.js */ + /** index into the `nodes` array in the generated `client/app.js` */ index: number; - /** external JS files */ + /** 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 */ + /** external CSS files that are loaded on the client */ stylesheets: string[]; - /** external font files */ + /** external font files that are loaded on the client */ fonts: string[]; /** inlined styles */ inline_styles?(): MaybePromise>; @@ -1860,6 +1898,15 @@ declare module '@sveltejs/kit' { endpoint_id?: string; } + interface SSRClientRoute { + 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]; + } + type ValidatedConfig = Config & { kit: ValidatedKitConfig; extensions: string[];