Skip to content

feat: make handleFetch a shared hook #13755

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

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
5 changes: 5 additions & 0 deletions .changeset/two-dolphins-fry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: make handleFetch a shared hook
36 changes: 33 additions & 3 deletions documentation/docs/30-advanced/20-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,15 @@ export async function handle({ event, resolve }) {

Note that `resolve(...)` will never throw an error, it will always return a `Promise<Response>` with the appropriate status code. If an error is thrown elsewhere during `handle`, it is treated as fatal, and SvelteKit will respond with a JSON representation of the error or a fallback error page — which can be customised via `src/error.html` — depending on the `Accept` header. You can read more about error handling [here](errors).

## Shared hooks

The following can be added to `src/hooks.server.js` _and_ `src/hooks.client.js`:

### handleFetch

This function allows you to modify (or replace) a `fetch` request that happens inside a `load`, `action` or `handle` function that runs on the server (or during prerendering).
#### on the server

This function allows you to modify (or replace) a `fetch` request that happens inside a `load`, `action` or `handle` function that runs on the server (or during pre-rendering).

For example, your `load` function might make a request to a public URL like `https://api.yourapp.com` when the user performs a client-side navigation to the respective page, but during SSR it might make sense to hit the API directly (bypassing whatever proxies and load balancers sit between it and the public internet).

Expand Down Expand Up @@ -147,9 +153,33 @@ export async function handleFetch({ event, request, fetch }) {
}
```

## Shared hooks
#### on the client

This function allows you to modify (or replace) `fetch` requests that happens on the client.

This allows, for example, to pass custom headers to server when running `load` or `action` function on the server *(inside a `+page.server.ts` or `+layout.server.ts`)*, to automatically includes credentials to requests to your API or to collect logs or metrics.

*Note: on the client, the `event` argument is not passed to the hook.*


```js
/// file: src/hooks.client.js
/** @type {import('@sveltejs/kit').HandleClientFetch} */
export async function handleFetch({ request, fetch }) {
if (request.url.startsWith(location.origin)) {
request.headers.set('X-Auth-Token', 'my-custom-auth-token');
} else if (request.url.startsWith('https://api.my-domain.com/')) {
request.headers.set('Authorization', 'Bearer my-api-token');
}

console.time(`request: ${request.url}`);

return fetch(request).finally(() => {
console.timeEnd(`request: ${request.url}`);
});
}
```

The following can be added to `src/hooks.server.js` _and_ `src/hooks.client.js`:

### handleError

Expand Down
3 changes: 3 additions & 0 deletions packages/kit/src/core/sync/write_client_manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ export function write_client_manifest(kit, manifest_data, output, metadata) {
export const dictionary = ${dictionary};

export const hooks = {
handleFetch: ${
client_hooks_file ? 'client_hooks.handleFetch || ' : ''
}(({ request, fetch }) => fetch(request)),
handleError: ${
client_hooks_file ? 'client_hooks.handleError || ' : ''
}(({ error }) => { console.error(error) }),
Expand Down
10 changes: 9 additions & 1 deletion packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,14 +790,22 @@ export type HandleClientError = (input: {
}) => MaybePromise<void | App.Error>;

/**
* The [`handleFetch`](https://svelte.dev/docs/kit/hooks#Server-hooks-handleFetch) hook allows you to modify (or replace) a `fetch` request that happens inside a `load` function that runs on the server (or during prerendering).
* The [`handleFetch`](https://svelte.dev/docs/kit/hooks#Shared-hooks-handleFetch) hook allows you to modify (or replace) a `fetch` request that happens inside a `load` function that runs on the server (or during pre-rendering).
*/
export type HandleFetch = (input: {
event: RequestEvent;
request: Request;
fetch: typeof fetch;
}) => MaybePromise<Response>;

/**
* The [`handleFetch`](https://svelte.dev/docs/kit/hooks#Shared-hooks-handleFetch) hook allows you to modify (or replace) a `fetch` request that happens inside a `load` function that runs on the client
*/
export type HandleClientFetch = (input: {
request: Request;
fetch: typeof fetch;
}) => MaybePromise<Response>;

/**
* The [`init`](https://svelte.dev/docs/kit/hooks#Shared-hooks-init) will be invoked before the server responds to its first request
* @since 2.10.0
Expand Down
10 changes: 9 additions & 1 deletion packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ import {
make_trackable,
normalize_path
} from '../../utils/url.js';
import { dev_fetch, initial_fetch, lock_fetch, subsequent_fetch, unlock_fetch } from './fetcher.js';
import {
create_fetch,
dev_fetch,
initial_fetch,
lock_fetch,
subsequent_fetch,
unlock_fetch
} from './fetcher.js';
import { parse, parse_server_route } from './parse.js';
import * as storage from './session-storage.js';
import {
Expand Down Expand Up @@ -276,6 +283,7 @@ export async function start(_app, _target, hydrate) {
}

app = _app;
create_fetch(_app);

await _app.hooks.init?.();

Expand Down
155 changes: 101 additions & 54 deletions packages/kit/src/runtime/client/fetcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,66 +15,86 @@ export function unlock_fetch() {
loading -= 1;
}

if (DEV && BROWSER) {
let can_inspect_stack_trace = false;

// detect whether async stack traces work
// eslint-disable-next-line @typescript-eslint/require-await
const check_stack_trace = async () => {
const stack = /** @type {string} */ (new Error().stack);
can_inspect_stack_trace = stack.includes('check_stack_trace');
};

void check_stack_trace();

/**
* @param {import('./types.js').SvelteKitApp} app
*/
export function create_fetch(app) {
/**
* @param {RequestInfo | URL} input
* @param {RequestInit & Record<string, any> | undefined} init
* @type {typeof fetch}
*/
window.fetch = (input, init) => {
// Check if fetch was called via load_node. the lock method only checks if it was called at the
// same time, but not necessarily if it was called from `load`.
// We use just the filename as the method name sometimes does not appear on the CI.
const url = input instanceof Request ? input.url : input.toString();
const stack_array = /** @type {string} */ (new Error().stack).split('\n');
// We need to do a cutoff because Safari and Firefox maintain the stack
// across events and for example traces a `fetch` call triggered from a button
// back to the creation of the event listener and the element creation itself,
// where at some point client.js will show up, leading to false positives.
const cutoff = stack_array.findIndex((a) => a.includes('load@') || a.includes('at load'));
const stack = stack_array.slice(0, cutoff + 2).join('\n');

const in_load_heuristic = can_inspect_stack_trace
? stack.includes('src/runtime/client/client.js')
: loading;

// This flag is set in initial_fetch and subsequent_fetch
const used_kit_fetch = init?.__sveltekit_fetch__;

if (in_load_heuristic && !used_kit_fetch) {
console.warn(
`Loading ${url} using \`window.fetch\`. For best results, use the \`fetch\` that is passed to your \`load\` function: https://svelte.dev/docs/kit/load#making-fetch-requests`
);
}
let runtime_fetch;
if (DEV && BROWSER) {
let can_inspect_stack_trace = false;

// detect whether async stack traces work
// eslint-disable-next-line @typescript-eslint/require-await
const check_stack_trace = async () => {
const stack = /** @type {string} */ (new Error().stack);
can_inspect_stack_trace = stack.includes('check_stack_trace');
};

void check_stack_trace();

/**
* @param {RequestInfo | URL} input
* @param {RequestInit & Record<string, any> | undefined} init
*/
runtime_fetch = (input, init) => {
// Check if fetch was called via load_node. the lock method only checks if it was called at the
// same time, but not necessarily if it was called from `load`.
// We use just the filename as the method name sometimes does not appear on the CI.
const url = input instanceof Request ? input.url : input.toString();
const stack_array = /** @type {string} */ (new Error().stack).split('\n');
// We need to do a cutoff because Safari and Firefox maintain the stack
// across events and for example traces a `fetch` call triggered from a button
// back to the creation of the event listener and the element creation itself,
// where at some point client.js will show up, leading to false positives.
const cutoff = stack_array.findIndex((a) => a.includes('load@') || a.includes('at load'));
const stack = stack_array.slice(0, cutoff + 2).join('\n');

const in_load_heuristic = can_inspect_stack_trace
? stack.includes('src/runtime/client/client.js')
: loading;

// This flag is set in initial_fetch and subsequent_fetch
const used_kit_fetch = init?.__sveltekit_fetch__;

if (in_load_heuristic && !used_kit_fetch) {
console.warn(
`Loading ${url} using \`window.fetch\`. For best results, use the \`fetch\` that is passed to your \`load\` function: https://svelte.dev/docs/kit/load#making-fetch-requests`
);
}

const method = input instanceof Request ? input.method : init?.method || 'GET';
const method = input instanceof Request ? input.method : init?.method || 'GET';

if (method !== 'GET') {
cache.delete(build_selector(input));
}
if (method !== 'GET') {
cache.delete(build_selector(input));
}

return native_fetch(input, init);
};
} else if (BROWSER) {
window.fetch = (input, init) => {
const method = input instanceof Request ? input.method : init?.method || 'GET';
return native_fetch(input, init);
};
} else if (BROWSER) {
runtime_fetch = (input, init) => {
const method = input instanceof Request ? input.method : init?.method || 'GET';

if (method !== 'GET') {
cache.delete(build_selector(input));
}
if (method !== 'GET') {
cache.delete(build_selector(input));
}

return native_fetch(input, init);
};
}

if (BROWSER) {
window.fetch = async (input, init) => {
const original_request = normalize_fetch_input(input, init);

return native_fetch(input, init);
};
return app.hooks.handleFetch({
request: original_request,
fetch: runtime_fetch
});
};
}
}

const cache = new Map();
Expand Down Expand Up @@ -154,7 +174,7 @@ export function dev_fetch(resource, opts) {
* @param {RequestInit} [opts]
*/
function build_selector(resource, opts) {
const url = JSON.stringify(resource instanceof Request ? resource.url : resource);
const url = get_selector_url(resource);

let selector = `script[data-sveltekit-fetched][data-url=${url}]`;

Expand All @@ -175,3 +195,30 @@ function build_selector(resource, opts) {

return selector;
}

/**
* Build the cache url for a given request
* @param {URL | RequestInfo} resource
*/
function get_selector_url(resource) {
if (resource instanceof Request) {
resource = resource.url.startsWith(location.origin)
? resource.url.slice(location.origin.length)
: resource.url;
}

return JSON.stringify(resource);
}

/**
* @param {RequestInfo | URL} info
* @param {RequestInit | undefined} init
* @returns {Request}
*/
function normalize_fetch_input(info, init) {
if (info instanceof Request) {
return info;
}

return new Request(typeof info === 'string' ? new URL(info, location.href) : info, init);
}
2 changes: 2 additions & 0 deletions packages/kit/src/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ServerInitOptions,
HandleFetch,
Actions,
HandleClientFetch,
HandleClientError,
Reroute,
RequestEvent,
Expand Down Expand Up @@ -155,6 +156,7 @@ export interface ServerHooks {
}

export interface ClientHooks {
handleFetch: HandleClientFetch;
handleError: HandleClientError;
reroute: Reroute;
transport: Record<string, Transporter>;
Expand Down
2 changes: 2 additions & 0 deletions packages/kit/test/apps/client-fetch/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.custom-out-dir
!.env
25 changes: 25 additions & 0 deletions packages/kit/test/apps/client-fetch/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "test-embed",
"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.23.1",
"svelte-check": "^4.1.1",
"typescript": "^5.5.4",
"vite": "^6.2.6"
},
"type": "module"
}
1 change: 1 addition & 0 deletions packages/kit/test/apps/client-fetch/playwright.config.js
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/client-fetch/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>
7 changes: 7 additions & 0 deletions packages/kit/test/apps/client-fetch/src/hooks.client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const handleFetch = async ({ request, fetch }) => {
if (request.url.startsWith(location.origin)) {
request.headers.set('X-Client-Header', 'imtheclient');
}

return await fetch(request);
};
7 changes: 7 additions & 0 deletions packages/kit/test/apps/client-fetch/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script>
import { setup } from '../../../../setup.js';

setup();
</script>

<slot />
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<a href="/load" class="navigate-to-load">load</a>
7 changes: 7 additions & 0 deletions packages/kit/test/apps/client-fetch/src/routes/api/+server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { json } from '@sveltejs/kit';

export function GET({ request }) {
const header = request.headers.get('x-client-header') ?? 'empty';

return json({ header });
}
13 changes: 13 additions & 0 deletions packages/kit/test/apps/client-fetch/src/routes/fetch/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script>
import { onMount } from 'svelte';

let header = $state('loading');

onMount(async () => {
const res = await fetch('/api');
const data = await res.json();
header = data.header;
});
</script>

<div data-testid="header">{header}</div>
Loading
Loading