Skip to content

feat: add server reroute hook #13422

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

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 32 additions & 0 deletions documentation/docs/30-advanced/20-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,33 @@ export async function handleFetch({ event, request, fetch }) {
}
```

### reroute

This function runs before `handle` and allows you to change how URLs are translated into routes. The returned pathname (which defaults to `url.pathname`) is used to select the route and its parameters. In order to use this hook, you need to opt in to [server-side route resolution](configuration#router), which means a server request is made before each navigation in order to invoke the server `reroute` hook.

In contrast to the [universal `reroute` hook](#universal-hooks-reroute), it

- is allowed to be async (though you should take extra caution to not do long running operations here, as it will delay navigation)
- also receives headers and cookies (though you cannot modify them)

For example, you might have two variants of a page via `src/routes/sale/variant-a/+page.svelte` and `src/routes/sale/variant-b/+page.svelte`, which should be accessible as `/sale` and want to use a cookie to determine what variant of the sales page to load. You could implement this with `reroute`:

```js
/// file: src/hooks.js
// @errors: 2345
// @errors: 2304

/** @type {import('@sveltejs/kit').Reroute} */
export function reroute({ url, cookies }) {
if (url.pathname === '/sale') {
const variant = cookies.get('sales-variant') ?? 'variant-a';
return `/sale/${variant}`;
}
}
```

Using `reroute` will _not_ change the contents of the browser's address bar, or the value of `event.url`.

## Shared hooks

The following can be added to `src/hooks.server.js` _and_ `src/hooks.client.js`:
Expand Down Expand Up @@ -273,6 +300,11 @@ The following can be added to `src/hooks.js`. Universal hooks run on both server

This function runs before `handle` and allows you to change how URLs are translated into routes. The returned pathname (which defaults to `url.pathname`) is used to select the route and its parameters.

In contrast to the [server `reroute` hook](#server-hooks-reroute), it

- must be synchronous
- only receives the URL

For example, you might have a `src/routes/[[lang]]/about/+page.svelte` page, which should be accessible as `/en/about` or `/de/ueber-uns` or `/fr/a-propos`. You could implement this with `reroute`:

```js
Expand Down
13 changes: 11 additions & 2 deletions packages/kit/src/core/sync/write_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,27 @@ export async function get_hooks() {
let handleFetch;
let handleError;
let init;
${server_hooks ? `({ handle, handleFetch, handleError, init } = await import(${s(server_hooks)}));` : ''}
let server_reroute;
${server_hooks ? `({ handle, handleFetch, handleError, init, reroute: server_reroute } = await import(${s(server_hooks)}));` : ''}

let reroute;
let transport;
${universal_hooks ? `({ reroute, transport } = await import(${s(universal_hooks)}));` : ''}

if (server_reroute && reroute) {
throw new Error('Cannot define "reroute" in both server hooks and universal hooks. Remove the function from one of the files.');
}

if (server_reroute && ${config.kit.router.resolution === 'client'}) {
throw new Error('Cannot define "reroute" in server hooks when router.resolution is set to "client". Remove the function from the file, or set router.resolution to "server".');
}

return {
handle,
handleFetch,
handleError,
init,
reroute,
reroute: server_reroute ?? reroute,
transport
};
}
Expand Down
18 changes: 17 additions & 1 deletion packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,9 +672,11 @@ export interface KitConfig {
* - 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 `reroute` inside `hooks.server.js`, you _must_ use server-side route resolution.
*
* > [!NOTE] When using server-side route resolution and prerendering, the resolution is prerendered along with the route itself.
*
* @default "client"
Expand Down Expand Up @@ -816,6 +818,20 @@ export type ClientInit = () => MaybePromise<void>;
*/
export type Reroute = (event: { url: URL }) => void | string;

/**
* The [`reroute`](https://svelte.dev/docs/kit/hooks#Server-hooks-reroute) hook on the server allows you to modify the URL before it is used to determine which route to render.
* In contrast to the universal [`reroute`](https://svelte.dev/docs/kit/hooks#Universal-hooks-reroute) hook, it
* - is allowed to be async (though you should take extra caution to not do long running operations here, as it will delay navigation)
* - also receives headers and cookies (though you cannot modify them)
*
* @since 2.18.0
*/
export type ServerReroute = (event: {
url: URL;
headers: Omit<Headers, 'set' | 'delete' | 'append'>;
cookies: { get: Cookies['get'] };
}) => MaybePromise<void | string>;

/**
* The [`transport`](https://svelte.dev/docs/kit/hooks#Universal-hooks-transport) hook allows you to transport custom types across the server/client boundary.
*
Expand Down
5 changes: 4 additions & 1 deletion packages/kit/src/runtime/server/page/server_routing.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ export async function resolve_route(resolved_path, url, manifest) {
*/
export function create_server_routing_response(route, params, url, manifest) {
const headers = new Headers({
'content-type': 'application/javascript; charset=utf-8'
'content-type': 'application/javascript; charset=utf-8',
// Because we load this on the client via import('...') it's only requested once per session.
// We make sure that it is not cached beyond that.
'cache-control': 'no-store'
});

if (route) {
Expand Down
9 changes: 7 additions & 2 deletions packages/kit/src/runtime/server/respond.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,13 @@ export async function respond(request, options, manifest, state) {
let resolved_path;

try {
// reroute could alter the given URL, so we pass a copy
resolved_path = options.hooks.reroute({ url: new URL(url) }) ?? url.pathname;
// reroute could alter the given arguments, so we pass copies
resolved_path =
(await options.hooks.reroute({
url: new URL(url),
headers: new Headers(request.headers),
cookies: { get: get_cookies(request, url, 'never').cookies.get }
})) ?? url.pathname;
} catch {
return text('Internal Server Error', {
status: 500
Expand Down
5 changes: 3 additions & 2 deletions packages/kit/src/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import {
Adapter,
ServerInit,
ClientInit,
Transporter
Transporter,
ServerReroute
} from '@sveltejs/kit';
import {
HttpMethod,
Expand Down Expand Up @@ -142,7 +143,7 @@ export interface ServerHooks {
handleFetch: HandleFetch;
handle: Handle;
handleError: HandleServerError;
reroute: Reroute;
reroute: ServerReroute;
transport: Record<string, Transporter>;
init?: ServerInit;
}
Expand Down
25 changes: 25 additions & 0 deletions packages/kit/test/apps/server-reroute-hook/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "test-server-reroute-hook",
"private": true,
"version": "0.0.1",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync",
"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"
},
"devDependencies": {
"@sveltejs/kit": "workspace:^",
"@sveltejs/vite-plugin-svelte": "^5.0.1",
"cross-env": "^7.0.3",
"svelte": "^5.2.9",
"svelte-check": "^4.1.1",
"typescript": "^5.5.4",
"vite": "^6.0.11"
},
"type": "module"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { config as default } from '../../utils.js';
12 changes: 12 additions & 0 deletions packages/kit/test/apps/server-reroute-hook/src/app.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.png" />
%sveltekit.head%
</head>
<body>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
18 changes: 18 additions & 0 deletions packages/kit/test/apps/server-reroute-hook/src/hooks.server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export async function reroute({ url, headers, cookies }) {
if (url.pathname === '/not-rerouted') {
return;
}

if (url.pathname === '/reroute') {
await new Promise((resolve) => setTimeout(resolve, 100)); // simulate async
return '/rerouted';
}

if (headers.get('x-reroute')) {
return '/rerouted-header';
}

if (cookies.get('reroute')) {
return '/rerouted-cookie';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function load({ params, route, url }) {
return {
params,
route,
url: new URL(url)
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script>
import { setup } from '../../../../setup.js';

setup();

let { children } = $props();
</script>

<a href="/">/</a>
<a href="/reroute">/reroute</a>
<a href="/somewhere">/somewhere</a>
<a href="/not-rerouted">/not-rerouted</a>

{@render children()}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>home</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>not-rerouted</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>rerouted-cookie</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>rerouted-header</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>rerouted</p>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions packages/kit/test/apps/server-reroute-hook/svelte.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
router: { resolution: 'server' }
}
};

export default config;
37 changes: 37 additions & 0 deletions packages/kit/test/apps/server-reroute-hook/test/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect } from '@playwright/test';
import { test } from '../../../utils.js';

/** @typedef {import('@playwright/test').Response} Response */

test.describe.configure({ mode: 'parallel' });

test.describe('server-side route resolution with server reroute', () => {
test('can reroute based on header', async ({ page, context }) => {
await page.goto('/');
await expect(page.locator('p')).toHaveText('home');

context.setExtraHTTPHeaders({ 'x-reroute': 'true' });
await page.locator('a[href="/somewhere"]').click();
await expect(page.locator('p')).toHaveText('rerouted-header');
});

test('can reroute based on cookie', async ({ page, context }) => {
await page.goto('/');
await expect(page.locator('p')).toHaveText('home');

await context.addCookies([{ name: 'reroute', value: 'true', path: '/', domain: 'localhost' }]);
await page.locator('a[href="/somewhere"]').click();
await expect(page.locator('p')).toHaveText('rerouted-cookie');
});

test('can reroute based on pathname', async ({ page }) => {
await page.goto('/');
await expect(page.locator('p')).toHaveText('home');

await page.locator('a[href="/reroute"]').click();
await expect(page.locator('p')).toHaveText('rerouted');

await page.locator('a[href="/not-rerouted"]').click();
await expect(page.locator('p')).toHaveText('not-rerouted');
});
});
10 changes: 10 additions & 0 deletions packages/kit/test/apps/server-reroute-hook/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"noEmit": true,
"resolveJsonModule": true
},
"extends": "./.svelte-kit/tsconfig.json"
}
21 changes: 21 additions & 0 deletions packages/kit/test/apps/server-reroute-hook/vite.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as path from 'node:path';
import { sveltekit } from '@sveltejs/kit/vite';

/** @type {import('vite').UserConfig} */
const config = {
build: {
minify: false
},
clearScreen: false,
plugins: [sveltekit()],
server: {
fs: {
allow: [path.resolve('../../../src')]
}
},
optimizeDeps: {
exclude: ['svelte']
}
};

export default config;
18 changes: 17 additions & 1 deletion packages/kit/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,9 +654,11 @@ declare module '@sveltejs/kit' {
* - 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 `reroute` inside `hooks.server.js`, you _must_ use server-side route resolution.
*
* > [!NOTE] When using server-side route resolution and prerendering, the resolution is prerendered along with the route itself.
*
* @default "client"
Expand Down Expand Up @@ -798,6 +800,20 @@ declare module '@sveltejs/kit' {
*/
export type Reroute = (event: { url: URL }) => void | string;

/**
* The [`reroute`](https://svelte.dev/docs/kit/hooks#Server-hooks-reroute) hook on the server allows you to modify the URL before it is used to determine which route to render.
* In contrast to the universal [`reroute`](https://svelte.dev/docs/kit/hooks#Universal-hooks-reroute) hook, it
* - is allowed to be async (though you should take extra caution to not do long running operations here, as it will delay navigation)
* - also receives headers and cookies (though you cannot modify them)
*
* @since 2.18.0
*/
export type ServerReroute = (event: {
url: URL;
headers: Omit<Headers, 'set' | 'delete' | 'append'>;
cookies: { get: Cookies['get'] };
}) => MaybePromise<void | string>;

/**
* The [`transport`](https://svelte.dev/docs/kit/hooks#Universal-hooks-transport) hook allows you to transport custom types across the server/client boundary.
*
Expand Down
24 changes: 24 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading