Skip to content

Commit 2856ba3

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 410f6db commit 2856ba3

8 files changed

Lines changed: 298 additions & 4 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
@@ -69,6 +69,49 @@ For SPA routes, the initial HTML can still include the matched shell and an
6969
optional shell `Loading` export so the page is not blank before the route-state
7070
request resolves.
7171

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

74117
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
@@ -258,3 +258,44 @@ export const app = defineApp({
258258
- For OAuth flows, handle the callback in an API route (`src/api/auth/callback.ts`) that sets the session cookie and redirects.
259259
- For role-based access, extend the middleware to check permissions and return a `403` or redirect.
260260
- Never store passwords or secrets in loader data — it gets serialized to the client. Only return what the component needs.
261+
262+
---
263+
264+
## Bearer tokens instead of cookies
265+
266+
If your auth uses a short-lived bearer token kept on the client (e.g. returned
267+
from an OAuth token endpoint) rather than a session cookie, the token must be
268+
attached to every client-initiated route-state fetch — not just the initial
269+
SSR request. Use `configureClient()` at the top of `src/routes.ts` so the
270+
framework's own navigation, revalidation, prefetch, and `<Form>` fetches all
271+
forward it:
272+
273+
```ts [src/routes.ts]
274+
import { configureClient, defineApp } from "@pracht/core";
275+
import { getToken } from "./auth/token";
276+
277+
configureClient({
278+
fetch: (input, init) => {
279+
const token = getToken();
280+
return fetch(input, {
281+
...init,
282+
headers: {
283+
...(init?.headers as Record<string, string> | undefined),
284+
...(token ? { Authorization: `Bearer ${token}` } : {}),
285+
},
286+
});
287+
},
288+
});
289+
290+
export const app = defineApp({ /* ... */ });
291+
```
292+
293+
Loaders then read the header the same way on either the first SSR render or
294+
subsequent client navigations:
295+
296+
```ts
297+
export async function loader({ request }: LoaderArgs) {
298+
const token = request.headers.get("authorization")?.replace(/^Bearer /, "");
299+
// ...
300+
}
301+
```

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
@@ -6,6 +6,65 @@ export type RouteStateResult =
66
| { type: "redirect"; location: string }
77
| { type: "error"; error: SerializedRouteError };
88

9+
/**
10+
* A fetch implementation matching the standard Web fetch signature.
11+
*
12+
* Provided to {@link configureClient} to customize how the framework's
13+
* client runtime (navigation, revalidation, prefetch, `<Form>`) performs
14+
* HTTP requests — e.g. to forward an `Authorization` header from a
15+
* client-held token into loader requests.
16+
*/
17+
export type PrachtClientFetch = typeof fetch;
18+
19+
export interface ConfigureClientOptions {
20+
/**
21+
* Replacement fetch function used for every framework-initiated client
22+
* request: route-state fetches during navigation/revalidation/prefetch,
23+
* and `<Form>` submissions.
24+
*
25+
* The function receives the same `(input, init)` arguments as the
26+
* standard `fetch`. The framework sets its own required headers
27+
* (e.g. `x-pracht-route-state-request`) on `init.headers`; callers
28+
* should merge them when adding their own.
29+
*/
30+
fetch?: PrachtClientFetch;
31+
}
32+
33+
let configuredClientFetch: PrachtClientFetch | undefined;
34+
35+
/**
36+
* Configure the client runtime. Call this once at app startup (e.g. at the
37+
* top of your `src/routes.ts`) to install a custom fetch implementation
38+
* that every framework-initiated client request will flow through.
39+
*
40+
* Typical use is forwarding an `Authorization` header on client-side
41+
* navigations, revalidations, prefetches, and `<Form>` submissions so
42+
* server-side loaders can read it from `request.headers`.
43+
*
44+
* ```ts
45+
* configureClient({
46+
* fetch: (input, init) =>
47+
* fetch(input, {
48+
* ...init,
49+
* credentials: "include",
50+
* headers: {
51+
* ...(init?.headers as Record<string, string> | undefined),
52+
* Authorization: `Bearer ${getToken()}`,
53+
* },
54+
* }),
55+
* });
56+
* ```
57+
*
58+
* Passing `undefined` for `fetch` resets to the global `fetch`.
59+
*/
60+
export function configureClient(options: ConfigureClientOptions = {}): void {
61+
configuredClientFetch = options.fetch;
62+
}
63+
64+
export function getConfiguredClientFetch(): PrachtClientFetch {
65+
return configuredClientFetch ?? fetch;
66+
}
67+
968
export function buildRouteStateUrl(url: string): string {
1069
const separator = url.includes("?") ? "&" : "?";
1170
return `${url}${separator}_data=1`;
@@ -16,7 +75,8 @@ export async function fetchPrachtRouteState(
1675
options?: { useDataParam?: boolean },
1776
): Promise<RouteStateResult> {
1877
const fetchUrl = options?.useDataParam ? buildRouteStateUrl(url) : url;
19-
const response = await fetch(fetchUrl, {
78+
const clientFetch = getConfiguredClientFetch();
79+
const response = await clientFetch(fetchUrl, {
2080
headers: options?.useDataParam
2181
? {}
2282
: { [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: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,4 +433,10 @@ export {
433433
type PrachtHydrationState,
434434
type StartAppOptions,
435435
} from "./runtime-hooks.ts";
436-
export { fetchPrachtRouteState, type RouteStateResult } from "./runtime-client-fetch.ts";
436+
export {
437+
configureClient,
438+
fetchPrachtRouteState,
439+
type ConfigureClientOptions,
440+
type PrachtClientFetch,
441+
type RouteStateResult,
442+
} 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)