Skip to content

Commit

Permalink
feat: server side route resolution (#13379)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
dummdidumm and Rich-Harris authored Feb 3, 2025
1 parent 09296d0 commit 5906e97
Show file tree
Hide file tree
Showing 42 changed files with 743 additions and 152 deletions.
5 changes: 5 additions & 0 deletions .changeset/nine-camels-begin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/adapter-vercel': minor
---

feat: generate edge function dedicated to server side route resolution when using that option in SvelteKit
5 changes: 5 additions & 0 deletions .changeset/slimy-foxes-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: support server-side route resolution
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]
- 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:
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions packages/adapter-vercel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}` });
Expand Down
4 changes: 4 additions & 0 deletions packages/kit/kit.vitest.config.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions packages/kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 16 additions & 1 deletion packages/kit/src/core/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
3 changes: 2 additions & 1 deletion packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,8 @@ const options = object(
}),

router: object({
type: list(['pathname', 'hash'])
type: list(['pathname', 'hash']),
resolution: list(['client', 'server'])
}),

serviceWorker: object({
Expand Down
15 changes: 11 additions & 4 deletions packages/kit/src/core/generate_manifest/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<any>}
*/
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);

Expand Down Expand Up @@ -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<number | undefined>} indexes */
function get_nodes(indexes) {
Expand Down Expand Up @@ -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')}
],
Expand Down
4 changes: 2 additions & 2 deletions packages/kit/src/core/postbuild/analyse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
};
}

Expand Down
50 changes: 33 additions & 17 deletions packages/kit/src/core/sync/write_client_manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);

Expand All @@ -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`
Expand All @@ -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}
Expand All @@ -158,29 +172,31 @@ 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);
export { default as root } from '../root.${isSvelte5Plus() ? 'js' : 'svelte'}';
`
);

// 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);
}
}
3 changes: 2 additions & 1 deletion packages/kit/src/core/sync/write_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)},
Expand Down Expand Up @@ -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({
Expand Down
22 changes: 22 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
/**
Expand Down
Loading

0 comments on commit 5906e97

Please sign in to comment.