From 5906e9708965b848b468d0014999c36272dc8d50 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Mon, 3 Feb 2025 10:33:31 +0100
Subject: [PATCH] feat: server side route resolution (#13379)
This PR adds server side route resolution to SvelteKit. This means that instead of loading the whole routing manifest in the client, and doing the route resolution there, the server runtime is invoked for each route request.
How this works:
- user clicks on `..`
- server call to `_app/route[route].js`, so in this case `_app/routes/foo/bar.js`
- SvelteKit server runtime does route resolution (does this match a route, taking reroutes into account etc) on the server and returns a response that is a JavaScript file containing the route information in a format that can be parsed on the client
What this enables:
- Projects with massive routes can use this to not send so many kilobytes up front to the client, because the client routing manifest would no longer be sent
- You can hide what routes you have
- Because the server is hit for every route resolution, you can put things like edge middleware in front and be confident it is always called, and for example do rewrites (for example for A/B testing) in there
---------
Co-authored-by: Rich Harris
---
.changeset/nine-camels-begin.md | 5 +
.changeset/slimy-foxes-travel.md | 5 +
.github/workflows/ci.yml | 34 +++++
package.json | 2 +
packages/adapter-vercel/index.js | 20 +++
packages/kit/kit.vitest.config.js | 4 +
packages/kit/package.json | 2 +
packages/kit/src/core/config/index.js | 17 ++-
packages/kit/src/core/config/index.spec.js | 3 +-
packages/kit/src/core/config/options.js | 3 +-
.../kit/src/core/generate_manifest/index.js | 15 ++-
packages/kit/src/core/postbuild/analyse.js | 4 +-
.../src/core/sync/write_client_manifest.js | 50 ++++---
packages/kit/src/core/sync/write_server.js | 3 +-
packages/kit/src/exports/public.d.ts | 22 +++
packages/kit/src/exports/vite/dev/index.js | 29 +++-
packages/kit/src/exports/vite/index.js | 42 +++++-
packages/kit/src/runtime/client/client.js | 126 +++++++++++-------
packages/kit/src/runtime/client/parse.js | 20 +++
packages/kit/src/runtime/client/types.d.ts | 17 ++-
packages/kit/src/runtime/pathname.js | 54 ++++++++
packages/kit/src/runtime/server/cookie.js | 3 +-
packages/kit/src/runtime/server/page/index.js | 2 +-
.../kit/src/runtime/server/page/render.js | 28 +++-
.../src/runtime/server/page/server_routing.js | 110 +++++++++++++++
packages/kit/src/runtime/server/respond.js | 55 +++++---
packages/kit/src/runtime/utils.js | 21 +++
packages/kit/src/types/ambient-private.d.ts | 1 +
packages/kit/src/types/global-private.d.ts | 2 +
packages/kit/src/types/internal.d.ts | 56 ++++++--
packages/kit/src/utils/routing.js | 8 ++
packages/kit/src/utils/url.js | 23 ----
packages/kit/test/apps/basics/package.json | 4 +-
.../kit/test/apps/basics/playwright.config.js | 4 +-
.../kit/test/apps/basics/svelte.config.js | 5 +
.../kit/test/apps/basics/test/client.test.js | 7 +-
.../basics/test/cross-platform/client.test.js | 3 +-
packages/kit/test/apps/options/package.json | 4 +-
.../test/apps/options/playwright.config.js | 13 +-
.../kit/test/apps/options/svelte.config.js | 5 +
packages/kit/test/mocks/path.js | 3 +
packages/kit/types/index.d.ts | 61 ++++++++-
42 files changed, 743 insertions(+), 152 deletions(-)
create mode 100644 .changeset/nine-camels-begin.md
create mode 100644 .changeset/slimy-foxes-travel.md
create mode 100644 packages/kit/src/runtime/pathname.js
create mode 100644 packages/kit/src/runtime/server/page/server_routing.js
create mode 100644 packages/kit/test/mocks/path.js
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[];