Skip to content

Commit 906a2bb

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 87e5634 commit 906a2bb

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
@@ -74,6 +74,49 @@ For SPA routes, the initial HTML can still include the matched shell and an
7474
optional shell `Loading` export so the page is not blank before the route-state
7575
request resolves.
7676

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

79122
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
@@ -323,3 +323,44 @@ This is a pure header check — it doesn't issue or validate tokens. Pair it wit
323323
### 3. Token-based CSRF
324324

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

packages/framework/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export { useIsHydrated } from "./hydration.ts";
1414
export { Suspense, lazy } from "preact-suspense";
1515
export {
1616
applyDefaultSecurityHeaders,
17+
configureClient,
1718
Form,
1819
handlePrachtRequest,
1920
readHydrationState,
@@ -74,9 +75,11 @@ export type {
7475
PrachtAppConfig,
7576
} from "./types.ts";
7677
export type {
78+
ConfigureClientOptions,
7779
FormProps,
7880
HandlePrachtRequestOptions,
7981
Location,
82+
PrachtClientFetch,
8083
PrachtRuntimeDiagnosticPhase,
8184
PrachtRuntimeDiagnostics,
8285
RouteStateResult,

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,65 @@ export function parseSafeNavigationUrl(location: string, base: string | URL): UR
3131
return targetUrl;
3232
}
3333

34+
/**
35+
* A fetch implementation matching the standard Web fetch signature.
36+
*
37+
* Provided to {@link configureClient} to customize how the framework's
38+
* client runtime (navigation, revalidation, prefetch, `<Form>`) performs
39+
* HTTP requests — e.g. to forward an `Authorization` header from a
40+
* client-held token into loader requests.
41+
*/
42+
export type PrachtClientFetch = typeof fetch;
43+
44+
export interface ConfigureClientOptions {
45+
/**
46+
* Replacement fetch function used for every framework-initiated client
47+
* request: route-state fetches during navigation/revalidation/prefetch,
48+
* and `<Form>` submissions.
49+
*
50+
* The function receives the same `(input, init)` arguments as the
51+
* standard `fetch`. The framework sets its own required headers
52+
* (e.g. `x-pracht-route-state-request`) on `init.headers`; callers
53+
* should merge them when adding their own.
54+
*/
55+
fetch?: PrachtClientFetch;
56+
}
57+
58+
let configuredClientFetch: PrachtClientFetch | undefined;
59+
60+
/**
61+
* Configure the client runtime. Call this once at app startup (e.g. at the
62+
* top of your `src/routes.ts`) to install a custom fetch implementation
63+
* that every framework-initiated client request will flow through.
64+
*
65+
* Typical use is forwarding an `Authorization` header on client-side
66+
* navigations, revalidations, prefetches, and `<Form>` submissions so
67+
* server-side loaders can read it from `request.headers`.
68+
*
69+
* ```ts
70+
* configureClient({
71+
* fetch: (input, init) =>
72+
* fetch(input, {
73+
* ...init,
74+
* credentials: "include",
75+
* headers: {
76+
* ...(init?.headers as Record<string, string> | undefined),
77+
* Authorization: `Bearer ${getToken()}`,
78+
* },
79+
* }),
80+
* });
81+
* ```
82+
*
83+
* Passing `undefined` for `fetch` resets to the global `fetch`.
84+
*/
85+
export function configureClient(options: ConfigureClientOptions = {}): void {
86+
configuredClientFetch = options.fetch;
87+
}
88+
89+
export function getConfiguredClientFetch(): PrachtClientFetch {
90+
return configuredClientFetch ?? fetch;
91+
}
92+
3493
export function buildRouteStateUrl(url: string): string {
3594
const separator = url.includes("?") ? "&" : "?";
3695
return `${url}${separator}_data=1`;
@@ -41,7 +100,8 @@ export async function fetchPrachtRouteState(
41100
options?: { useDataParam?: boolean },
42101
): Promise<RouteStateResult> {
43102
const fetchUrl = options?.useDataParam ? buildRouteStateUrl(url) : url;
44-
const response = await fetch(fetchUrl, {
103+
const clientFetch = getConfiguredClientFetch();
104+
const response = await clientFetch(fetchUrl, {
45105
headers: options?.useDataParam
46106
? {}
47107
: { [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
@@ -8,7 +8,11 @@ import {
88
SAFE_METHODS,
99
} from "./runtime-constants.ts";
1010
import { deserializeRouteError, type SerializedRouteError } from "./runtime-errors.ts";
11-
import { fetchPrachtRouteState, navigateToClientLocation } from "./runtime-client-fetch.ts";
11+
import {
12+
fetchPrachtRouteState,
13+
getConfiguredClientFetch,
14+
navigateToClientLocation,
15+
} from "./runtime-client-fetch.ts";
1216
import type { RouteParams } from "./types.ts";
1317

1418
export interface PrachtHydrationState<TData = unknown> {
@@ -198,7 +202,8 @@ export function Form(props: FormProps) {
198202
}
199203

200204
event.preventDefault();
201-
const response = await fetch(props.action ?? form.action, {
205+
const clientFetch = getConfiguredClientFetch();
206+
const response = await clientFetch(props.action ?? form.action, {
202207
method: formMethod,
203208
body: new FormData(form),
204209
redirect: "manual",

packages/framework/src/runtime.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,10 @@ export {
445445
type StartAppOptions,
446446
} from "./runtime-hooks.ts";
447447
export {
448+
configureClient,
448449
fetchPrachtRouteState,
449450
parseSafeNavigationUrl,
451+
type ConfigureClientOptions,
452+
type PrachtClientFetch,
450453
type RouteStateResult,
451454
} 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)