Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: server side route resolution #13379

Merged
merged 75 commits into from
Feb 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
388363b
first draft of server routing manifest
dummdidumm Jan 24, 2025
70d1a8f
_app/routes/... endpoint
dummdidumm Jan 24, 2025
b0ecbeb
WIP client integration
dummdidumm Jan 24, 2025
72782d8
do this via ssrmanifest instead (previous approach does not work beca…
dummdidumm Jan 24, 2025
a107444
add option
dummdidumm Jan 25, 2025
63808a9
integrate option
dummdidumm Jan 25, 2025
e8528f5
oops
dummdidumm Jan 25, 2025
e14b473
silence vite warning
dummdidumm Jan 25, 2025
f65ceb3
obsolete
dummdidumm Jan 25, 2025
0bc5cd8
oops
dummdidumm Jan 25, 2025
3be38e4
fix
dummdidumm Jan 25, 2025
b05f960
see what happens if everything runs with server routing
dummdidumm Jan 25, 2025
5fd674f
fixes
dummdidumm Jan 27, 2025
56f7b71
temporarily adjust test
dummdidumm Jan 27, 2025
678cd28
adjust tests
dummdidumm Jan 27, 2025
98f10eb
fix
dummdidumm Jan 27, 2025
3e7211b
disallow adapter-static with resolution=server
dummdidumm Jan 27, 2025
733445d
adjust order of token updates; they need to happen before the first a…
dummdidumm Jan 27, 2025
8e53116
fix
dummdidumm Jan 27, 2025
bd29ba5
oops
dummdidumm Jan 27, 2025
b0653e0
prerender route resolution requests
dummdidumm Jan 27, 2025
a082fc2
create client routes for server as part of manifest already. This sav…
dummdidumm Jan 28, 2025
ef74189
run all of apps/basics in a separate github action with router.resol…
dummdidumm Jan 28, 2025
46507b1
lint
dummdidumm Jan 28, 2025
21cf0ce
feedback
dummdidumm Jan 28, 2025
1af6c1a
oops
dummdidumm Jan 28, 2025
fe0f25f
make server routing base path aware
dummdidumm Jan 28, 2025
38b14eb
here instead
dummdidumm Jan 28, 2025
7288023
simplify
dummdidumm Jan 28, 2025
453aa2a
make imports relative
dummdidumm Jan 28, 2025
5bced02
fix
dummdidumm Jan 28, 2025
42df818
lint
dummdidumm Jan 28, 2025
89aa78b
run options test suite in server routing mode, too
dummdidumm Jan 28, 2025
c6941e4
fix
dummdidumm Jan 28, 2025
24b8cd6
fix artifact upload
dummdidumm Jan 28, 2025
ec8b7fa
vercel adapter (hope this works)
dummdidumm Jan 28, 2025
7c2a23e
fix
dummdidumm Jan 28, 2025
f38a6d2
generate symlink before running server routing tests
dummdidumm Jan 28, 2025
24ff293
take base path into account
dummdidumm Jan 28, 2025
df5efb4
remove unnecessary validation, make sure route resolution is also sav…
dummdidumm Jan 28, 2025
8ffb62d
harmonize pathname endings before sending to resolution endpoint
dummdidumm Jan 28, 2025
c99a78b
take assets path into account when creating import paths during serve…
dummdidumm Jan 29, 2025
1528dfa
obsolete
dummdidumm Jan 29, 2025
2f53412
nomenclature
dummdidumm Jan 29, 2025
9e408f6
changeset
dummdidumm Jan 29, 2025
1bb22a1
Apply suggestions from code review
dummdidumm Jan 29, 2025
0337eed
don't include root error/layout if no routes
dummdidumm Jan 29, 2025
b458c81
regenerate types
dummdidumm Jan 29, 2025
7cf48b2
prettier
dummdidumm Jan 29, 2025
6a0ab67
Apply suggestions from code review
dummdidumm Jan 29, 2025
0342bb7
type generation
elliott-with-the-longest-name-on-github Jan 30, 2025
cfe0477
omit base path from route resolution endpoint
dummdidumm Jan 29, 2025
c9734fb
tidier output
dummdidumm Jan 29, 2025
5521417
make server route / route in hydrate clearer
dummdidumm Jan 29, 2025
56bd377
comment
dummdidumm Jan 29, 2025
d8362a7
Merge remote-tracking branch 'origin/main' into server-side-routing
dummdidumm Jan 30, 2025
ccb0f67
exclude default options from route resolution function
Rich-Harris Feb 1, 2025
88db9e6
Merge branch 'server-side-routing' of github.com:sveltejs/kit into se…
Rich-Harris Feb 1, 2025
a520b30
'routes' -> 'route', more consistent with endpoint naming conventions…
Rich-Harris Feb 1, 2025
6964355
use same naming convention as verb_data_suffix — prevents the need fo…
Rich-Harris Feb 1, 2025
bf3676a
make add_resolution_prefix operate on strings, tweak implementation
Rich-Harris Feb 1, 2025
6e74c74
simplify function signatures by making app_dir globally available
Rich-Harris Feb 1, 2025
3383fae
remove now-unused options.app_dir
Rich-Harris Feb 1, 2025
9cc37b4
pass pathname into resolution helpers, for parity with similar __data…
Rich-Harris Feb 1, 2025
c03c47a
move pathname manipulation functions into their own module
Rich-Harris Feb 1, 2025
4ce9070
move more stuff into pathname.js, DRY out
Rich-Harris Feb 1, 2025
24b6d13
simplify
Rich-Harris Feb 1, 2025
3f366cf
assets is always absolute, config validation will fail otherwise
Rich-Harris Feb 1, 2025
cec8994
lint
Rich-Harris Feb 1, 2025
3181914
unused
Rich-Harris Feb 1, 2025
b0bfc3c
lint
Rich-Harris Feb 1, 2025
b88fc3a
stricter interface
Rich-Harris Feb 1, 2025
b9156e7
use generate terminology in line with generate_manifest, which is a s…
Rich-Harris Feb 1, 2025
8b36c85
cosmetic change, to make route modules more readable
Rich-Harris Feb 1, 2025
27a8243
save a few bytes
Rich-Harris Feb 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -25,10 +26,12 @@ export function generate_manifest({ build_data, relative_path, routes }) {
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 @@ -57,7 +60,11 @@ export function generate_manifest({ build_data, relative_path, routes }) {
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 @@ -90,7 +97,7 @@ export function generate_manifest({ build_data, relative_path, routes }) {
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)
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved
.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