Skip to content

Commit c497d16

Browse files
claudeJoviDeCroock
authored andcommitted
Add configureClient fetch interceptor for client-side requests
Introduces configureClient({ fetch }) so apps can install a custom fetch used by every framework-initiated client request: route-state fetches on navigation, useRevalidate, prefetch, and <Form> submissions. Enables forwarding an Authorization header (e.g. a bearer token held on the client) so server-side loaders can read it from request.headers on both the initial SSR request and every subsequent client-triggered fetch.
1 parent 1c11385 commit c497d16

8 files changed

Lines changed: 294 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pracht/core": minor
3+
---
4+
5+
Add `configureClient({ fetch })` so apps can install a custom fetch implementation for every framework-initiated client request — route-state fetches during navigation, revalidation, and prefetch, as well as `<Form>` submissions. Enables forwarding auth headers (e.g. `Authorization: Bearer …`) to loaders on client-side navigations without losing them after initial SSR.

docs/DATA_LOADING.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,49 @@ For SPA routes, the initial HTML can still include the matched shell and an
7575
optional shell `Loading` export so the page is not blank before the route-state
7676
request resolves.
7777

78+
### Forwarding headers from the client
79+
80+
Pracht's client runtime issues its own fetches for:
81+
82+
- Navigation route-state fetches
83+
- `useRevalidate()` calls
84+
- Prefetching (hover / viewport / intent)
85+
- `<Form>` submissions
86+
87+
By default these fetches only set framework headers (e.g.
88+
`x-pracht-route-state-request`). If a loader needs to read a header that the
89+
browser does not attach automatically — most commonly `Authorization: Bearer …`
90+
from a client-held token — install a custom fetch with `configureClient()`:
91+
92+
```ts
93+
// src/routes.ts
94+
import { configureClient, defineApp } from "@pracht/core";
95+
96+
configureClient({
97+
fetch: (input, init) =>
98+
fetch(input, {
99+
...init,
100+
credentials: "include",
101+
headers: {
102+
...(init?.headers as Record<string, string> | undefined),
103+
Authorization: `Bearer ${getToken()}`,
104+
},
105+
}),
106+
});
107+
108+
export const app = defineApp({ /* ... */ });
109+
```
110+
111+
Because `src/routes.ts` is imported by the generated client entry before
112+
`initClientRouter` runs, the configuration is in place before the first
113+
client fetch. On the server, loaders can then read the header from
114+
`request.headers.get("authorization")` on both the initial SSR request and
115+
every subsequent client-triggered route-state fetch.
116+
117+
Cookies (used by the session-based auth recipe) continue to flow by default
118+
for same-origin deployments; set `credentials: "include"` as shown above only
119+
if you serve loaders from a cross-origin host.
120+
78121
### Error handling
79122

80123
Throw `PrachtHttpError` for structured error responses:

examples/docs/src/routes/docs/recipes-auth.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,3 +324,44 @@ This is a pure header check — it doesn't issue or validate tokens. Pair it wit
324324
### 3. Token-based CSRF
325325

326326
Only reach for per-request CSRF tokens if your cookies must be `SameSite=None` (e.g. you embed the app in a third-party iframe). For first-party apps, the two layers above are sufficient.
327+
328+
---
329+
330+
## Bearer tokens instead of cookies
331+
332+
If your auth uses a short-lived bearer token kept on the client (e.g. returned
333+
from an OAuth token endpoint) rather than a session cookie, the token must be
334+
attached to every client-initiated route-state fetch — not just the initial
335+
SSR request. Use `configureClient()` at the top of `src/routes.ts` so the
336+
framework's own navigation, revalidation, prefetch, and `<Form>` fetches all
337+
forward it:
338+
339+
```ts [src/routes.ts]
340+
import { configureClient, defineApp } from "@pracht/core";
341+
import { getToken } from "./auth/token";
342+
343+
configureClient({
344+
fetch: (input, init) => {
345+
const token = getToken();
346+
return fetch(input, {
347+
...init,
348+
headers: {
349+
...(init?.headers as Record<string, string> | undefined),
350+
...(token ? { Authorization: `Bearer ${token}` } : {}),
351+
},
352+
});
353+
},
354+
});
355+
356+
export const app = defineApp({ /* ... */ });
357+
```
358+
359+
Loaders then read the header the same way on either the first SSR render or
360+
subsequent client navigations:
361+
362+
```ts
363+
export async function loader({ request }: LoaderArgs) {
364+
const token = request.headers.get("authorization")?.replace(/^Bearer /, "");
365+
// ...
366+
}
367+
```

packages/framework/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export { useIsHydrated } from "./hydration.ts";
2828
export { Suspense, lazy } from "preact-suspense";
2929
export {
3030
applyDefaultSecurityHeaders,
31+
configureClient,
3132
Form,
3233
formatServerTimingHeader,
3334
Link,
@@ -112,13 +113,15 @@ export type {
112113
PrachtAppConfig,
113114
} from "./types.ts";
114115
export type {
116+
ConfigureClientOptions,
115117
FormProps,
116118
HandlePrachtRequestOptions,
117119
LinkProps,
118120
Location,
119121
Navigation,
120122
NavigationLocation,
121123
PrachtPhaseTimings,
124+
PrachtClientFetch,
122125
PrachtRuntimeDiagnosticPhase,
123126
PrachtRuntimeDiagnostics,
124127
RouteStateResult,

packages/framework/src/runtime-client-fetch.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,65 @@ export function routeNeedsServerFetch(route: ResolvedRoute): boolean {
3737
return true;
3838
}
3939

40+
/**
41+
* A fetch implementation matching the standard Web fetch signature.
42+
*
43+
* Provided to {@link configureClient} to customize how the framework's
44+
* client runtime (navigation, revalidation, prefetch, `<Form>`) performs
45+
* HTTP requests — e.g. to forward an `Authorization` header from a
46+
* client-held token into loader requests.
47+
*/
48+
export type PrachtClientFetch = typeof fetch;
49+
50+
export interface ConfigureClientOptions {
51+
/**
52+
* Replacement fetch function used for every framework-initiated client
53+
* request: route-state fetches during navigation/revalidation/prefetch,
54+
* and `<Form>` submissions.
55+
*
56+
* The function receives the same `(input, init)` arguments as the
57+
* standard `fetch`. The framework sets its own required headers
58+
* (e.g. `x-pracht-route-state-request`) on `init.headers`; callers
59+
* should merge them when adding their own.
60+
*/
61+
fetch?: PrachtClientFetch;
62+
}
63+
64+
let configuredClientFetch: PrachtClientFetch | undefined;
65+
66+
/**
67+
* Configure the client runtime. Call this once at app startup (e.g. at the
68+
* top of your `src/routes.ts`) to install a custom fetch implementation
69+
* that every framework-initiated client request will flow through.
70+
*
71+
* Typical use is forwarding an `Authorization` header on client-side
72+
* navigations, revalidations, prefetches, and `<Form>` submissions so
73+
* server-side loaders can read it from `request.headers`.
74+
*
75+
* ```ts
76+
* configureClient({
77+
* fetch: (input, init) =>
78+
* fetch(input, {
79+
* ...init,
80+
* credentials: "include",
81+
* headers: {
82+
* ...(init?.headers as Record<string, string> | undefined),
83+
* Authorization: `Bearer ${getToken()}`,
84+
* },
85+
* }),
86+
* });
87+
* ```
88+
*
89+
* Passing `undefined` for `fetch` resets to the global `fetch`.
90+
*/
91+
export function configureClient(options: ConfigureClientOptions = {}): void {
92+
configuredClientFetch = options.fetch;
93+
}
94+
95+
export function getConfiguredClientFetch(): PrachtClientFetch {
96+
return configuredClientFetch ?? fetch;
97+
}
98+
4099
export function buildRouteStateUrl(url: string): string {
41100
const separator = url.includes("?") ? "&" : "?";
42101
return `${url}${separator}_data=1`;
@@ -47,7 +106,8 @@ export async function fetchPrachtRouteState(
47106
options?: { signal?: AbortSignal; useDataParam?: boolean },
48107
): Promise<RouteStateResult> {
49108
const fetchUrl = options?.useDataParam ? buildRouteStateUrl(url) : url;
50-
const response = await fetch(fetchUrl, {
109+
const clientFetch = getConfiguredClientFetch();
110+
const response = await clientFetch(fetchUrl, {
51111
headers: options?.useDataParam
52112
? {}
53113
: { [ROUTE_STATE_REQUEST_HEADER]: "1", "Cache-Control": "no-cache" },

packages/framework/src/runtime-hooks.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ import {
2727
} from "./runtime-context.ts";
2828
import { clearPrefetchCache } from "./prefetch-cache.ts";
2929
import { deserializeRouteError } from "./runtime-errors.ts";
30-
import { fetchPrachtRouteState, navigateToClientLocation } from "./runtime-client-fetch.ts";
30+
import {
31+
fetchPrachtRouteState,
32+
getConfiguredClientFetch,
33+
navigateToClientLocation,
34+
} from "./runtime-client-fetch.ts";
3135
import type {
3236
LinkPrefetchStrategy,
3337
LoaderData,
@@ -198,7 +202,8 @@ export function Form(props: FormProps) {
198202
formData,
199203
);
200204
try {
201-
const response = await fetch(actionUrl, {
205+
const clientFetch = getConfiguredClientFetch();
206+
const response = await clientFetch(actionUrl, {
202207
method: formMethod,
203208
body: formData,
204209
redirect: "manual",

packages/framework/src/runtime.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,7 +637,10 @@ export {
637637
type StartAppOptions,
638638
} from "./runtime-hooks.ts";
639639
export {
640+
configureClient,
640641
fetchPrachtRouteState,
641642
parseSafeNavigationUrl,
643+
type ConfigureClientOptions,
644+
type PrachtClientFetch,
642645
type RouteStateResult,
643646
} from "./runtime-client-fetch.ts";
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// @vitest-environment jsdom
2+
import { h, render } from "preact";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
5+
import { configureClient, Form } from "../src/index.ts";
6+
import { fetchPrachtRouteState } from "../src/runtime.ts";
7+
8+
function createJsonResponse(body: unknown, init?: ResponseInit): Response {
9+
return new Response(JSON.stringify(body), {
10+
headers: {
11+
"content-type": "application/json",
12+
},
13+
...init,
14+
});
15+
}
16+
17+
describe("configureClient", () => {
18+
afterEach(() => {
19+
// Reset to default (global fetch) between tests.
20+
configureClient({ fetch: undefined });
21+
vi.unstubAllGlobals();
22+
vi.restoreAllMocks();
23+
});
24+
25+
it("routes fetchPrachtRouteState through the configured fetch", async () => {
26+
const customFetch = vi.fn(async () => createJsonResponse({ data: { hello: "world" } }));
27+
28+
configureClient({ fetch: customFetch as unknown as typeof fetch });
29+
30+
const result = await fetchPrachtRouteState("/foo");
31+
32+
expect(customFetch).toHaveBeenCalledTimes(1);
33+
expect(customFetch).toHaveBeenCalledWith(
34+
"/foo",
35+
expect.objectContaining({
36+
headers: expect.objectContaining({
37+
"Cache-Control": "no-cache",
38+
"x-pracht-route-state-request": "1",
39+
}),
40+
redirect: "manual",
41+
}),
42+
);
43+
expect(result).toEqual({ type: "data", data: { hello: "world" } });
44+
});
45+
46+
it("falls back to the global fetch when no configuration is set", async () => {
47+
const globalFetch = vi.fn(async () => createJsonResponse({ data: 1 }));
48+
vi.stubGlobal("fetch", globalFetch);
49+
50+
await fetchPrachtRouteState("/bar");
51+
52+
expect(globalFetch).toHaveBeenCalledTimes(1);
53+
});
54+
55+
it("resets to the global fetch when fetch: undefined is passed", async () => {
56+
const customFetch = vi.fn(async () => createJsonResponse({ data: 1 }));
57+
const globalFetch = vi.fn(async () => createJsonResponse({ data: 2 }));
58+
vi.stubGlobal("fetch", globalFetch);
59+
60+
configureClient({ fetch: customFetch as unknown as typeof fetch });
61+
await fetchPrachtRouteState("/a");
62+
expect(customFetch).toHaveBeenCalledTimes(1);
63+
expect(globalFetch).not.toHaveBeenCalled();
64+
65+
configureClient({ fetch: undefined });
66+
await fetchPrachtRouteState("/b");
67+
expect(customFetch).toHaveBeenCalledTimes(1);
68+
expect(globalFetch).toHaveBeenCalledTimes(1);
69+
});
70+
71+
it("forwards an Authorization header injected by the configured fetch to client navigation fetches", async () => {
72+
const seenHeaders: Array<Record<string, string>> = [];
73+
const customFetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
74+
seenHeaders.push((init?.headers as Record<string, string>) ?? {});
75+
return createJsonResponse({ data: null });
76+
});
77+
78+
configureClient({
79+
fetch: (input, init) => {
80+
const headers = {
81+
...(init?.headers as Record<string, string> | undefined),
82+
Authorization: "Bearer token-123",
83+
};
84+
return customFetch(input, { ...init, headers });
85+
},
86+
});
87+
88+
await fetchPrachtRouteState("/baz");
89+
90+
expect(seenHeaders[0]).toMatchObject({
91+
Authorization: "Bearer token-123",
92+
"x-pracht-route-state-request": "1",
93+
"Cache-Control": "no-cache",
94+
});
95+
});
96+
97+
it("routes <Form> submissions through the configured fetch", async () => {
98+
const customFetch = vi.fn<typeof fetch>(async () => new Response(null, { status: 204 }));
99+
configureClient({ fetch: customFetch });
100+
101+
const root = document.createElement("div");
102+
document.body.appendChild(root);
103+
104+
try {
105+
render(
106+
h(
107+
Form,
108+
{ action: "/api/do", method: "post" },
109+
h("input", { name: "x", defaultValue: "y" }),
110+
h("button", { type: "submit" }, "Go"),
111+
),
112+
root,
113+
);
114+
115+
const form = root.querySelector("form")!;
116+
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
117+
// let the microtask queue settle
118+
await Promise.resolve();
119+
await Promise.resolve();
120+
121+
expect(customFetch).toHaveBeenCalledTimes(1);
122+
const [url, init] = customFetch.mock.calls[0]!;
123+
expect(url).toBe("/api/do");
124+
expect(init?.method).toBe("POST");
125+
expect(init?.redirect).toBe("manual");
126+
} finally {
127+
render(null, root);
128+
root.remove();
129+
}
130+
});
131+
});

0 commit comments

Comments
 (0)