Skip to content

Commit 5906e97

Browse files
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 `<a href="/foo/bar">..</a>` - 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 <rich.harris@vercel.com>
1 parent 09296d0 commit 5906e97

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+743
-152
lines changed

.changeset/nine-camels-begin.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/adapter-vercel': minor
3+
---
4+
5+
feat: generate edge function dedicated to server side route resolution when using that option in SvelteKit

.changeset/slimy-foxes-travel.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': minor
3+
---
4+
5+
feat: support server-side route resolution

.github/workflows/ci.yml

+34
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,40 @@ jobs:
145145
retention-days: 3
146146
name: test-failure-cross-platform-${{ matrix.mode }}-${{ github.run_id }}-${{ matrix.os }}-${{ matrix.node-version }}-${{ matrix.e2e-browser }}
147147
path: test-results-cross-platform-${{ matrix.mode }}.tar.gz
148+
test-kit-server-side-route-resolution:
149+
runs-on: ubuntu-latest
150+
timeout-minutes: 30
151+
strategy:
152+
fail-fast: false
153+
matrix:
154+
include:
155+
- mode: 'dev'
156+
- mode: 'build'
157+
steps:
158+
- run: git config --global core.autocrlf false
159+
- uses: actions/checkout@v4
160+
- uses: pnpm/action-setup@v4.0.0
161+
- uses: actions/setup-node@v4
162+
with:
163+
node-version: 22
164+
cache: pnpm
165+
- run: pnpm install --frozen-lockfile
166+
- run: pnpm playwright install chromium
167+
- run: pnpm run sync-all
168+
- run: pnpm test:server-side-route-resolution:${{ matrix.mode }}
169+
- name: Print flaky test report
170+
run: node scripts/print-flaky-test-report.js
171+
- name: Archive test results
172+
if: failure()
173+
shell: bash
174+
run: find packages -type d -name test-results -not -empty | tar -czf test-results-server-side-route-resolution-${{ matrix.mode }}.tar.gz --files-from=-
175+
- name: Upload test results
176+
if: failure()
177+
uses: actions/upload-artifact@v4
178+
with:
179+
retention-days: 3
180+
name: test-failure-server-side-route-resolution-${{ matrix.mode }}-${{ github.run_id }}
181+
path: test-results-server-side-route-resolution-${{ matrix.mode }}.tar.gz
148182
test-others:
149183
runs-on: ubuntu-latest
150184
steps:

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
"test:kit": "pnpm run --dir packages/kit test",
99
"test:cross-platform:dev": "pnpm run --dir packages/kit test:cross-platform:dev",
1010
"test:cross-platform:build": "pnpm run --dir packages/kit test:cross-platform:build",
11+
"test:server-side-route-resolution:dev": "pnpm run --dir packages/kit test:server-side-route-resolution:dev",
12+
"test:server-side-route-resolution:build": "pnpm run --dir packages/kit test:server-side-route-resolution:build",
1113
"test:vite-ecosystem-ci": "pnpm test --dir packages/kit",
1214
"test:others": "pnpm test -r --filter=./packages/* --filter=!./packages/kit/ --workspace-concurrency=1",
1315
"check": "pnpm -r prepublishOnly && pnpm -r check",

packages/adapter-vercel/index.js

+20
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,26 @@ const plugin = function (defaults = {}) {
390390
);
391391
}
392392

393+
// optional chaining to support older versions that don't have this setting yet
394+
if (builder.config.kit.router?.resolution === 'server') {
395+
// Create a separate edge function just for server-side route resolution.
396+
// By omitting all routes we're ensuring it's small (the routes will still be available
397+
// to the route resolution, becaue it does not rely on the server routing manifest)
398+
await generate_edge_function(
399+
`${builder.config.kit.appDir}/routes`,
400+
{
401+
external: 'external' in defaults ? defaults.external : undefined,
402+
runtime: 'edge'
403+
},
404+
[]
405+
);
406+
407+
static_config.routes.push({
408+
src: `${builder.config.kit.paths.base}/${builder.config.kit.appDir}/routes(\\.js|/.*)`,
409+
dest: `${builder.config.kit.paths.base}/${builder.config.kit.appDir}/routes`
410+
});
411+
}
412+
393413
// Catch-all route must come at the end, otherwise it will swallow all other routes,
394414
// including ISR aliases if there is only one function
395415
static_config.routes.push({ src: '/.*', dest: `/${DEFAULT_FUNCTION_NAME}` });

packages/kit/kit.vitest.config.js

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { fileURLToPath } from 'node:url';
12
import { defineConfig } from 'vitest/config';
23

34
// 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({
89
}
910
},
1011
test: {
12+
alias: {
13+
'__sveltekit/paths': fileURLToPath(new URL('./test/mocks/path.js', import.meta.url))
14+
},
1115
// shave a couple seconds off the tests
1216
isolate: false,
1317
poolOptions: {

packages/kit/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@
6969
"test:integration": "pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test",
7070
"test:cross-platform:dev": "pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test:cross-platform:dev",
7171
"test:cross-platform:build": "pnpm test:unit && pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test:cross-platform:build",
72+
"test:server-side-route-resolution:dev": "pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test:server-side-route-resolution:dev",
73+
"test:server-side-route-resolution:build": "pnpm test:unit && pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test:server-side-route-resolution:build",
7274
"test:unit": "vitest --config kit.vitest.config.js run",
7375
"prepublishOnly": "pnpm generate:types",
7476
"generate:version": "node scripts/generate-version.js",

packages/kit/src/core/config/index.js

+16-1
Original file line numberDiff line numberDiff line change
@@ -115,5 +115,20 @@ export function validate_config(config) {
115115
);
116116
}
117117

118-
return options(config, 'config');
118+
const validated = options(config, 'config');
119+
120+
if (validated.kit.router.resolution === 'server') {
121+
if (validated.kit.router.type === 'hash') {
122+
throw new Error(
123+
"The `router.resolution` option cannot be 'server' if `router.type` is 'hash'"
124+
);
125+
}
126+
if (validated.kit.output.bundleStrategy !== 'split') {
127+
throw new Error(
128+
"The `router.resolution` option cannot be 'server' if `output.bundleStrategy` is 'inline' or 'single'"
129+
);
130+
}
131+
}
132+
133+
return validated;
119134
}

packages/kit/src/core/config/index.spec.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ const get_defaults = (prefix = '') => ({
9595
output: { preloadStrategy: 'modulepreload', bundleStrategy: 'split' },
9696
outDir: join(prefix, '.svelte-kit'),
9797
router: {
98-
type: 'pathname'
98+
type: 'pathname',
99+
resolution: 'client'
99100
},
100101
serviceWorker: {
101102
register: true

packages/kit/src/core/config/options.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,8 @@ const options = object(
261261
}),
262262

263263
router: object({
264-
type: list(['pathname', 'hash'])
264+
type: list(['pathname', 'hash']),
265+
resolution: list(['client', 'server'])
265266
}),
266267

267268
serviceWorker: object({

packages/kit/src/core/generate_manifest/index.js

+11-4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { compact } from '../../utils/array.js';
88
import { join_relative } from '../../utils/filesystem.js';
99
import { dedent } from '../sync/utils.js';
1010
import { find_server_assets } from './find_server_assets.js';
11+
import { uneval } from 'devalue';
1112

1213
/**
1314
* 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
2627
const reindexed = new Map();
2728
/**
2829
* All nodes actually used in the routes definition (prerendered routes are omitted).
29-
* Root layout/error is always included as they are needed for 404 and root errors.
30+
* If `routes` is empty, it means that this manifest is only used for server-side resolution
31+
* and the root layout/error is therefore not needed.
32+
* Else, root layout/error is always included as they are needed for 404 and root errors.
3033
* @type {Set<any>}
3134
*/
32-
const used_nodes = new Set([0, 1]);
35+
const used_nodes = new Set(routes.length > 0 ? [0, 1] : []);
3336

3437
const server_assets = find_server_assets(build_data, routes);
3538

@@ -58,7 +61,11 @@ export function generate_manifest({ build_data, prerendered, relative_path, rout
5861
assets.push(build_data.service_worker);
5962
}
6063

61-
const matchers = new Set();
64+
// In case of server side route resolution, we need to include all matchers. Prerendered routes are not part
65+
// of the server manifest, and they could reference matchers that then would not be included.
66+
const matchers = new Set(
67+
build_data.client?.nodes ? Object.keys(build_data.manifest_data.matchers) : undefined
68+
);
6269

6370
/** @param {Array<number | undefined>} indexes */
6471
function get_nodes(indexes) {
@@ -91,7 +98,7 @@ export function generate_manifest({ build_data, prerendered, relative_path, rout
9198
assets: new Set(${s(assets)}),
9299
mimeTypes: ${s(mime_types)},
93100
_: {
94-
client: ${s(build_data.client)},
101+
client: ${uneval(build_data.client)},
95102
nodes: [
96103
${(node_paths).map(loader).join(',\n')}
97104
],

packages/kit/src/core/postbuild/analyse.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { forked } from '../../utils/fork.js';
1313
import { installPolyfills } from '../../exports/node/polyfills.js';
1414
import { ENDPOINT_METHODS } from '../../constants.js';
1515
import { filter_private_env, filter_public_env } from '../../utils/env.js';
16-
import { resolve_route } from '../../utils/routing.js';
16+
import { has_server_load, resolve_route } from '../../utils/routing.js';
1717
import { get_page_config } from '../../utils/route_config.js';
1818
import { check_feature } from '../../utils/features.js';
1919
import { createReadableStream } from '@sveltejs/kit/node';
@@ -88,7 +88,7 @@ async function analyse({
8888
}
8989

9090
metadata.nodes[node.index] = {
91-
has_server_load: node.server?.load !== undefined || node.server?.trailingSlash !== undefined
91+
has_server_load: has_server_load(node)
9292
};
9393
}
9494

packages/kit/src/core/sync/write_client_manifest.js

+33-17
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import colors from 'kleur';
1010
* @param {import('types').ValidatedKitConfig} kit
1111
* @param {import('types').ManifestData} manifest_data
1212
* @param {string} output
13-
* @param {Array<{ has_server_load: boolean }>} [metadata]
13+
* @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
1414
*/
1515
export function write_client_manifest(kit, manifest_data, output, metadata) {
16+
const client_routing = kit.router.resolution === 'client';
17+
1618
/**
1719
* Creates a module that exports a `CSRPageNode`
1820
* @param {import('types').PageNode} node
@@ -47,11 +49,14 @@ export function write_client_manifest(kit, manifest_data, output, metadata) {
4749
write_if_changed(`${output}/nodes/${i}.js`, generate_node(node));
4850
return `() => import('./nodes/${i}')`;
4951
})
52+
// If route resolution happens on the server, we only need the root layout and root error page
53+
// upfront, the rest is loaded on demand as the user navigates the app
54+
.slice(0, client_routing ? manifest_data.nodes.length : 2)
5055
.join(',\n');
5156

5257
const layouts_with_server_load = new Set();
5358

54-
const dictionary = dedent`
59+
let dictionary = dedent`
5560
{
5661
${manifest_data.routes
5762
.map((route) => {
@@ -108,6 +113,13 @@ export function write_client_manifest(kit, manifest_data, output, metadata) {
108113
}
109114
`;
110115

116+
if (!client_routing) {
117+
dictionary = '{}';
118+
const root_layout = layouts_with_server_load.has(0);
119+
layouts_with_server_load.clear();
120+
if (root_layout) layouts_with_server_load.add(0);
121+
}
122+
111123
const client_hooks_file = resolve_entry(kit.files.hooks.client);
112124
const universal_hooks_file = resolve_entry(kit.files.hooks.universal);
113125

@@ -123,6 +135,8 @@ export function write_client_manifest(kit, manifest_data, output, metadata) {
123135
);
124136
}
125137

138+
// Stringified version of
139+
/** @type {import('../../runtime/client/types.js').SvelteKitApp} */
126140
write_if_changed(
127141
`${output}/app.js`,
128142
dedent`
@@ -137,7 +151,7 @@ export function write_client_manifest(kit, manifest_data, output, metadata) {
137151
: ''
138152
}
139153
140-
export { matchers } from './matchers.js';
154+
${client_routing ? "export { matchers } from './matchers.js';" : 'export const matchers = {};'}
141155
142156
export const nodes = [
143157
${nodes}
@@ -158,29 +172,31 @@ export function write_client_manifest(kit, manifest_data, output, metadata) {
158172
159173
export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode]));
160174
161-
export const hash = ${JSON.stringify(kit.router.type === 'hash')};
175+
export const hash = ${s(kit.router.type === 'hash')};
162176
163177
export const decode = (type, value) => decoders[type](value);
164178
165179
export { default as root } from '../root.${isSvelte5Plus() ? 'js' : 'svelte'}';
166180
`
167181
);
168182

169-
// write matchers to a separate module so that we don't
170-
// need to worry about name conflicts
171-
const imports = [];
172-
const matchers = [];
183+
if (client_routing) {
184+
// write matchers to a separate module so that we don't
185+
// need to worry about name conflicts
186+
const imports = [];
187+
const matchers = [];
173188

174-
for (const key in manifest_data.matchers) {
175-
const src = manifest_data.matchers[key];
189+
for (const key in manifest_data.matchers) {
190+
const src = manifest_data.matchers[key];
176191

177-
imports.push(`import { match as ${key} } from ${s(relative_path(output, src))};`);
178-
matchers.push(key);
179-
}
192+
imports.push(`import { match as ${key} } from ${s(relative_path(output, src))};`);
193+
matchers.push(key);
194+
}
180195

181-
const module = imports.length
182-
? `${imports.join('\n')}\n\nexport const matchers = { ${matchers.join(', ')} };`
183-
: 'export const matchers = {};';
196+
const module = imports.length
197+
? `${imports.join('\n')}\n\nexport const matchers = { ${matchers.join(', ')} };`
198+
: 'export const matchers = {};';
184199

185-
write_if_changed(`${output}/matchers.js`, module);
200+
write_if_changed(`${output}/matchers.js`, module);
201+
}
186202
}

packages/kit/src/core/sync/write_server.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ import { set_manifest, set_read_implementation } from '__sveltekit/server';
3535
import { set_private_env, set_public_env, set_safe_public_env } from '${runtime_directory}/shared-server.js';
3636
3737
export const options = {
38-
app_dir: ${s(config.kit.appDir)},
3938
app_template_contains_nonce: ${template.includes('%sveltekit.nonce%')},
4039
csp: ${s(config.kit.csp)},
4140
csrf_check_origin: ${s(config.kit.csrf.checkOrigin)},
@@ -118,6 +117,8 @@ export function write_server(config, output) {
118117
return posixify(path.relative(`${output}/server`, file));
119118
}
120119

120+
// Contains the stringified version of
121+
/** @type {import('types').SSROptions} */
121122
write_if_changed(
122123
`${output}/server/internal.js`,
123124
server_template({

packages/kit/src/exports/public.d.ts

+22
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,28 @@ export interface KitConfig {
659659
* @since 2.14.0
660660
*/
661661
type?: 'pathname' | 'hash';
662+
/**
663+
* How to determine which route to load when navigating to a new page.
664+
*
665+
* By default, SvelteKit will serve a route manifest to the browser.
666+
* 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.
667+
* Because everything happens on the client, this decision can be made immediately. The drawback is that the manifest needs to be
668+
* loaded and parsed before the first navigation can happen, which may have an impact if your app contains many routes.
669+
*
670+
* 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.
671+
* This has several advantages:
672+
* - The client does not need to load the routing manifest upfront, which can lead to faster initial page loads
673+
* - The list of routes is hidden from public view
674+
* - The server has an opportunity to intercept each navigation (for example through a middleware), enabling (for example) A/B testing opaque to SvelteKit
675+
676+
* 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)).
677+
*
678+
* > [!NOTE] When using server-side route resolution and prerendering, the resolution is prerendered along with the route itself.
679+
*
680+
* @default "client"
681+
* @since 2.17.0
682+
*/
683+
resolution?: 'client' | 'server';
662684
};
663685
serviceWorker?: {
664686
/**

0 commit comments

Comments
 (0)