diff --git a/documentation/docs/30-advanced/20-hooks.md b/documentation/docs/30-advanced/20-hooks.md index b35e66b73a09..5d3345ec706e 100644 --- a/documentation/docs/30-advanced/20-hooks.md +++ b/documentation/docs/30-advanced/20-hooks.md @@ -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`: @@ -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 diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index 5e93d5c1cd25..a0273eaad3ee 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -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 }; } diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 06bb38fd768c..c48161e4dbda 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -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" @@ -816,6 +818,20 @@ export type ClientInit = () => MaybePromise; */ 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; + cookies: { get: Cookies['get'] }; +}) => MaybePromise; + /** * The [`transport`](https://svelte.dev/docs/kit/hooks#Universal-hooks-transport) hook allows you to transport custom types across the server/client boundary. * diff --git a/packages/kit/src/runtime/server/page/server_routing.js b/packages/kit/src/runtime/server/page/server_routing.js index 8f66fb8eb65d..e158744bca2c 100644 --- a/packages/kit/src/runtime/server/page/server_routing.js +++ b/packages/kit/src/runtime/server/page/server_routing.js @@ -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) { diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 429d523c3715..57551afa5d1b 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -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 diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index c5d2609fc006..246868807503 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -20,7 +20,8 @@ import { Adapter, ServerInit, ClientInit, - Transporter + Transporter, + ServerReroute } from '@sveltejs/kit'; import { HttpMethod, @@ -142,7 +143,7 @@ export interface ServerHooks { handleFetch: HandleFetch; handle: Handle; handleError: HandleServerError; - reroute: Reroute; + reroute: ServerReroute; transport: Record; init?: ServerInit; } diff --git a/packages/kit/test/apps/server-reroute-hook/package.json b/packages/kit/test/apps/server-reroute-hook/package.json new file mode 100644 index 000000000000..68690381d77b --- /dev/null +++ b/packages/kit/test/apps/server-reroute-hook/package.json @@ -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" +} diff --git a/packages/kit/test/apps/server-reroute-hook/playwright.config.js b/packages/kit/test/apps/server-reroute-hook/playwright.config.js new file mode 100644 index 000000000000..33d36b651014 --- /dev/null +++ b/packages/kit/test/apps/server-reroute-hook/playwright.config.js @@ -0,0 +1 @@ +export { config as default } from '../../utils.js'; diff --git a/packages/kit/test/apps/server-reroute-hook/src/app.html b/packages/kit/test/apps/server-reroute-hook/src/app.html new file mode 100644 index 000000000000..79d946ed86a3 --- /dev/null +++ b/packages/kit/test/apps/server-reroute-hook/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/packages/kit/test/apps/server-reroute-hook/src/hooks.server.js b/packages/kit/test/apps/server-reroute-hook/src/hooks.server.js new file mode 100644 index 000000000000..0c47f237a14d --- /dev/null +++ b/packages/kit/test/apps/server-reroute-hook/src/hooks.server.js @@ -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'; + } +} diff --git a/packages/kit/test/apps/server-reroute-hook/src/routes/+layout.js b/packages/kit/test/apps/server-reroute-hook/src/routes/+layout.js new file mode 100644 index 000000000000..2b6766631e95 --- /dev/null +++ b/packages/kit/test/apps/server-reroute-hook/src/routes/+layout.js @@ -0,0 +1,7 @@ +export function load({ params, route, url }) { + return { + params, + route, + url: new URL(url) + }; +} diff --git a/packages/kit/test/apps/server-reroute-hook/src/routes/+layout.svelte b/packages/kit/test/apps/server-reroute-hook/src/routes/+layout.svelte new file mode 100644 index 000000000000..d27a99a69e85 --- /dev/null +++ b/packages/kit/test/apps/server-reroute-hook/src/routes/+layout.svelte @@ -0,0 +1,14 @@ + + +/ +/reroute +/somewhere +/not-rerouted + +{@render children()} diff --git a/packages/kit/test/apps/server-reroute-hook/src/routes/+page.svelte b/packages/kit/test/apps/server-reroute-hook/src/routes/+page.svelte new file mode 100644 index 000000000000..0a88878c34c2 --- /dev/null +++ b/packages/kit/test/apps/server-reroute-hook/src/routes/+page.svelte @@ -0,0 +1 @@ +

home

diff --git a/packages/kit/test/apps/server-reroute-hook/src/routes/not-rerouted/+page.svelte b/packages/kit/test/apps/server-reroute-hook/src/routes/not-rerouted/+page.svelte new file mode 100644 index 000000000000..11b7b212e6bd --- /dev/null +++ b/packages/kit/test/apps/server-reroute-hook/src/routes/not-rerouted/+page.svelte @@ -0,0 +1 @@ +

not-rerouted

diff --git a/packages/kit/test/apps/server-reroute-hook/src/routes/rerouted-cookie/+page.svelte b/packages/kit/test/apps/server-reroute-hook/src/routes/rerouted-cookie/+page.svelte new file mode 100644 index 000000000000..c09f27daf1e4 --- /dev/null +++ b/packages/kit/test/apps/server-reroute-hook/src/routes/rerouted-cookie/+page.svelte @@ -0,0 +1 @@ +

rerouted-cookie

diff --git a/packages/kit/test/apps/server-reroute-hook/src/routes/rerouted-header/+page.svelte b/packages/kit/test/apps/server-reroute-hook/src/routes/rerouted-header/+page.svelte new file mode 100644 index 000000000000..c4775fd8e2f3 --- /dev/null +++ b/packages/kit/test/apps/server-reroute-hook/src/routes/rerouted-header/+page.svelte @@ -0,0 +1 @@ +

rerouted-header

diff --git a/packages/kit/test/apps/server-reroute-hook/src/routes/rerouted/+page.svelte b/packages/kit/test/apps/server-reroute-hook/src/routes/rerouted/+page.svelte new file mode 100644 index 000000000000..0ee3066bba03 --- /dev/null +++ b/packages/kit/test/apps/server-reroute-hook/src/routes/rerouted/+page.svelte @@ -0,0 +1 @@ +

rerouted

diff --git a/packages/kit/test/apps/server-reroute-hook/static/favicon.png b/packages/kit/test/apps/server-reroute-hook/static/favicon.png new file mode 100644 index 000000000000..825b9e65af7c Binary files /dev/null and b/packages/kit/test/apps/server-reroute-hook/static/favicon.png differ diff --git a/packages/kit/test/apps/server-reroute-hook/svelte.config.js b/packages/kit/test/apps/server-reroute-hook/svelte.config.js new file mode 100644 index 000000000000..cdede3d0b2ea --- /dev/null +++ b/packages/kit/test/apps/server-reroute-hook/svelte.config.js @@ -0,0 +1,8 @@ +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + router: { resolution: 'server' } + } +}; + +export default config; diff --git a/packages/kit/test/apps/server-reroute-hook/test/test.js b/packages/kit/test/apps/server-reroute-hook/test/test.js new file mode 100644 index 000000000000..c4c98036b9f4 --- /dev/null +++ b/packages/kit/test/apps/server-reroute-hook/test/test.js @@ -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'); + }); +}); diff --git a/packages/kit/test/apps/server-reroute-hook/tsconfig.json b/packages/kit/test/apps/server-reroute-hook/tsconfig.json new file mode 100644 index 000000000000..1d665886266b --- /dev/null +++ b/packages/kit/test/apps/server-reroute-hook/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "noEmit": true, + "resolveJsonModule": true + }, + "extends": "./.svelte-kit/tsconfig.json" +} diff --git a/packages/kit/test/apps/server-reroute-hook/vite.config.js b/packages/kit/test/apps/server-reroute-hook/vite.config.js new file mode 100644 index 000000000000..9640847b2704 --- /dev/null +++ b/packages/kit/test/apps/server-reroute-hook/vite.config.js @@ -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; diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 39247ec06dd7..8390df023eeb 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -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" @@ -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; + cookies: { get: Cookies['get'] }; + }) => MaybePromise; + /** * The [`transport`](https://svelte.dev/docs/kit/hooks#Universal-hooks-transport) hook allows you to transport custom types across the server/client boundary. * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e918a1764f38..fb11c262b1b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -647,6 +647,30 @@ importers: specifier: ^6.0.11 version: 6.0.11(@types/node@18.19.50)(lightningcss@1.24.1) + packages/kit/test/apps/server-reroute-hook: + devDependencies: + '@sveltejs/kit': + specifier: workspace:^ + version: link:../../.. + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.1 + version: 5.0.1(svelte@5.2.9)(vite@6.0.11(@types/node@18.19.50)(lightningcss@1.24.1)) + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + svelte: + specifier: ^5.2.9 + version: 5.2.9 + svelte-check: + specifier: ^4.1.1 + version: 4.1.1(picomatch@4.0.2)(svelte@5.2.9)(typescript@5.6.3) + typescript: + specifier: ^5.5.4 + version: 5.6.3 + vite: + specifier: ^6.0.11 + version: 6.0.11(@types/node@18.19.50)(lightningcss@1.24.1) + packages/kit/test/apps/writes: devDependencies: '@sveltejs/kit':