@bractjs/bractjs 0.1.27 → 0.1.29

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 (117) hide show
  1. package/bin/cli.ts +18 -1
  2. package/package.json +3 -2
  3. package/src/__tests__/codegen-write.test.ts +67 -0
  4. package/src/__tests__/codegen.test.ts +29 -2
  5. package/src/__tests__/compile-safety.test.ts +4 -0
  6. package/src/__tests__/csp.test.ts +10 -0
  7. package/src/__tests__/define-actions.test.ts +69 -0
  8. package/src/__tests__/env.test.ts +18 -0
  9. package/src/__tests__/fetcher-store.test.ts +67 -0
  10. package/src/__tests__/fixtures/app/root.tsx +7 -2
  11. package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
  12. package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
  13. package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
  14. package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
  15. package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
  16. package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
  17. package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
  18. package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
  19. package/src/__tests__/form-data-helpers.test.ts +43 -0
  20. package/src/__tests__/headers.test.ts +111 -0
  21. package/src/__tests__/integration.test.ts +90 -0
  22. package/src/__tests__/layout-registry.test.ts +7 -3
  23. package/src/__tests__/loader.test.ts +32 -1
  24. package/src/__tests__/matcher.test.ts +29 -0
  25. package/src/__tests__/module-registry.test.ts +2 -3
  26. package/src/__tests__/nav-utils.test.ts +46 -0
  27. package/src/__tests__/prerender.test.ts +102 -0
  28. package/src/__tests__/programmatic-api.test.ts +20 -1
  29. package/src/__tests__/revalidation.test.ts +65 -0
  30. package/src/__tests__/route-lint.test.ts +79 -0
  31. package/src/__tests__/route-middleware.test.ts +84 -0
  32. package/src/__tests__/route-table.test.ts +33 -0
  33. package/src/__tests__/safe-validate.test.ts +96 -0
  34. package/src/__tests__/scanner.test.ts +46 -1
  35. package/src/__tests__/scroll-restoration.test.ts +66 -0
  36. package/src/__tests__/search-serializer.test.ts +42 -0
  37. package/src/__tests__/search-validation.test.ts +125 -0
  38. package/src/__tests__/security-fixes.test.ts +201 -0
  39. package/src/__tests__/security.test.ts +110 -1
  40. package/src/__tests__/selective-ssr.test.ts +85 -0
  41. package/src/__tests__/spa-mode.test.ts +77 -0
  42. package/src/__tests__/typed-routing.test.ts +51 -1
  43. package/src/__tests__/use-matches.test.ts +54 -0
  44. package/src/build/bundler.ts +33 -0
  45. package/src/build/prerender.ts +88 -0
  46. package/src/build/route-lint.ts +49 -0
  47. package/src/client/ClientRouter.tsx +339 -47
  48. package/src/client/cache.ts +8 -0
  49. package/src/client/components/Await.tsx +9 -2
  50. package/src/client/components/Form.tsx +23 -34
  51. package/src/client/components/Link.tsx +80 -9
  52. package/src/client/components/Outlet.tsx +8 -2
  53. package/src/client/components/ScrollRestoration.tsx +125 -0
  54. package/src/client/entry.tsx +39 -2
  55. package/src/client/fetcher-store.ts +61 -0
  56. package/src/client/form-utils.ts +3 -0
  57. package/src/client/hooks/useActionData.ts +7 -3
  58. package/src/client/hooks/useFetcher.ts +116 -33
  59. package/src/client/hooks/useFetchers.ts +23 -0
  60. package/src/client/hooks/useLoaderData.ts +8 -4
  61. package/src/client/hooks/useLocation.ts +27 -0
  62. package/src/client/hooks/useMatches.ts +32 -0
  63. package/src/client/hooks/useNavigate.ts +11 -6
  64. package/src/client/hooks/useRevalidator.ts +26 -0
  65. package/src/client/hooks/useSearch.ts +73 -0
  66. package/src/client/hooks/useSearchParams.ts +7 -2
  67. package/src/client/nav-utils.ts +26 -0
  68. package/src/client/prefetch.ts +110 -15
  69. package/src/client/registry.ts +24 -0
  70. package/src/client/revalidation.ts +25 -0
  71. package/src/client/router.tsx +34 -1
  72. package/src/client/rpc.ts +11 -1
  73. package/src/client/scroll-restoration.ts +48 -0
  74. package/src/client/search-serializer.ts +40 -0
  75. package/src/client/types.ts +6 -0
  76. package/src/codegen/module-registry.ts +13 -21
  77. package/src/codegen/route-codegen.ts +148 -10
  78. package/src/config/load.ts +22 -0
  79. package/src/dev/hmr-client.ts +3 -1
  80. package/src/dev/route-table.ts +27 -0
  81. package/src/dev/server.ts +106 -8
  82. package/src/dev/watcher.ts +25 -3
  83. package/src/index.ts +38 -6
  84. package/src/server/action-handler.ts +3 -13
  85. package/src/server/action-registry.ts +35 -0
  86. package/src/server/adapter.ts +16 -0
  87. package/src/server/api-route.ts +47 -0
  88. package/src/server/csp.ts +19 -4
  89. package/src/server/csrf.ts +36 -3
  90. package/src/server/env.ts +26 -5
  91. package/src/server/headers.ts +49 -0
  92. package/src/server/layout.ts +43 -20
  93. package/src/server/loader.ts +14 -8
  94. package/src/server/matcher.ts +29 -2
  95. package/src/server/matches.ts +50 -0
  96. package/src/server/middleware.ts +66 -0
  97. package/src/server/proto-guard.ts +56 -0
  98. package/src/server/render.ts +51 -18
  99. package/src/server/request-handler.ts +111 -29
  100. package/src/server/scanner.ts +45 -3
  101. package/src/server/search.ts +47 -0
  102. package/src/server/serve.ts +116 -4
  103. package/src/server/session.ts +12 -1
  104. package/src/server/spa.ts +62 -0
  105. package/src/server/stream-handler.ts +10 -1
  106. package/src/server/validate.ts +89 -14
  107. package/src/shared/context.ts +7 -0
  108. package/src/shared/define-actions.ts +39 -0
  109. package/src/shared/form-data.ts +34 -0
  110. package/src/shared/route-types.ts +191 -2
  111. package/templates/new-app/app/root.tsx +2 -1
  112. package/templates/new-app/bractjs.config.ts +7 -12
  113. package/types/config.d.ts +24 -0
  114. package/types/index.d.ts +182 -9
  115. package/types/route.d.ts +138 -3
  116. package/LICENSE +0 -21
  117. package/README.md +0 -1125
@@ -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
+ });
@@ -1,4 +1,4 @@
1
- import { test, expect, describe, beforeAll, afterAll } from "bun:test";
1
+ import { test, expect, describe, beforeAll, afterAll, spyOn } from "bun:test";
2
2
  import { mkdir, rm, writeFile, symlink } from "node:fs/promises";
3
3
  import { resolve, join, relative, isAbsolute } from "node:path";
4
4
  import { tmpdir } from "node:os";
@@ -104,6 +104,48 @@ describe("action-handler — arg validation", () => {
104
104
  expect(res?.status).toBe(400);
105
105
  });
106
106
 
107
+ test("nested __proto__ below the old depth-20 cap → 400 (scan reaches it)", async () => {
108
+ // Build a raw JSON string so "__proto__" is an OWN key (an object literal
109
+ // would set the prototype instead). Bury it 24 levels deep — past the old
110
+ // depth-20 short-circuit that previously let it slip through.
111
+ let body = '{"__proto__":{"polluted":true}}';
112
+ for (let i = 0; i < 24; i++) body = `{"a":${body}}`;
113
+ const req = new Request(`http://x/_action?id=${registeredActionId}`, {
114
+ method: "POST",
115
+ headers: { "Content-Type": "application/json", "X-BractJS-Action": "1" },
116
+ body: `[${body}]`,
117
+ });
118
+ const res = await handleActionRequest(req);
119
+ expect(res?.status).toBe(400);
120
+ });
121
+
122
+ test("payload nested past MAX_SCAN_DEPTH → 400 (fails closed)", async () => {
123
+ // Over-deep nesting with NO forbidden key must still be rejected: a
124
+ // security scan that can't see the bottom must not pass it through.
125
+ let body = '{"value":"x"}';
126
+ for (let i = 0; i < 250; i++) body = `{"a":${body}}`;
127
+ const req = new Request(`http://x/_action?id=${registeredActionId}`, {
128
+ method: "POST",
129
+ headers: { "Content-Type": "application/json", "X-BractJS-Action": "1" },
130
+ body: `[${body}]`,
131
+ });
132
+ const res = await handleActionRequest(req);
133
+ expect(res?.status).toBe(400);
134
+ });
135
+
136
+ test("normal nested payload (within cap) still succeeds", async () => {
137
+ // A legitimately nested object (no forbidden keys) must NOT be rejected.
138
+ let obj: Record<string, unknown> = { value: "ok" };
139
+ for (let i = 0; i < 30; i++) obj = { nested: obj };
140
+ const req = new Request(`http://x/_action?id=${registeredActionId}`, {
141
+ method: "POST",
142
+ headers: { "Content-Type": "application/json", "X-BractJS-Action": "1" },
143
+ body: JSON.stringify([obj]),
144
+ });
145
+ const res = await handleActionRequest(req);
146
+ expect(res?.status).toBe(200);
147
+ });
148
+
107
149
  test("JSON body > 1 MiB rejected with 413 (advertised via Content-Length)", async () => {
108
150
  const huge = "a".repeat(2 * 1024 * 1024);
109
151
  const req = new Request(`http://x/_action?id=${registeredActionId}`, {
@@ -135,6 +177,45 @@ describe("action-handler — arg validation", () => {
135
177
  });
136
178
  });
137
179
 
180
+ // ── F2 — reserved route exports are not registered as actions ──────────────
181
+
182
+ describe("action-registry — reserved route exports", () => {
183
+ const TMP = resolve(import.meta.dir, ".tmp-reserved-exports");
184
+
185
+ beforeAll(async () => {
186
+ await rm(TMP, { recursive: true, force: true });
187
+ await mkdir(join(TMP, "routes"), { recursive: true });
188
+ await writeFile(
189
+ join(TMP, "routes", "page.tsx"),
190
+ `"use server";
191
+ export async function loader() { return { secret: "leaked" }; }
192
+ export async function action() { return "mutated"; }
193
+ export default function Page() { return null; }
194
+ export async function doThing() { return "ok"; }
195
+ `,
196
+ );
197
+ await loadServerActions(TMP);
198
+ });
199
+
200
+ afterAll(async () => {
201
+ await rm(TMP, { recursive: true, force: true });
202
+ });
203
+
204
+ test("loader / action / default in a routes/ file are NOT resolvable as actions", async () => {
205
+ const { resolveAction } = await import("../server/action-registry.ts");
206
+ for (const name of ["loader", "action", "default"]) {
207
+ const id = await computeId(join(TMP, "routes", "page.tsx"), name, TMP);
208
+ expect(resolveAction(id)).toBeNull();
209
+ }
210
+ });
211
+
212
+ test("a genuine named export in the same file IS resolvable", async () => {
213
+ const { resolveAction } = await import("../server/action-registry.ts");
214
+ const id = await computeId(join(TMP, "routes", "page.tsx"), "doThing", TMP);
215
+ expect(resolveAction(id)).not.toBeNull();
216
+ });
217
+ });
218
+
138
219
  // ── Item 3 — CSRF ─────────────────────────────────────────────────────────
139
220
 
140
221
  describe("CSRF — cross-origin mutation", () => {
@@ -166,6 +247,34 @@ describe("CSRF — cross-origin mutation", () => {
166
247
  });
167
248
  expect(res.status).toBe(403);
168
249
  });
250
+
251
+ test("403 body is terse in prod (no info disclosure)", async () => {
252
+ // This server runs in prod mode (NODE_ENV unset) — the body must not
253
+ // include the dev hint.
254
+ const res = await fetch(`${BASE}/`, {
255
+ method: "POST",
256
+ body: new FormData(),
257
+ headers: { Origin: "https://evil.example" },
258
+ });
259
+ expect(await res.text()).toBe("Forbidden");
260
+ });
261
+
262
+ test("csrfForbiddenResponse explains the fix in dev, stays terse in prod", async () => {
263
+ const { csrfForbiddenResponse } = await import("../server/csrf.ts");
264
+ const original = Bun.env.NODE_ENV;
265
+ const spy = spyOn(console, "warn").mockImplementation(() => {});
266
+ try {
267
+ Bun.env.NODE_ENV = "development";
268
+ expect(await csrfForbiddenResponse().text()).toContain("X-BractJS-Action");
269
+
270
+ Bun.env.NODE_ENV = "production";
271
+ expect(await csrfForbiddenResponse().text()).toBe("Forbidden");
272
+ } finally {
273
+ if (original === undefined) delete Bun.env.NODE_ENV;
274
+ else Bun.env.NODE_ENV = original;
275
+ spy.mockRestore();
276
+ }
277
+ });
169
278
  });
170
279
 
171
280
  describe("CSRF — Sec-Fetch-Site (isAllowedMutation)", () => {
@@ -0,0 +1,85 @@
1
+ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
2
+ import { resolve } from "node:path";
3
+ import { createServer } from "../server/serve.ts";
4
+
5
+ const PORT = 3994;
6
+ const BASE = `http://localhost:${PORT}`;
7
+ const FIXTURE_APP = resolve(import.meta.dir, "fixtures/app");
8
+
9
+ let handle: ReturnType<typeof createServer>;
10
+
11
+ beforeAll(() => {
12
+ handle = createServer({
13
+ port: PORT,
14
+ appDir: FIXTURE_APP,
15
+ manifest: { clientEntry: "/build/client/client.js", routes: {} },
16
+ });
17
+ });
18
+
19
+ afterAll(() => {
20
+ handle.stop();
21
+ });
22
+
23
+ /** The rendered document without the __BRACTJS_DATA__ script island. */
24
+ function withoutScripts(html: string): string {
25
+ return html.replace(/<script[\s\S]*?<\/script>/g, "");
26
+ }
27
+
28
+ describe("ssr: false (client-only)", () => {
29
+ test("document SSR renders the Fallback, never the component or loader data", async () => {
30
+ const res = await fetch(`${BASE}/client-only`);
31
+ expect(res.status).toBe(200);
32
+ const html = await res.text();
33
+ const rendered = withoutScripts(html);
34
+ expect(rendered).toContain("client-only fallback");
35
+ expect(rendered).not.toContain("client-only component");
36
+ // The loader must not have run at all — its data appears nowhere, not
37
+ // even in the bootstrap payload.
38
+ expect(html).not.toContain("CLIENT-ONLY-LOADER-DATA");
39
+ expect(html).toContain('"ssrMode":"client-only"');
40
+ });
41
+
42
+ test("/_data DOES run the loader — that is how the client completes the render", async () => {
43
+ const res = await fetch(`${BASE}/_data?path=/client-only`);
44
+ expect(res.status).toBe(200);
45
+ const data = (await res.json()) as { route: { secret: string } };
46
+ expect(data.route.secret).toBe("CLIENT-ONLY-LOADER-DATA");
47
+ });
48
+
49
+ test("beforeLoad still gates the document — ssr:false is not an auth bypass", async () => {
50
+ const res = await fetch(`${BASE}/protected-client-only`);
51
+ expect(res.status).toBe(403);
52
+ const body = await res.text();
53
+ expect(body).not.toContain("GATED-CLIENT-ONLY-DATA");
54
+ });
55
+
56
+ test("beforeLoad still gates /_data for ssr:false routes", async () => {
57
+ const res = await fetch(`${BASE}/_data?path=/protected-client-only`);
58
+ expect(res.status).toBe(403);
59
+ const body = await res.text();
60
+ expect(body).not.toContain("GATED-CLIENT-ONLY-DATA");
61
+ });
62
+ });
63
+
64
+ describe('ssr: "data-only"', () => {
65
+ test("loaders run (data in bootstrap) but the Fallback renders in the component's place", async () => {
66
+ const res = await fetch(`${BASE}/data-only`);
67
+ expect(res.status).toBe(200);
68
+ const html = await res.text();
69
+ const rendered = withoutScripts(html);
70
+ expect(rendered).toContain("data-only fallback");
71
+ expect(rendered).not.toContain("data-only component");
72
+ // Loader data IS present — in the bootstrap payload only.
73
+ expect(html).toContain("DATA-ONLY-LOADER-DATA");
74
+ expect(html).toContain('"ssrMode":"data-only"');
75
+ });
76
+ });
77
+
78
+ describe("default routes are untouched", () => {
79
+ test("a normal route still fully SSRs with no ssrMode marker", async () => {
80
+ const res = await fetch(`${BASE}/`);
81
+ const html = await res.text();
82
+ expect(withoutScripts(html)).toContain("Index page content");
83
+ expect(html).not.toContain('"ssrMode"');
84
+ });
85
+ });
@@ -0,0 +1,77 @@
1
+ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
2
+ import { resolve } from "node:path";
3
+ import { createServer } from "../server/serve.ts";
4
+
5
+ const PORT = 3993;
6
+ const BASE = `http://localhost:${PORT}`;
7
+ const FIXTURE_APP = resolve(import.meta.dir, "fixtures/app");
8
+
9
+ let handle: ReturnType<typeof createServer>;
10
+
11
+ beforeAll(() => {
12
+ handle = createServer({
13
+ port: PORT,
14
+ appDir: FIXTURE_APP,
15
+ ssr: false,
16
+ manifest: { clientEntry: "/build/client/client.js", routes: {} },
17
+ });
18
+ });
19
+
20
+ afterAll(() => {
21
+ handle.stop();
22
+ });
23
+
24
+ describe("SPA mode (config ssr: false)", () => {
25
+ test("document GETs return the static shell — no loader data, ssrMode spa", async () => {
26
+ const res = await fetch(`${BASE}/`);
27
+ expect(res.status).toBe(200);
28
+ expect(res.headers.get("content-type")).toContain("text/html");
29
+ const html = await res.text();
30
+ expect(html).toContain('"ssrMode":"spa"');
31
+ // The index loader must not have run for the document.
32
+ expect(html).not.toContain("hello from bractjs");
33
+ });
34
+
35
+ test("every matching document path serves the same shell", async () => {
36
+ const a = await (await fetch(`${BASE}/`)).text();
37
+ const b = await (await fetch(`${BASE}/counter`)).text();
38
+ expect(b).toContain('"ssrMode":"spa"');
39
+ expect(b).toBe(a);
40
+ });
41
+
42
+ test("unmatched paths still 404", async () => {
43
+ const res = await fetch(`${BASE}/nonexistent`);
44
+ expect(res.status).toBe(404);
45
+ });
46
+
47
+ test("/_data still runs loaders — SPA mode is 'no document SSR', not 'no server'", async () => {
48
+ const res = await fetch(`${BASE}/_data?path=/`);
49
+ expect(res.status).toBe(200);
50
+ const data = (await res.json()) as { route: { message: string } };
51
+ expect(data.route.message).toBe("hello from bractjs");
52
+ });
53
+
54
+ test("actions still work, with the CSRF gate intact", async () => {
55
+ // Same-origin mutation with the header → allowed.
56
+ const ok = await fetch(`${BASE}/counter`, {
57
+ method: "POST",
58
+ body: new FormData(),
59
+ headers: { Origin: BASE, "X-BractJS-Action": "1" },
60
+ });
61
+ expect(ok.status).toBe(200);
62
+ expect(((await ok.json()) as { ok: boolean }).ok).toBe(true);
63
+
64
+ // Cross-origin mutation → blocked exactly as in SSR mode.
65
+ const blocked = await fetch(`${BASE}/counter`, {
66
+ method: "POST",
67
+ body: new FormData(),
68
+ headers: { Origin: "https://evil.example" },
69
+ });
70
+ expect(blocked.status).toBe(403);
71
+ });
72
+
73
+ test("beforeLoad-gated /_data stays gated in SPA mode", async () => {
74
+ const res = await fetch(`${BASE}/_data?path=/protected`);
75
+ expect(res.status).toBe(403);
76
+ });
77
+ });
@@ -112,6 +112,27 @@ describe("typed routing (type-level)", () => {
112
112
  await mkdir(join(app, "routes", "blog"), { recursive: true });
113
113
  await writeFile(join(app, "routes", "_index.tsx"), "export default () => null;\n");
114
114
  await writeFile(join(app, "routes", "blog", "[id].tsx"), "export default () => null;\n");
115
+ // A route with a typed searchSchema: its safeParse return type is what
116
+ // `InferSchemaOutput` (and therefore useSearch<"/posts">) must pick up.
117
+ // It also has a loader whose return type drives `useLoaderData<typeof loader>`:
118
+ // an object union with a Response branch (must be excluded) and a Deferred
119
+ // field (must be preserved so <Await> accepts it).
120
+ await writeFile(
121
+ join(app, "routes", "posts.tsx"),
122
+ `import { defer } from "@bractjs/bractjs";\n` +
123
+ `import type { LoaderArgs } from "@bractjs/bractjs";\n` +
124
+ `export const searchSchema = {\n` +
125
+ ` safeParse(_input: unknown): { success: boolean; data?: { page: number; q?: string } } {\n` +
126
+ ` return { success: true, data: { page: 1 } };\n` +
127
+ ` },\n` +
128
+ `};\n` +
129
+ `export function loader({ search }: LoaderArgs<{ page: number }>) {\n` +
130
+ ` const p: number = search.page;\n` +
131
+ ` if (p < 0) return new Response("bad", { status: 400 });\n` +
132
+ ` return { count: p, comments: defer({ list: Promise.resolve([1, 2]) }).list };\n` +
133
+ `}\n` +
134
+ `export default () => null;\n`,
135
+ );
115
136
 
116
137
  // Generate the registration file (augments Register on the package).
117
138
  await writeFile(join(app, "route-types.gen.ts"), await generateRouteTypes(app));
@@ -119,29 +140,58 @@ describe("typed routing (type-level)", () => {
119
140
 
120
141
  await writeFile(
121
142
  join(app, "usage.tsx"),
122
- `import { Link, useNavigate, useParams, useSearchParams } from "@bractjs/bractjs";\n` +
143
+ `import { Link, useNavigate, useParams, useSearchParams, useSearch, useSetSearch, useLoaderData, Await } from "@bractjs/bractjs";\n` +
144
+ `import type { LoaderArgsFor } from "./route-types.gen.ts";\n` +
145
+ `import { loader } from "./routes/posts.tsx";\n` +
123
146
  `import "./route-types.gen.ts";\n` +
124
147
  `export function Ok() {\n` +
125
148
  ` const navigate = useNavigate();\n` +
126
149
  ` const p = useParams<"/blog/:id">();\n` +
127
150
  ` const id: string = p.id;\n` +
128
151
  ` useSearchParams<"/blog/:id">();\n` +
152
+ ` const s = useSearch<"/posts">();\n` +
153
+ ` const page: number = s.page;\n` +
154
+ ` const setSearch = useSetSearch<"/posts">();\n` +
155
+ ` void setSearch({ page: page + 1 });\n` +
156
+ ` void setSearch((prev) => ({ page: prev.page + 1 }), { replace: true });\n` +
157
+ ` // useLoaderData<typeof loader>(): Response branch excluded, count typed,\n` +
158
+ ` // Deferred field preserved + accepted by <Await>.\n` +
159
+ ` const data = useLoaderData<typeof loader>();\n` +
160
+ ` const count: number = data.count;\n` +
161
+ ` // LoaderArgsFor<"/posts">: full route-literal arg typing.\n` +
162
+ ` const argSearch = (null as unknown as LoaderArgsFor<"/posts">).search;\n` +
163
+ ` const argPage: number = argSearch.page;\n` +
164
+ ` void count; void argPage;\n` +
129
165
  ` return (<>\n` +
166
+ ` <Await resolve={data.comments} fallback={null}>{(list) => <span>{list.length}</span>}</Await>\n` +
130
167
  ` <Link to="/blog/:id" params={{ id }}>typed</Link>\n` +
131
168
  ` <Link to="/">static literal</Link>\n` +
169
+ ` <Link to="/posts" search={{ page: 2 }}>typed search</Link>\n` +
132
170
  ` <Link to={\`/\${id}\`}>built string (BC)</Link>\n` +
133
171
  ` <button onClick={() => { void navigate("/blog/:id", { params: { id } }); }}>go</button>\n` +
172
+ ` <button onClick={() => { void navigate("/posts", { search: { page: 3 } }); }}>paged</button>\n` +
134
173
  ` <button onClick={() => { void navigate("/"); }}>home</button>\n` +
135
174
  ` </>);\n` +
136
175
  `}\n` +
137
176
  `export function Bad() {\n` +
138
177
  ` const navigate = useNavigate();\n` +
139
178
  ` const p = useParams<"/blog/:id">();\n` +
179
+ ` const s = useSearch<"/posts">();\n` +
180
+ ` const setSearch = useSetSearch<"/posts">();\n` +
181
+ ` // @ts-expect-error page is a number, not a string\n` +
182
+ ` void setSearch({ page: "2" });\n` +
183
+ ` // @ts-expect-error the schema declares no \`bogus\` key\n` +
184
+ ` void (s.bogus);\n` +
185
+ ` const data = useLoaderData<typeof loader>();\n` +
186
+ ` // @ts-expect-error loader data has no \`missing\` field (Response branch excluded, object inferred)\n` +
187
+ ` void (data.missing);\n` +
140
188
  ` return (<>\n` +
141
189
  ` {/* @ts-expect-error wrong param key */}\n` +
142
190
  ` <Link to="/blog/:id" params={{ wrong: "1" }}>x</Link>\n` +
143
191
  ` {/* @ts-expect-error missing required param */}\n` +
144
192
  ` <Link to="/blog/:id" params={{}}>x</Link>\n` +
193
+ ` {/* @ts-expect-error search value has the wrong type */}\n` +
194
+ ` <Link to="/posts" search={{ page: "2" }}>x</Link>\n` +
145
195
  ` <button onClick={() => {\n` +
146
196
  ` // @ts-expect-error wrong param key in navigate\n` +
147
197
  ` void navigate("/blog/:id", { params: { wrong: "1" } });\n` +
@@ -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
+ });