@bractjs/bractjs 0.1.28 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +98 -17
  2. package/package.json +8 -7
  3. package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
  4. package/src/__tests__/headers.test.ts +111 -0
  5. package/src/__tests__/integration.test.ts +34 -0
  6. package/src/__tests__/layout-registry.test.ts +7 -3
  7. package/src/__tests__/matcher.test.ts +29 -0
  8. package/src/__tests__/module-registry.test.ts +2 -3
  9. package/src/__tests__/route-lint.test.ts +5 -0
  10. package/src/__tests__/route-middleware.test.ts +84 -0
  11. package/src/__tests__/scanner.test.ts +46 -1
  12. package/src/__tests__/security-fixes.test.ts +201 -0
  13. package/src/__tests__/use-matches.test.ts +54 -0
  14. package/src/build/route-lint.ts +3 -3
  15. package/src/client/ClientRouter.tsx +118 -18
  16. package/src/client/hooks/useMatches.ts +32 -0
  17. package/src/client/router.tsx +7 -1
  18. package/src/client/rpc.ts +11 -1
  19. package/src/codegen/module-registry.ts +13 -21
  20. package/src/codegen/route-codegen.ts +8 -3
  21. package/src/config/load.ts +1 -0
  22. package/src/index.ts +11 -3
  23. package/src/server/action-handler.ts +1 -20
  24. package/src/server/adapter.ts +16 -0
  25. package/src/server/api-route.ts +47 -0
  26. package/src/server/csp.ts +9 -3
  27. package/src/server/csrf.ts +10 -3
  28. package/src/server/headers.ts +49 -0
  29. package/src/server/layout.ts +12 -19
  30. package/src/server/matcher.ts +29 -2
  31. package/src/server/matches.ts +50 -0
  32. package/src/server/middleware.ts +66 -0
  33. package/src/server/proto-guard.ts +56 -0
  34. package/src/server/render.ts +34 -16
  35. package/src/server/request-handler.ts +67 -27
  36. package/src/server/scanner.ts +45 -3
  37. package/src/server/search.ts +5 -1
  38. package/src/server/serve.ts +28 -3
  39. package/src/server/session.ts +12 -1
  40. package/src/server/validate.ts +4 -1
  41. package/src/shared/context.ts +3 -1
  42. package/src/shared/route-types.ts +108 -0
  43. package/types/config.d.ts +3 -0
  44. package/types/index.d.ts +17 -0
  45. package/types/route.d.ts +76 -1
@@ -1,5 +1,10 @@
1
1
  import { test, expect, describe } from "bun:test";
2
- import { filePathToPattern, pathToSegments } from "../server/scanner.ts";
2
+ import {
3
+ filePathToPattern,
4
+ pathToSegments,
5
+ layoutDirsFromFilePath,
6
+ isRouteGroupSegment,
7
+ } from "../server/scanner.ts";
3
8
 
4
9
  describe("filePathToPattern", () => {
5
10
  test("_index maps to empty pattern (root index)", () => {
@@ -25,6 +30,42 @@ describe("filePathToPattern", () => {
25
30
  test("strips .ts extension too", () => {
26
31
  expect(filePathToPattern("routes/api/data.ts")).toBe("api/data");
27
32
  });
33
+
34
+ test("route group folder adds no URL segment", () => {
35
+ expect(filePathToPattern("routes/(marketing)/about.tsx")).toBe("about");
36
+ });
37
+
38
+ test("nested route group strips only the group segment", () => {
39
+ expect(filePathToPattern("routes/(marketing)/blog/[id].tsx")).toBe("blog/[id]");
40
+ });
41
+
42
+ test("group wrapping the index → root pattern", () => {
43
+ expect(filePathToPattern("routes/(marketing)/_index.tsx")).toBe("");
44
+ });
45
+
46
+ test("[[id]] optional kept in pattern string", () => {
47
+ expect(filePathToPattern("routes/users/[[id]].tsx")).toBe("users/[[id]]");
48
+ });
49
+ });
50
+
51
+ describe("route groups", () => {
52
+ test("isRouteGroupSegment detects (group) but not () or plain", () => {
53
+ expect(isRouteGroupSegment("(marketing)")).toBe(true);
54
+ expect(isRouteGroupSegment("()")).toBe(false);
55
+ expect(isRouteGroupSegment("about")).toBe(false);
56
+ expect(isRouteGroupSegment("[id]")).toBe(false);
57
+ });
58
+
59
+ test("layoutDirsFromFilePath includes group folders", () => {
60
+ expect(layoutDirsFromFilePath("routes/(marketing)/blog/[id].tsx")).toEqual([
61
+ "(marketing)",
62
+ "(marketing)/blog",
63
+ ]);
64
+ });
65
+
66
+ test("layoutDirsFromFilePath for a top-level route → empty", () => {
67
+ expect(layoutDirsFromFilePath("routes/about.tsx")).toEqual([]);
68
+ });
28
69
  });
29
70
 
30
71
  describe("pathToSegments", () => {
@@ -44,6 +85,10 @@ describe("pathToSegments", () => {
44
85
  expect(pathToSegments("docs/[...slug]")).toEqual(["docs", { catchAll: "slug" }]);
45
86
  });
46
87
 
88
+ test("[[id]] → optional segment", () => {
89
+ expect(pathToSegments("users/[[id]]")).toEqual(["users", { optional: "id" }]);
90
+ });
91
+
47
92
  test("nested static path", () => {
48
93
  expect(pathToSegments("a/b/c")).toEqual(["a", "b", "c"]);
49
94
  });
@@ -0,0 +1,201 @@
1
+ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
2
+ import { createServer } from "../server/serve.ts";
3
+ import { pipeline } from "../server/middleware.ts";
4
+ import { route, handleApiRequest } from "../server/api-route.ts";
5
+ import { csp } from "../server/csp.ts";
6
+ import { hasForbiddenKey, nullProtoFromEntries } from "../server/proto-guard.ts";
7
+ import { searchParamsToObject } from "../server/search.ts";
8
+ import { validate } from "../server/validate.ts";
9
+ import { resolve } from "node:path";
10
+
11
+ const PORT = 3989;
12
+ const BASE = `http://localhost:${PORT}`;
13
+ const FIXTURE_APP = resolve(import.meta.dir, "fixtures/app");
14
+
15
+ // A marker middleware on the GLOBAL pipeline + a CSP middleware, plus a couple
16
+ // of API routes. Registered before the server starts; cleared afterwards so
17
+ // these don't leak into other suites sharing the process.
18
+ const MARKER = "X-Test-Global-MW";
19
+
20
+ let handle: ReturnType<typeof createServer>;
21
+
22
+ beforeAll(() => {
23
+ pipeline.clear();
24
+ pipeline.use(async (_ctx, next) => {
25
+ const res = await next();
26
+ res.headers.set(MARKER, "1");
27
+ return res;
28
+ });
29
+ pipeline.use(csp());
30
+
31
+ // Protected-by-default mutating route, an opted-out one, and a GET.
32
+ route("POST", "/api/secure", (input: unknown) => ({ ok: true, input }));
33
+ route("POST", "/api/public", (input: unknown) => ({ ok: true, input }), { csrf: false });
34
+ route("GET", "/api/ping", () => ({ pong: true }));
35
+
36
+ handle = createServer({
37
+ port: PORT,
38
+ appDir: FIXTURE_APP,
39
+ manifest: { clientEntry: "/build/client/client.js", routes: {} },
40
+ });
41
+ });
42
+
43
+ afterAll(() => {
44
+ handle.stop();
45
+ pipeline.clear();
46
+ });
47
+
48
+ // ── H-1 — global middleware now wraps the special endpoints ────────────────
49
+
50
+ describe("H-1: global middleware covers special endpoints", () => {
51
+ test("marker + CSP applied to an /api response", async () => {
52
+ const res = await fetch(`${BASE}/api/ping`);
53
+ expect(res.headers.get(MARKER)).toBe("1");
54
+ expect(res.headers.get("Content-Security-Policy")).toContain("default-src 'self'");
55
+ });
56
+
57
+ test("marker applied to /_action (even on 404 unknown id)", async () => {
58
+ const res = await fetch(`${BASE}/_action?id=deadbeefdeadbeef`, {
59
+ method: "POST",
60
+ headers: { "X-BractJS-Action": "1", "Content-Type": "application/json" },
61
+ body: "[]",
62
+ });
63
+ // unknown id → 404, but it still passed through the global pipeline.
64
+ expect(res.headers.get(MARKER)).toBe("1");
65
+ });
66
+
67
+ test("marker applied to /_image (even on 400 bad request)", async () => {
68
+ const res = await fetch(`${BASE}/_image?src=/etc/passwd`);
69
+ expect(res.headers.get(MARKER)).toBe("1");
70
+ });
71
+
72
+ test("marker applied to a normal SSR document too (no double-run)", async () => {
73
+ const res = await fetch(`${BASE}/`);
74
+ expect(res.headers.get(MARKER)).toBe("1");
75
+ // CSP header present exactly once.
76
+ expect(res.headers.get("Content-Security-Policy")).toContain("script-src");
77
+ });
78
+ });
79
+
80
+ // ── H-2 — CSRF on typed /api routes ────────────────────────────────────────
81
+
82
+ describe("H-2: /api CSRF protection", () => {
83
+ test("cross-site POST to a protected route → 403", async () => {
84
+ const res = await fetch(`${BASE}/api/secure`, {
85
+ method: "POST",
86
+ headers: { "Content-Type": "application/json", "Sec-Fetch-Site": "cross-site" },
87
+ body: JSON.stringify({ a: 1 }),
88
+ });
89
+ expect(res.status).toBe(403);
90
+ });
91
+
92
+ test("no-attribution POST (no Origin / no header / no Sec-Fetch-Site) → 403", async () => {
93
+ // Call the handler directly so no Origin is auto-added by fetch.
94
+ const req = new Request("http://localhost/api/secure", {
95
+ method: "POST",
96
+ headers: { "Content-Type": "application/json" },
97
+ body: JSON.stringify({ a: 1 }),
98
+ });
99
+ const res = await handleApiRequest(req);
100
+ expect(res?.status).toBe(403);
101
+ });
102
+
103
+ test("same-origin POST (X-BractJS-Action) to a protected route → 200", async () => {
104
+ const res = await fetch(`${BASE}/api/secure`, {
105
+ method: "POST",
106
+ headers: { "Content-Type": "application/json", "X-BractJS-Action": "1" },
107
+ body: JSON.stringify({ a: 1 }),
108
+ });
109
+ expect(res.status).toBe(200);
110
+ expect(await res.json()).toEqual({ ok: true, input: { a: 1 } });
111
+ });
112
+
113
+ test("same-origin POST via Sec-Fetch-Site → 200 (no custom header needed)", async () => {
114
+ const res = await fetch(`${BASE}/api/secure`, {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/json", "Sec-Fetch-Site": "same-origin" },
117
+ body: JSON.stringify({ a: 2 }),
118
+ });
119
+ expect(res.status).toBe(200);
120
+ });
121
+
122
+ test("opted-out route (csrf:false) allows cross-site POST", async () => {
123
+ const res = await fetch(`${BASE}/api/public`, {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/json", "Sec-Fetch-Site": "cross-site" },
126
+ body: JSON.stringify({ a: 3 }),
127
+ });
128
+ expect(res.status).toBe(200);
129
+ expect(await res.json()).toEqual({ ok: true, input: { a: 3 } });
130
+ });
131
+
132
+ test("GET /api is never CSRF-gated", async () => {
133
+ const res = await fetch(`${BASE}/api/ping`, { headers: { "Sec-Fetch-Site": "cross-site" } });
134
+ expect(res.status).toBe(200);
135
+ });
136
+
137
+ test("forbidden-key JSON body to an /api route → 400", async () => {
138
+ const res = await fetch(`${BASE}/api/secure`, {
139
+ method: "POST",
140
+ headers: { "Content-Type": "application/json", "X-BractJS-Action": "1" },
141
+ body: '{"__proto__":{"polluted":true}}',
142
+ });
143
+ expect(res.status).toBe(400);
144
+ // And Object.prototype was not polluted.
145
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined();
146
+ });
147
+ });
148
+
149
+ // ── M-1 — CSP form-action ──────────────────────────────────────────────────
150
+
151
+ describe("M-1: CSP defaults", () => {
152
+ test("default policy includes form-action 'self'", async () => {
153
+ const res = await fetch(`${BASE}/api/ping`);
154
+ expect(res.headers.get("Content-Security-Policy")).toContain("form-action 'self'");
155
+ });
156
+ });
157
+
158
+ // ── M-2 — prototype-pollution guards ───────────────────────────────────────
159
+
160
+ describe("M-2: proto-guard", () => {
161
+ test("hasForbiddenKey detects buried __proto__", () => {
162
+ let v: unknown = JSON.parse('{"__proto__":{"x":1}}');
163
+ for (let i = 0; i < 5; i++) v = { a: v };
164
+ expect(hasForbiddenKey(v)).toBe(true);
165
+ });
166
+
167
+ test("hasForbiddenKey passes a clean object", () => {
168
+ expect(hasForbiddenKey({ a: { b: { c: 1 } } })).toBe(false);
169
+ });
170
+
171
+ test("hasForbiddenKey fails closed past the scan depth", () => {
172
+ let v: unknown = { value: "x" };
173
+ for (let i = 0; i < 250; i++) v = { a: v };
174
+ expect(hasForbiddenKey(v)).toBe(true);
175
+ });
176
+
177
+ test("searchParamsToObject yields a null-prototype object", () => {
178
+ const out = searchParamsToObject(new URLSearchParams("__proto__=evil&a=1"));
179
+ expect(Object.getPrototypeOf(out)).toBeNull();
180
+ // __proto__ lands as a real own key, not a prototype mutation.
181
+ expect(out["__proto__"]).toBe("evil");
182
+ expect(({} as Record<string, unknown>).evil).toBeUndefined();
183
+ });
184
+
185
+ test("validate() over FormData with a __proto__ field does not pollute", async () => {
186
+ const fd = new FormData();
187
+ fd.set("__proto__", "evil");
188
+ fd.set("name", "ok");
189
+ // Identity schema — just returns what it gets.
190
+ const schema = { parse: (x: unknown) => x };
191
+ const out = (await validate(schema, fd)) as Record<string, unknown>;
192
+ expect(({} as Record<string, unknown>).evil).toBeUndefined();
193
+ expect(out.name).toBe("ok");
194
+ });
195
+
196
+ test("nullProtoFromEntries builds a null-prototype object", () => {
197
+ const out = nullProtoFromEntries([["__proto__", 1], ["a", 2]]);
198
+ expect(Object.getPrototypeOf(out)).toBeNull();
199
+ expect(out["a"]).toBe(2);
200
+ });
201
+ });
@@ -0,0 +1,54 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { buildMatches } from "../server/matches.ts";
3
+ import type { LayoutChain } from "../server/layout.ts";
4
+ import type { LoaderResults } from "../server/loader.ts";
5
+
6
+ describe("buildMatches", () => {
7
+ test("returns root → layouts → route in order with ids and data", () => {
8
+ const chain: LayoutChain = {
9
+ root: { handle: { breadcrumb: "Home" } },
10
+ layouts: [{ handle: { breadcrumb: "Blog" } }],
11
+ route: { handle: { breadcrumb: "Post" } },
12
+ files: { root: "root.tsx", layouts: ["routes/blog/layout.tsx"], route: "routes/blog/[id].tsx" },
13
+ };
14
+ const data: LoaderResults = { root: { user: "a" }, layouts: [{ posts: 2 }], route: { id: "7" } };
15
+
16
+ const matches = buildMatches(chain, data, { id: "7" }, "/blog/7");
17
+
18
+ expect(matches.map((m) => m.id)).toEqual([
19
+ "root.tsx",
20
+ "routes/blog/layout.tsx",
21
+ "routes/blog/[id].tsx",
22
+ ]);
23
+ expect(matches.map((m) => m.handle?.breadcrumb)).toEqual(["Home", "Blog", "Post"]);
24
+ expect(matches[2].data).toEqual({ id: "7" });
25
+ expect(matches[1].data).toEqual({ posts: 2 });
26
+ // params + pathname shared across the chain.
27
+ expect(matches.every((m) => m.pathname === "/blog/7")).toBe(true);
28
+ expect(matches.every((m) => m.params.id === "7")).toBe(true);
29
+ });
30
+
31
+ test("handle is undefined when a module does not export it", () => {
32
+ const chain: LayoutChain = {
33
+ root: {},
34
+ layouts: [],
35
+ route: { handle: { title: "x" } },
36
+ files: { root: "root.tsx", layouts: [], route: "routes/_index.tsx" },
37
+ };
38
+ const data: LoaderResults = { root: null, layouts: [], route: null };
39
+ const matches = buildMatches(chain, data, {}, "/");
40
+ expect(matches[0].handle).toBeUndefined();
41
+ expect(matches[1].handle).toEqual({ title: "x" });
42
+ });
43
+
44
+ test("falls back to synthetic ids when files metadata is absent", () => {
45
+ const chain: LayoutChain = {
46
+ root: {},
47
+ layouts: [{}],
48
+ route: {},
49
+ };
50
+ const data: LoaderResults = { root: null, layouts: [null], route: null };
51
+ const matches = buildMatches(chain, data, {}, "/x");
52
+ expect(matches.map((m) => m.id)).toEqual(["root", "layout:0", "route"]);
53
+ });
54
+ });
@@ -4,9 +4,9 @@ import { extractExports } from "./directives.ts";
4
4
  // (wrong case) of one of these is almost always a mistake — the framework's
5
5
  // projection is case-sensitive, so `Loader` is silently ignored.
6
6
  export const ROUTE_EXPORT_NAMES = [
7
- "default", "loader", "action", "meta", "beforeLoad", "shouldRevalidate",
8
- "searchSchema", "ssr", "Fallback", "handle", "ErrorBoundary", "config",
9
- "loaderDeps", "context",
7
+ "default", "loader", "action", "clientLoader", "clientAction", "meta", "headers",
8
+ "middleware", "beforeLoad", "shouldRevalidate", "searchSchema", "ssr", "Fallback",
9
+ "handle", "ErrorBoundary", "config", "loaderDeps", "context",
10
10
  ] as const;
11
11
 
12
12
  const CANONICAL_LOWER = new Map(ROUTE_EXPORT_NAMES.map((n) => [n.toLowerCase(), n]));
@@ -16,7 +16,7 @@ import { matchPatternForPath, toSamePath, parseTo, createLocationKey } from "./n
16
16
  import { loaderCache, cacheKey } from "./cache.ts";
17
17
  import { registerRevalidator, type RevalidationInfo } from "./revalidation.ts";
18
18
  import { MetaTags } from "../shared/meta-tags.tsx";
19
- import type { MetaDescriptor, RouterLocation, ShouldRevalidateFunction } from "../shared/route-types.ts";
19
+ import type { MetaDescriptor, RouterLocation, RouteMatch, ShouldRevalidateFunction } from "../shared/route-types.ts";
20
20
 
21
21
  // ── Types ──────────────────────────────────────────────────────────────────
22
22
 
@@ -47,6 +47,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
47
47
  const [params, setParams] = useState(initialData.params);
48
48
  const [location, setLocation] = useState<RouterLocation>(initialData.location);
49
49
  const [search, setSearch] = useState<Record<string, unknown>>(initialData.search ?? {});
50
+ const [matches, setMatches] = useState<RouteMatch[]>(initialData.matches ?? []);
50
51
  const [navState, setNavState] = useState<NavigationState>("idle");
51
52
  const [revalidationState, setRevalidationState] = useState<"idle" | "loading">("idle");
52
53
  const [currentModule, setCurrentModule] = useState<RouteModuleClient | null>(initialModule);
@@ -61,6 +62,8 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
61
62
  // Refs mirroring state that the stable revalidate/submit callbacks need.
62
63
  const locationRef = useRef(location);
63
64
  useEffect(() => { locationRef.current = location; }, [location]);
65
+ const paramsRef = useRef(params);
66
+ useEffect(() => { paramsRef.current = params; }, [params]);
64
67
  const currentModuleRef = useRef(currentModule);
65
68
  useEffect(() => { currentModuleRef.current = currentModule; }, [currentModule]);
66
69
 
@@ -69,6 +72,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
69
72
  if (state.actionData !== undefined) setActionData(state.actionData);
70
73
  if (state.params !== undefined) setParams(state.params);
71
74
  if (state.search !== undefined) setSearch(state.search);
75
+ if (state.matches !== undefined) setMatches(state.matches);
72
76
  if (state.location !== undefined) setLocation(state.location);
73
77
  else if (state.pathname !== undefined) {
74
78
  // Legacy callers pass a (possibly query-carrying) pathname string.
@@ -148,6 +152,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
148
152
  // React 19 hoists the <title>/<meta> elements rendered by <MetaTags>
149
153
  // into <head>, so description/OG tags update on soft navigation.
150
154
  setMeta((data.meta as MetaDescriptor[] | undefined) ?? []);
155
+ setMatches((data.matches as RouteMatch[] | undefined) ?? []);
151
156
  });
152
157
  };
153
158
 
@@ -199,6 +204,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
199
204
  setParams(((fresh as Record<string, unknown>).params as Record<string, string>) ?? {});
200
205
  setSearch(((fresh as Record<string, unknown>).search as Record<string, unknown>) ?? {});
201
206
  setMeta(((fresh as Record<string, unknown>).meta as MetaDescriptor[] | undefined) ?? []);
207
+ setMatches(((fresh as Record<string, unknown>).matches as RouteMatch[] | undefined) ?? []);
202
208
  });
203
209
  });
204
210
  return;
@@ -215,6 +221,27 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
215
221
  return;
216
222
  }
217
223
  const data = await res.json() as Record<string, unknown>;
224
+
225
+ // clientLoader (RR7-style): when the route exports one, it runs in the
226
+ // browser and its result replaces the route's loader slice. It receives a
227
+ // `serverLoader()` that resolves to the freshly-fetched server data, so a
228
+ // clientLoader can wrap/augment/cache it. Other slices (root/layouts) and
229
+ // the meta/matches payload are untouched.
230
+ const clientLoader = (routeModule as Record<string, unknown> | null)
231
+ ?.clientLoader as import("../shared/route-types.ts").ClientLoaderFunction | undefined;
232
+ if (typeof clientLoader === "function") {
233
+ try {
234
+ data.route = await clientLoader({
235
+ request: new Request(new URL(dataPath, window.location.origin)),
236
+ params: (data.params as Record<string, string>) ?? {},
237
+ search: (data.search as Record<string, unknown>) ?? {},
238
+ serverLoader: () => Promise.resolve(data.route),
239
+ });
240
+ } catch (err) {
241
+ console.error("[bractjs] clientLoader error:", err);
242
+ }
243
+ }
244
+
218
245
  if (staleTime > 0) loaderCache.set(key, data, staleTime, gcTime);
219
246
 
220
247
  // Update DevTools state (dev-only — no-op in prod since the import fails).
@@ -290,6 +317,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
290
317
  setParams((data.params as Record<string, string>) ?? {});
291
318
  setSearch((data.search as Record<string, unknown>) ?? {});
292
319
  setMeta((data.meta as MetaDescriptor[] | undefined) ?? []);
320
+ setMatches((data.matches as RouteMatch[] | undefined) ?? []);
293
321
  });
294
322
  } catch (err) {
295
323
  console.error("[bractjs] revalidate error:", err);
@@ -304,6 +332,39 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
304
332
  return () => registerRevalidator(null);
305
333
  }, [revalidate]);
306
334
 
335
+ // clientLoader.hydrate: for a fully-SSR'd route whose clientLoader opted into
336
+ // hydration, run it once after mount and replace the route's loader slice.
337
+ // Routes that didn't SSR (hydrationPending truthy) take the fetch path below,
338
+ // where clientLoader already applies via loadRoute on navigation; this effect
339
+ // is only for the first paint of an SSR document.
340
+ useEffect(() => {
341
+ if (hydrationPending) return;
342
+ const cl = (initialModule as Record<string, unknown> | null)
343
+ ?.clientLoader as import("../shared/route-types.ts").ClientLoaderFunction | undefined;
344
+ if (typeof cl !== "function" || cl.hydrate !== true) return;
345
+ let cancelled = false;
346
+ void (async () => {
347
+ const path = window.location.pathname + window.location.search;
348
+ const serverSlice = (initialData.loaderData as Record<string, unknown>)?.route;
349
+ try {
350
+ const next = await cl({
351
+ request: new Request(new URL(path, window.location.origin)),
352
+ params: initialData.params,
353
+ search: initialData.search ?? {},
354
+ serverLoader: async () => serverSlice,
355
+ });
356
+ if (cancelled) return;
357
+ startTransition(() => {
358
+ setLoaderData((prev) => ({ ...prev, route: next }));
359
+ });
360
+ } catch (err) {
361
+ console.error("[bractjs] clientLoader (hydrate) error:", err);
362
+ }
363
+ })();
364
+ return () => { cancelled = true; };
365
+ // eslint-disable-next-line react-hooks/exhaustive-deps
366
+ }, []);
367
+
307
368
  // Selective-SSR / SPA hydration completion. The first client render matched
308
369
  // the server (Fallback or empty shell); after mount, put loader data in
309
370
  // place and swap in the real component via a transition.
@@ -334,6 +395,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
334
395
  setParams((data.params as Record<string, string>) ?? {});
335
396
  setSearch((data.search as Record<string, unknown>) ?? {});
336
397
  setMeta((data.meta as MetaDescriptor[] | undefined) ?? []);
398
+ setMatches((data.matches as RouteMatch[] | undefined) ?? []);
337
399
  setHydrationPending(false);
338
400
  });
339
401
  return;
@@ -402,34 +464,72 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
402
464
  const body = opts.body instanceof FormData
403
465
  ? opts.body
404
466
  : new URLSearchParams(opts.body);
405
- const res = await fetch(to, {
406
- method: opts.method.toUpperCase(),
407
- body,
408
- headers: { "X-BractJS-Action": "1" },
409
- });
410
- if (res.redirected) {
411
- const safe = toSamePath(res.url);
412
- if (safe) {
413
- // navigate() loads fresh data for the target — no extra revalidation.
414
- await navigateRef.current(safe);
415
- return;
467
+
468
+ // The server submit — also the `serverAction()` a clientAction can call.
469
+ // A redirected response short-circuits to a real navigation (via
470
+ // toSamePath so an attacker Location can never soft-nav the SPA); it
471
+ // returns a sentinel so the caller stops.
472
+ const REDIRECTED = Symbol("redirected");
473
+ let lastStatus = 0;
474
+ const doServerPost = async (): Promise<unknown> => {
475
+ const res = await fetch(to, {
476
+ method: opts.method.toUpperCase(),
477
+ body,
478
+ headers: { "X-BractJS-Action": "1" },
479
+ });
480
+ lastStatus = res.status;
481
+ if (res.redirected) {
482
+ const safe = toSamePath(res.url);
483
+ if (safe) { await navigateRef.current(safe); return REDIRECTED; }
484
+ window.location.assign(res.url);
485
+ return REDIRECTED;
416
486
  }
417
- window.location.assign(res.url);
418
- return;
487
+ return res.json();
488
+ };
489
+
490
+ // clientAction (RR7-style): if the target route exports one, it runs in
491
+ // the browser and decides whether/how to hit the server via serverAction().
492
+ const [toPath] = to.split("?");
493
+ const pattern = matchPatternForPath(toPath, manifest);
494
+ const chunkUrl = pattern !== null ? manifest.routes[pattern]?.chunk : undefined;
495
+ let clientAction: import("../shared/route-types.ts").ClientActionFunction | undefined;
496
+ if (chunkUrl) {
497
+ try {
498
+ const mod = await import(/* @vite-ignore */ chunkUrl) as Record<string, unknown>;
499
+ if (typeof mod.clientAction === "function") {
500
+ clientAction = mod.clientAction as import("../shared/route-types.ts").ClientActionFunction;
501
+ }
502
+ } catch { /* fall back to a plain server submit */ }
419
503
  }
420
- const data = (await res.json()) as unknown;
504
+
505
+ let data: unknown;
506
+ if (clientAction) {
507
+ let calledServer = false;
508
+ data = await clientAction({
509
+ request: new Request(new URL(to, window.location.origin), { method: opts.method.toUpperCase() }),
510
+ params: paramsRef.current,
511
+ formData: body instanceof FormData ? body : new FormData(),
512
+ serverAction: () => { calledServer = true; return doServerPost(); },
513
+ });
514
+ // If the clientAction triggered a redirect via serverAction(), stop.
515
+ if (calledServer && data === REDIRECTED) return;
516
+ } else {
517
+ data = await doServerPost();
518
+ if (data === REDIRECTED) return;
519
+ }
520
+
421
521
  setActionData(data);
422
522
  setNavState("loading");
423
- await revalidate({ formMethod: opts.method, actionStatus: res.status });
523
+ await revalidate({ formMethod: opts.method, actionStatus: lastStatus });
424
524
  } finally {
425
525
  setNavState("idle");
426
526
  }
427
- }, [revalidate]);
527
+ }, [revalidate, manifest]);
428
528
 
429
529
  return (
430
530
  <RouterContext.Provider
431
531
  value={{
432
- loaderData, actionData, params, pathname: location.pathname, location, search,
532
+ loaderData, actionData, params, pathname: location.pathname, location, search, matches,
433
533
  manifest, currentModule, setRoute, revalidate, revalidationState, hydrationPending,
434
534
  }}
435
535
  >
@@ -0,0 +1,32 @@
1
+ import { useContext } from "react";
2
+ import { RouterContext } from "../router.tsx";
3
+ import { BractJSContext } from "../../shared/context.ts";
4
+ import type { RouteMatch } from "../../shared/route-types.ts";
5
+
6
+ /**
7
+ * Returns the matched route chain, outermost → innermost: the root, then each
8
+ * layout, then the leaf route. Each entry exposes `{ id, pathname, params,
9
+ * data, handle }`, where `handle` is that module's static `handle` export.
10
+ *
11
+ * Use it to build breadcrumbs or conditional chrome from `handle` without
12
+ * threading props through every layout. Works in both SSR and client contexts;
13
+ * the chain updates on soft navigation and revalidation.
14
+ *
15
+ * ```tsx
16
+ * // routes/blog/[id].tsx
17
+ * export const handle = { breadcrumb: "Post" };
18
+ *
19
+ * // some layout
20
+ * const crumbs = useMatches()
21
+ * .filter((m) => m.handle?.breadcrumb)
22
+ * .map((m) => m.handle!.breadcrumb as string);
23
+ * ```
24
+ *
25
+ * `handle` must be JSON-serializable — it travels in the SSR bootstrap and the
26
+ * `/_data` soft-nav payload, the same as loader data.
27
+ */
28
+ export function useMatches(): RouteMatch[] {
29
+ const router = useContext(RouterContext);
30
+ const bract = useContext(BractJSContext);
31
+ return router?.matches ?? bract?.matches ?? [];
32
+ }
@@ -1,6 +1,6 @@
1
1
  import { createContext, useContext, type ComponentType } from "react";
2
2
  import type { ServerManifest } from "../server/render.ts";
3
- import type { RouterLocation } from "../shared/route-types.ts";
3
+ import type { RouterLocation, RouteMatch } from "../shared/route-types.ts";
4
4
 
5
5
  // ── Route module shape visible on the client ───────────────────────────────
6
6
 
@@ -9,6 +9,10 @@ export interface RouteModuleClient {
9
9
  ErrorBoundary?: ComponentType<{ error: Error }>;
10
10
  /** SSR'd placeholder for selective-SSR routes (`ssr: false` / `"data-only"`). */
11
11
  Fallback?: ComponentType;
12
+ /** Browser-side loader (RR7-style). Runs on navigation instead of just fetching /_data. */
13
+ clientLoader?: import("../shared/route-types.ts").ClientLoaderFunction;
14
+ /** Browser-side action (RR7-style). Runs on submit instead of POSTing directly. */
15
+ clientAction?: import("../shared/route-types.ts").ClientActionFunction;
12
16
  }
13
17
 
14
18
  /**
@@ -30,6 +34,8 @@ export interface RouteState {
30
34
  location: RouterLocation;
31
35
  /** Validated search params (route `searchSchema` output; raw string record otherwise). */
32
36
  search: Record<string, unknown>;
37
+ /** The matched route chain (root → layouts → route) for `useMatches()`. */
38
+ matches: RouteMatch[];
33
39
  }
34
40
 
35
41
  export interface RouterContextValue extends RouteState {
package/src/client/rpc.ts CHANGED
@@ -49,9 +49,19 @@ export function createClient<
49
49
  const httpMethod = method.toUpperCase();
50
50
  const url = baseUrl + path;
51
51
  const hasBody = httpMethod !== "GET" && httpMethod !== "DELETE" && input !== undefined;
52
+ // Send the custom CSRF marker on every mutating call. The server's
53
+ // /api CSRF gate accepts Sec-Fetch-Site / Origin too, but this
54
+ // header keeps same-origin calls working even behind proxies that
55
+ // strip those — and it can't be set cross-origin without a CORS
56
+ // preflight the framework's CORS never grants. Mirrors the
57
+ // server-action proxy in src/build/directives.ts.
58
+ const isMutating = httpMethod !== "GET";
59
+ const headers: Record<string, string> = {};
60
+ if (hasBody) headers["Content-Type"] = "application/json";
61
+ if (isMutating) headers["X-BractJS-Action"] = "1";
52
62
  const res = await fetch(url, {
53
63
  method: httpMethod,
54
- headers: hasBody ? { "Content-Type": "application/json" } : undefined,
64
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
55
65
  body: hasBody ? JSON.stringify(input) : undefined,
56
66
  });
57
67
  if (!res.ok) {