@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.
- package/README.md +98 -17
- package/package.json +8 -7
- package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
- package/src/__tests__/headers.test.ts +111 -0
- package/src/__tests__/integration.test.ts +34 -0
- package/src/__tests__/layout-registry.test.ts +7 -3
- package/src/__tests__/matcher.test.ts +29 -0
- package/src/__tests__/module-registry.test.ts +2 -3
- package/src/__tests__/route-lint.test.ts +5 -0
- package/src/__tests__/route-middleware.test.ts +84 -0
- package/src/__tests__/scanner.test.ts +46 -1
- package/src/__tests__/security-fixes.test.ts +201 -0
- package/src/__tests__/use-matches.test.ts +54 -0
- package/src/build/route-lint.ts +3 -3
- package/src/client/ClientRouter.tsx +118 -18
- package/src/client/hooks/useMatches.ts +32 -0
- package/src/client/router.tsx +7 -1
- package/src/client/rpc.ts +11 -1
- package/src/codegen/module-registry.ts +13 -21
- package/src/codegen/route-codegen.ts +8 -3
- package/src/config/load.ts +1 -0
- package/src/index.ts +11 -3
- package/src/server/action-handler.ts +1 -20
- package/src/server/adapter.ts +16 -0
- package/src/server/api-route.ts +47 -0
- package/src/server/csp.ts +9 -3
- package/src/server/csrf.ts +10 -3
- package/src/server/headers.ts +49 -0
- package/src/server/layout.ts +12 -19
- package/src/server/matcher.ts +29 -2
- package/src/server/matches.ts +50 -0
- package/src/server/middleware.ts +66 -0
- package/src/server/proto-guard.ts +56 -0
- package/src/server/render.ts +34 -16
- package/src/server/request-handler.ts +67 -27
- package/src/server/scanner.ts +45 -3
- package/src/server/search.ts +5 -1
- package/src/server/serve.ts +28 -3
- package/src/server/session.ts +12 -1
- package/src/server/validate.ts +4 -1
- package/src/shared/context.ts +3 -1
- package/src/shared/route-types.ts +108 -0
- package/types/config.d.ts +3 -0
- package/types/index.d.ts +17 -0
- package/types/route.d.ts +76 -1
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { test, expect, describe } from "bun:test";
|
|
2
|
-
import {
|
|
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
|
+
});
|
package/src/build/route-lint.ts
CHANGED
|
@@ -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", "
|
|
8
|
-
"
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|
package/src/client/router.tsx
CHANGED
|
@@ -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:
|
|
64
|
+
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
55
65
|
body: hasBody ? JSON.stringify(input) : undefined,
|
|
56
66
|
});
|
|
57
67
|
if (!res.ok) {
|