Skip to content

Commit 6071077

Browse files
committed
feat: add server reroute hook
closes #13176
1 parent 461aa95 commit 6071077

File tree

23 files changed

+243
-7
lines changed

23 files changed

+243
-7
lines changed

documentation/docs/30-advanced/20-hooks.md

+32
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,33 @@ export async function handleFetch({ event, request, fetch }) {
147147
}
148148
```
149149

150+
### reroute
151+
152+
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.
153+
154+
In contrast to the [universal `reroute` hook](#universal-hooks-reroute), it
155+
156+
- is allowed to be async (though you should take extra caution to not do long running operations here, as it will delay navigation)
157+
- also receives headers and cookies (though you cannot modify them)
158+
159+
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`:
160+
161+
```js
162+
/// file: src/hooks.js
163+
// @errors: 2345
164+
// @errors: 2304
165+
166+
/** @type {import('@sveltejs/kit').Reroute} */
167+
export function reroute({ url, cookies }) {
168+
if (url.pathname === '/sale') {
169+
const variant = cookies.get('sales-variant') ?? 'variant-a';
170+
return `/sale/${variant}`;
171+
}
172+
}
173+
```
174+
175+
Using `reroute` will _not_ change the contents of the browser's address bar, or the value of `event.url`.
176+
150177
## Shared hooks
151178
152179
The following can be added to `src/hooks.server.js` _and_ `src/hooks.client.js`:
@@ -273,6 +300,11 @@ The following can be added to `src/hooks.js`. Universal hooks run on both server
273300
274301
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.
275302
303+
In contrast to the [server `reroute` hook](#server-hooks-reroute), it
304+
305+
- must be synchronous
306+
- only receives the URL
307+
276308
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`:
277309
278310
```js

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

+11-2
Original file line numberDiff line numberDiff line change
@@ -68,18 +68,27 @@ export async function get_hooks() {
6868
let handleFetch;
6969
let handleError;
7070
let init;
71-
${server_hooks ? `({ handle, handleFetch, handleError, init } = await import(${s(server_hooks)}));` : ''}
71+
let server_reroute;
72+
${server_hooks ? `({ handle, handleFetch, handleError, init, reroute: server_reroute } = await import(${s(server_hooks)}));` : ''}
7273
7374
let reroute;
7475
let transport;
7576
${universal_hooks ? `({ reroute, transport } = await import(${s(universal_hooks)}));` : ''}
7677
78+
if (server_reroute && reroute) {
79+
throw new Error('Cannot define "reroute" in both server hooks and universal hooks. Remove the function from one of the files.');
80+
}
81+
82+
if (server_reroute && ${config.kit.router.resolution === 'client'}) {
83+
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".');
84+
}
85+
7786
return {
7887
handle,
7988
handleFetch,
8089
handleError,
8190
init,
82-
reroute,
91+
reroute: server_reroute ?? reroute,
8392
transport
8493
};
8594
}

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

+14
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,20 @@ export type ClientInit = () => MaybePromise<void>;
816816
*/
817817
export type Reroute = (event: { url: URL }) => void | string;
818818

819+
/**
820+
* 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.
821+
* In contrast to the universal [`reroute`](https://svelte.dev/docs/kit/hooks#Universal-hooks-reroute) hook, it
822+
* - is allowed to be async (though you should take extra caution to not do long running operations here, as it will delay navigation)
823+
* - also receives headers and cookies (though you cannot modify them)
824+
*
825+
* @since 2.18.0
826+
*/
827+
export type ServerReroute = (event: {
828+
url: URL;
829+
headers: Omit<Headers, 'set' | 'delete' | 'append'>;
830+
cookies: { get: Cookies['get'] };
831+
}) => MaybePromise<void | string>;
832+
819833
/**
820834
* The [`transport`](https://svelte.dev/docs/kit/hooks#Universal-hooks-transport) hook allows you to transport custom types across the server/client boundary.
821835
*

packages/kit/src/runtime/server/page/server_routing.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,10 @@ export async function resolve_route(resolved_path, url, manifest) {
100100
*/
101101
export function create_server_routing_response(route, params, url, manifest) {
102102
const headers = new Headers({
103-
'content-type': 'application/javascript; charset=utf-8'
103+
'content-type': 'application/javascript; charset=utf-8',
104+
// Because we load this on the client via import('...') it's only requested once per session.
105+
// We make sure that it is not cached beyond that.
106+
'cache-control': 'no-store'
104107
});
105108

106109
if (route) {

packages/kit/src/runtime/server/respond.js

+7-2
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,13 @@ export async function respond(request, options, manifest, state) {
114114
let resolved_path;
115115

116116
try {
117-
// reroute could alter the given URL, so we pass a copy
118-
resolved_path = options.hooks.reroute({ url: new URL(url) }) ?? url.pathname;
117+
// reroute could alter the given arguments, so we pass copies
118+
resolved_path =
119+
(await options.hooks.reroute({
120+
url: new URL(url),
121+
headers: new Headers(request.headers),
122+
cookies: { get: get_cookies(request, url, 'never').cookies.get }
123+
})) ?? url.pathname;
119124
} catch {
120125
return text('Internal Server Error', {
121126
status: 500

packages/kit/src/types/internal.d.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import {
2020
Adapter,
2121
ServerInit,
2222
ClientInit,
23-
Transporter
23+
Transporter,
24+
ServerReroute
2425
} from '@sveltejs/kit';
2526
import {
2627
HttpMethod,
@@ -142,7 +143,7 @@ export interface ServerHooks {
142143
handleFetch: HandleFetch;
143144
handle: Handle;
144145
handleError: HandleServerError;
145-
reroute: Reroute;
146+
reroute: ServerReroute;
146147
transport: Record<string, Transporter>;
147148
init?: ServerInit;
148149
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "test-server-reroute-hook",
3+
"private": true,
4+
"version": "0.0.1",
5+
"scripts": {
6+
"dev": "vite dev",
7+
"build": "vite build",
8+
"preview": "vite preview",
9+
"prepare": "svelte-kit sync",
10+
"check": "svelte-kit sync && tsc && svelte-check",
11+
"test": "pnpm test:dev && pnpm test:build",
12+
"test:dev": "cross-env DEV=true playwright test",
13+
"test:build": "playwright test"
14+
},
15+
"devDependencies": {
16+
"@sveltejs/kit": "workspace:^",
17+
"@sveltejs/vite-plugin-svelte": "^5.0.1",
18+
"cross-env": "^7.0.3",
19+
"svelte": "^5.2.9",
20+
"svelte-check": "^4.1.1",
21+
"typescript": "^5.5.4",
22+
"vite": "^6.0.11"
23+
},
24+
"type": "module"
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { config as default } from '../../utils.js';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.png" />
7+
%sveltekit.head%
8+
</head>
9+
<body>
10+
<div style="display: contents">%sveltekit.body%</div>
11+
</body>
12+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export async function reroute({ url, headers, cookies }) {
2+
if (url.pathname === '/not-rerouted') {
3+
return;
4+
}
5+
6+
if (url.pathname === '/reroute') {
7+
await new Promise((resolve) => setTimeout(resolve, 100)); // simulate async
8+
return '/rerouted';
9+
}
10+
11+
if (headers.get('x-reroute')) {
12+
return '/rerouted-header';
13+
}
14+
15+
if (cookies.get('reroute')) {
16+
return '/rerouted-cookie';
17+
}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function load({ params, route, url }) {
2+
return {
3+
params,
4+
route,
5+
url: new URL(url)
6+
};
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script>
2+
import { setup } from '../../../../setup.js';
3+
4+
setup();
5+
6+
let { children } = $props();
7+
</script>
8+
9+
<a href="/">/</a>
10+
<a href="/reroute">/reroute</a>
11+
<a href="/somewhere">/somewhere</a>
12+
<a href="/not-rerouted">/not-rerouted</a>
13+
14+
{@render children()}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p>home</p>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p>not-rerouted</p>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p>rerouted-cookie</p>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p>rerouted-header</p>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p>rerouted</p>
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/** @type {import('@sveltejs/kit').Config} */
2+
const config = {
3+
kit: {
4+
router: { resolution: 'server' }
5+
}
6+
};
7+
8+
export default config;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { expect } from '@playwright/test';
2+
import { test } from '../../../utils.js';
3+
4+
/** @typedef {import('@playwright/test').Response} Response */
5+
6+
test.describe.configure({ mode: 'parallel' });
7+
8+
test.describe('server-side route resolution with server reroute', () => {
9+
test('can reroute based on header', async ({ page, context }) => {
10+
await page.goto('/');
11+
await expect(page.locator('p')).toHaveText('home');
12+
13+
context.setExtraHTTPHeaders({ 'x-reroute': 'true' });
14+
await page.locator('a[href="/somewhere"]').click();
15+
await expect(page.locator('p')).toHaveText('rerouted-header');
16+
});
17+
18+
test('can reroute based on cookie', async ({ page, context }) => {
19+
await page.goto('/');
20+
await expect(page.locator('p')).toHaveText('home');
21+
22+
await context.addCookies([{ name: 'reroute', value: 'true', path: '/', domain: 'localhost' }]);
23+
await page.locator('a[href="/somewhere"]').click();
24+
await expect(page.locator('p')).toHaveText('rerouted-cookie');
25+
});
26+
27+
test('can reroute based on pathname', async ({ page }) => {
28+
await page.goto('/');
29+
await expect(page.locator('p')).toHaveText('home');
30+
31+
await page.locator('a[href="/reroute"]').click();
32+
await expect(page.locator('p')).toHaveText('rerouted');
33+
34+
await page.locator('a[href="/not-rerouted"]').click();
35+
await expect(page.locator('p')).toHaveText('not-rerouted');
36+
});
37+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"compilerOptions": {
3+
"allowJs": true,
4+
"checkJs": true,
5+
"esModuleInterop": true,
6+
"noEmit": true,
7+
"resolveJsonModule": true
8+
},
9+
"extends": "./.svelte-kit/tsconfig.json"
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as path from 'node:path';
2+
import { sveltekit } from '@sveltejs/kit/vite';
3+
4+
/** @type {import('vite').UserConfig} */
5+
const config = {
6+
build: {
7+
minify: false
8+
},
9+
clearScreen: false,
10+
plugins: [sveltekit()],
11+
server: {
12+
fs: {
13+
allow: [path.resolve('../../../src')]
14+
}
15+
},
16+
optimizeDeps: {
17+
exclude: ['svelte']
18+
}
19+
};
20+
21+
export default config;

packages/kit/types/index.d.ts

+14
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,20 @@ declare module '@sveltejs/kit' {
798798
*/
799799
export type Reroute = (event: { url: URL }) => void | string;
800800

801+
/**
802+
* 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.
803+
* In contrast to the universal [`reroute`](https://svelte.dev/docs/kit/hooks#Universal-hooks-reroute) hook, it
804+
* - is allowed to be async (though you should take extra caution to not do long running operations here, as it will delay navigation)
805+
* - also receives headers and cookies (though you cannot modify them)
806+
*
807+
* @since 2.18.0
808+
*/
809+
export type ServerReroute = (event: {
810+
url: URL;
811+
headers: Omit<Headers, 'set' | 'delete' | 'append'>;
812+
cookies: { get: Cookies['get'] };
813+
}) => MaybePromise<void | string>;
814+
801815
/**
802816
* The [`transport`](https://svelte.dev/docs/kit/hooks#Universal-hooks-transport) hook allows you to transport custom types across the server/client boundary.
803817
*

0 commit comments

Comments
 (0)