@bractjs/bractjs 0.1.26 → 0.1.28

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 (98) hide show
  1. package/README.md +283 -58
  2. package/bin/cli.ts +18 -1
  3. package/package.json +1 -1
  4. package/src/__tests__/build-path.test.ts +29 -0
  5. package/src/__tests__/codegen-write.test.ts +67 -0
  6. package/src/__tests__/codegen.test.ts +64 -1
  7. package/src/__tests__/compile-safety.test.ts +4 -0
  8. package/src/__tests__/csp.test.ts +10 -0
  9. package/src/__tests__/define-actions.test.ts +69 -0
  10. package/src/__tests__/env.test.ts +18 -0
  11. package/src/__tests__/fetcher-store.test.ts +67 -0
  12. package/src/__tests__/fixtures/app/root.tsx +7 -2
  13. package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
  14. package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
  15. package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
  16. package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
  17. package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
  18. package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
  19. package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
  20. package/src/__tests__/form-data-helpers.test.ts +43 -0
  21. package/src/__tests__/integration.test.ts +56 -0
  22. package/src/__tests__/loader.test.ts +32 -1
  23. package/src/__tests__/nav-utils.test.ts +46 -0
  24. package/src/__tests__/prerender.test.ts +102 -0
  25. package/src/__tests__/programmatic-api.test.ts +20 -1
  26. package/src/__tests__/revalidation.test.ts +65 -0
  27. package/src/__tests__/route-lint.test.ts +74 -0
  28. package/src/__tests__/route-table.test.ts +33 -0
  29. package/src/__tests__/safe-validate.test.ts +96 -0
  30. package/src/__tests__/scroll-restoration.test.ts +66 -0
  31. package/src/__tests__/search-serializer.test.ts +42 -0
  32. package/src/__tests__/search-validation.test.ts +125 -0
  33. package/src/__tests__/security.test.ts +110 -1
  34. package/src/__tests__/selective-ssr.test.ts +85 -0
  35. package/src/__tests__/spa-mode.test.ts +77 -0
  36. package/src/__tests__/typed-routing.test.ts +239 -0
  37. package/src/build/bundler.ts +33 -0
  38. package/src/build/prerender.ts +88 -0
  39. package/src/build/route-lint.ts +49 -0
  40. package/src/client/ClientRouter.tsx +239 -47
  41. package/src/client/build-path.ts +24 -0
  42. package/src/client/cache.ts +8 -0
  43. package/src/client/components/Await.tsx +9 -2
  44. package/src/client/components/Form.tsx +23 -34
  45. package/src/client/components/Link.tsx +105 -11
  46. package/src/client/components/Outlet.tsx +8 -2
  47. package/src/client/components/ScrollRestoration.tsx +125 -0
  48. package/src/client/entry.tsx +39 -2
  49. package/src/client/fetcher-store.ts +61 -0
  50. package/src/client/form-utils.ts +3 -0
  51. package/src/client/hooks/useActionData.ts +7 -3
  52. package/src/client/hooks/useFetcher.ts +116 -33
  53. package/src/client/hooks/useFetchers.ts +23 -0
  54. package/src/client/hooks/useLoaderData.ts +8 -4
  55. package/src/client/hooks/useLocation.ts +27 -0
  56. package/src/client/hooks/useNavigate.ts +51 -0
  57. package/src/client/hooks/useParams.ts +15 -4
  58. package/src/client/hooks/useRevalidator.ts +26 -0
  59. package/src/client/hooks/useSearch.ts +73 -0
  60. package/src/client/hooks/useSearchParams.ts +21 -6
  61. package/src/client/nav-utils.ts +26 -0
  62. package/src/client/prefetch.ts +110 -15
  63. package/src/client/registry.ts +131 -0
  64. package/src/client/revalidation.ts +25 -0
  65. package/src/client/router.tsx +28 -1
  66. package/src/client/scroll-restoration.ts +48 -0
  67. package/src/client/search-serializer.ts +40 -0
  68. package/src/client/types.ts +6 -0
  69. package/src/codegen/route-codegen.ts +201 -29
  70. package/src/config/load.ts +21 -0
  71. package/src/dev/hmr-client.ts +3 -1
  72. package/src/dev/route-table.ts +27 -0
  73. package/src/dev/server.ts +106 -8
  74. package/src/dev/watcher.ts +25 -3
  75. package/src/index.ts +44 -3
  76. package/src/server/action-handler.ts +12 -3
  77. package/src/server/action-registry.ts +35 -0
  78. package/src/server/csp.ts +10 -1
  79. package/src/server/csrf.ts +26 -0
  80. package/src/server/env.ts +26 -5
  81. package/src/server/layout.ts +31 -1
  82. package/src/server/loader.ts +14 -8
  83. package/src/server/render.ts +18 -3
  84. package/src/server/request-handler.ts +50 -8
  85. package/src/server/search.ts +43 -0
  86. package/src/server/serve.ts +88 -1
  87. package/src/server/spa.ts +62 -0
  88. package/src/server/stream-handler.ts +10 -1
  89. package/src/server/validate.ts +85 -13
  90. package/src/shared/context.ts +5 -0
  91. package/src/shared/define-actions.ts +39 -0
  92. package/src/shared/form-data.ts +34 -0
  93. package/src/shared/route-types.ts +83 -2
  94. package/templates/new-app/app/root.tsx +2 -1
  95. package/templates/new-app/bractjs.config.ts +7 -12
  96. package/types/config.d.ts +21 -0
  97. package/types/index.d.ts +210 -10
  98. package/types/route.d.ts +62 -2
@@ -0,0 +1,125 @@
1
+ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
2
+ import { resolve } from "node:path";
3
+ import { searchParamsToObject, validateSearch } from "../server/search.ts";
4
+ import { createServer } from "../server/serve.ts";
5
+
6
+ // ── Unit: searchParamsToObject ──────────────────────────────────────────────
7
+
8
+ describe("searchParamsToObject", () => {
9
+ test("single values stay strings", () => {
10
+ expect(searchParamsToObject(new URLSearchParams("a=1&b=x"))).toEqual({ a: "1", b: "x" });
11
+ });
12
+
13
+ test("repeated keys collapse into arrays", () => {
14
+ expect(searchParamsToObject(new URLSearchParams("tag=a&tag=b&tag=c"))).toEqual({
15
+ tag: ["a", "b", "c"],
16
+ });
17
+ });
18
+
19
+ test("empty params → empty object", () => {
20
+ expect(searchParamsToObject(new URLSearchParams(""))).toEqual({});
21
+ });
22
+ });
23
+
24
+ // ── Unit: validateSearch ────────────────────────────────────────────────────
25
+
26
+ const coercingSchema = {
27
+ safeParse(input: unknown) {
28
+ const obj = input as Record<string, unknown>;
29
+ const n = Number(obj.page ?? 1);
30
+ if (!Number.isInteger(n)) {
31
+ return { success: false, error: { issues: [{ path: ["page"], message: "not an int" }] } };
32
+ }
33
+ return { success: true, data: { page: n } };
34
+ },
35
+ };
36
+
37
+ describe("validateSearch", () => {
38
+ test("no schema → raw string record (back-compat)", async () => {
39
+ const url = new URL("http://x.test/posts?page=2&tag=a&tag=b");
40
+ expect(await validateSearch(undefined, url)).toEqual({ page: "2", tag: ["a", "b"] });
41
+ });
42
+
43
+ test("schema output replaces raw strings (coercion)", async () => {
44
+ const url = new URL("http://x.test/posts?page=7");
45
+ expect(await validateSearch(coercingSchema, url)).toEqual({ page: 7 });
46
+ });
47
+
48
+ test("schema failure throws a 400 Response with field errors", async () => {
49
+ const url = new URL("http://x.test/posts?page=abc");
50
+ try {
51
+ await validateSearch(coercingSchema, url);
52
+ expect.unreachable("validateSearch should have thrown");
53
+ } catch (err) {
54
+ expect(err).toBeInstanceOf(Response);
55
+ const res = err as Response;
56
+ expect(res.status).toBe(400);
57
+ const body = (await res.json()) as { errors: Record<string, string[]> };
58
+ expect(body.errors.page).toEqual(["not an int"]);
59
+ }
60
+ });
61
+ });
62
+
63
+ // ── Integration: live server with a searchSchema route ─────────────────────
64
+
65
+ const PORT = 3996;
66
+ const BASE = `http://localhost:${PORT}`;
67
+ const FIXTURE_APP = resolve(import.meta.dir, "fixtures/app");
68
+
69
+ let handle: ReturnType<typeof createServer>;
70
+
71
+ beforeAll(() => {
72
+ handle = createServer({
73
+ port: PORT,
74
+ appDir: FIXTURE_APP,
75
+ manifest: { clientEntry: "/build/client/client.js", routes: {} },
76
+ });
77
+ });
78
+
79
+ afterAll(() => {
80
+ handle.stop();
81
+ });
82
+
83
+ describe("searchSchema end-to-end", () => {
84
+ test("/_data returns the validated+coerced search object and loaders receive it", async () => {
85
+ const res = await fetch(`${BASE}/_data?path=${encodeURIComponent("/search-demo?page=3&tag=x")}`);
86
+ expect(res.status).toBe(200);
87
+ const data = (await res.json()) as {
88
+ search: Record<string, unknown>;
89
+ route: { receivedSearch: Record<string, unknown> };
90
+ };
91
+ expect(data.search).toEqual({ page: 3, tag: ["x"] });
92
+ expect(data.route.receivedSearch).toEqual({ page: 3, tag: ["x"] });
93
+ });
94
+
95
+ test("/_data applies schema defaults when params are absent", async () => {
96
+ const res = await fetch(`${BASE}/_data?path=${encodeURIComponent("/search-demo")}`);
97
+ const data = (await res.json()) as { search: Record<string, unknown> };
98
+ expect(data.search).toEqual({ page: 1 });
99
+ });
100
+
101
+ test("/_data with invalid search → 400 with field errors, loader never runs", async () => {
102
+ const res = await fetch(`${BASE}/_data?path=${encodeURIComponent("/search-demo?page=abc")}`);
103
+ expect(res.status).toBe(400);
104
+ const body = (await res.json()) as { errors: Record<string, string[]> };
105
+ expect(body.errors.page).toBeDefined();
106
+ });
107
+
108
+ test("document GET with invalid search → 400", async () => {
109
+ const res = await fetch(`${BASE}/search-demo?page=abc`);
110
+ expect(res.status).toBe(400);
111
+ });
112
+
113
+ test("document GET hydrates the validated search into __BRACTJS_DATA__", async () => {
114
+ const res = await fetch(`${BASE}/search-demo?page=5`);
115
+ expect(res.status).toBe(200);
116
+ const html = await res.text();
117
+ expect(html).toContain('"search":{"page":5}');
118
+ });
119
+
120
+ test("routes without a schema still get the raw string record", async () => {
121
+ const res = await fetch(`${BASE}/_data?path=${encodeURIComponent("/?q=hello")}`);
122
+ const data = (await res.json()) as { search: Record<string, unknown> };
123
+ expect(data.search).toEqual({ q: "hello" });
124
+ });
125
+ });
@@ -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
+ });
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Type-level guardrail for end-to-end typed routing.
3
+ *
4
+ * The runtime helpers (`<Link>`, `useNavigate`, `useParams`, `useSearchParams`)
5
+ * gain their type-safety from a declaration-merging seam: `bractjs codegen`
6
+ * augments the package's `Register` interface, and the helpers resolve route
7
+ * types from it. None of that is observable at runtime — only `tsc` proves it.
8
+ * This test writes a fixture, generates its `route-types.gen.ts`, and runs
9
+ * `tsc --noEmit` over:
10
+ *
11
+ * - positive usage (typed <Link>/useNavigate/useParams must compile), and
12
+ * - negative usage guarded by `@ts-expect-error` (bad params/routes MUST error,
13
+ * so a directive that goes unused fails the build), and
14
+ * - a SECOND fixture with NO codegen, proving un-registered apps still compile
15
+ * with the loose `string` fallback (the backwards-compat contract).
16
+ *
17
+ * It guards the subtle failure mode that nearly shipped: a too-clever resolution
18
+ * type silently falling back to loose `string`, which compiles but enforces
19
+ * nothing.
20
+ *
21
+ * The fixture lives inside the repo (`.tmp-types-*`, gitignored) so its
22
+ * `@bractjs/bractjs` import self-resolves to the in-repo framework via the root
23
+ * tsconfig `paths` mapping.
24
+ */
25
+ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
26
+ import { mkdir, rm, writeFile } from "node:fs/promises";
27
+ import { resolve, join } from "node:path";
28
+ import { generateRouteTypes } from "../codegen/route-codegen.ts";
29
+
30
+ const REPO_ROOT = resolve(import.meta.dir, "../..");
31
+ const TMP = resolve(import.meta.dir, `.tmp-types-${Date.now()}`);
32
+
33
+ // Invoke TypeScript via `bunx tsc` — it works whether tsc is installed locally
34
+ // or fetched on demand. (A direct node_modules/.bin/tsc path is unreliable here:
35
+ // it may be a dangling symlink to an un-installed package.)
36
+ const TSC_CMD = ["bunx", "tsc"] as const;
37
+
38
+ // A fixture tsconfig that resolves `@bractjs/bractjs` → the in-repo entry, mirrors
39
+ // the example apps' compiler options, and type-checks only this fixture's files.
40
+ function tsconfig(includeGlobDir: string): string {
41
+ return JSON.stringify(
42
+ {
43
+ compilerOptions: {
44
+ target: "ESNext",
45
+ module: "ESNext",
46
+ moduleResolution: "bundler",
47
+ lib: ["ESNext", "DOM", "DOM.Iterable"],
48
+ jsx: "react-jsx",
49
+ jsxImportSource: "react",
50
+ strict: true,
51
+ noEmit: true,
52
+ skipLibCheck: true,
53
+ allowImportingTsExtensions: true,
54
+ types: ["react", "react-dom"],
55
+ // `paths` with absolute targets needs no `baseUrl` (and baseUrl is
56
+ // deprecated as of TS6, which would fail the compile here).
57
+ paths: { "@bractjs/bractjs": [join(REPO_ROOT, "src/index.ts")] },
58
+ },
59
+ include: [join(includeGlobDir, "**/*.ts"), join(includeGlobDir, "**/*.tsx")],
60
+ },
61
+ null,
62
+ 2,
63
+ );
64
+ }
65
+
66
+ async function runTsc(projectDir: string): Promise<{ code: number; output: string }> {
67
+ const proc = Bun.spawn([...TSC_CMD, "--noEmit", "-p", join(projectDir, "tsconfig.json")], {
68
+ cwd: projectDir,
69
+ stdout: "pipe",
70
+ stderr: "pipe",
71
+ });
72
+ const [stdout, stderr, code] = await Promise.all([
73
+ new Response(proc.stdout).text(),
74
+ new Response(proc.stderr).text(),
75
+ proc.exited,
76
+ ]);
77
+ return { code, output: stdout + stderr };
78
+ }
79
+
80
+ let tscAvailable = false;
81
+
82
+ beforeAll(async () => {
83
+ await mkdir(TMP, { recursive: true });
84
+ // Real availability probe: actually run the compiler. Earlier this checked a
85
+ // symlink's existence and silently skipped when it dangled — making the whole
86
+ // suite a no-op that still "passed". Run `tsc --version` and trust the exit.
87
+ try {
88
+ const probe = Bun.spawn([...TSC_CMD, "--version"], { stdout: "pipe", stderr: "pipe" });
89
+ const out = await new Response(probe.stdout).text();
90
+ tscAvailable = (await probe.exited) === 0 && /Version/.test(out);
91
+ } catch {
92
+ tscAvailable = false;
93
+ }
94
+ });
95
+
96
+ afterAll(async () => {
97
+ await rm(TMP, { recursive: true, force: true });
98
+ });
99
+
100
+ describe("typed routing (type-level)", () => {
101
+ test("tsc is available (else the type-level assertions below are skipped)", () => {
102
+ // Surfaced as its own test so a skipped type-check is visible in the report
103
+ // rather than masquerading as a silent pass.
104
+ if (!tscAvailable) console.warn("[typed-routing] tsc unavailable — type-level checks skipped");
105
+ expect(typeof tscAvailable).toBe("boolean");
106
+ });
107
+
108
+ test("registered app: typed Link/useNavigate/useParams compile; bad usage errors", async () => {
109
+ if (!tscAvailable) return; // tsc not available in this environment — skip gracefully
110
+
111
+ const app = join(TMP, "registered");
112
+ await mkdir(join(app, "routes", "blog"), { recursive: true });
113
+ await writeFile(join(app, "routes", "_index.tsx"), "export default () => null;\n");
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
+ );
136
+
137
+ // Generate the registration file (augments Register on the package).
138
+ await writeFile(join(app, "route-types.gen.ts"), await generateRouteTypes(app));
139
+ await writeFile(join(app, "tsconfig.json"), tsconfig("."));
140
+
141
+ await writeFile(
142
+ join(app, "usage.tsx"),
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` +
146
+ `import "./route-types.gen.ts";\n` +
147
+ `export function Ok() {\n` +
148
+ ` const navigate = useNavigate();\n` +
149
+ ` const p = useParams<"/blog/:id">();\n` +
150
+ ` const id: string = p.id;\n` +
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` +
165
+ ` return (<>\n` +
166
+ ` <Await resolve={data.comments} fallback={null}>{(list) => <span>{list.length}</span>}</Await>\n` +
167
+ ` <Link to="/blog/:id" params={{ id }}>typed</Link>\n` +
168
+ ` <Link to="/">static literal</Link>\n` +
169
+ ` <Link to="/posts" search={{ page: 2 }}>typed search</Link>\n` +
170
+ ` <Link to={\`/\${id}\`}>built string (BC)</Link>\n` +
171
+ ` <button onClick={() => { void navigate("/blog/:id", { params: { id } }); }}>go</button>\n` +
172
+ ` <button onClick={() => { void navigate("/posts", { search: { page: 3 } }); }}>paged</button>\n` +
173
+ ` <button onClick={() => { void navigate("/"); }}>home</button>\n` +
174
+ ` </>);\n` +
175
+ `}\n` +
176
+ `export function Bad() {\n` +
177
+ ` const navigate = useNavigate();\n` +
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` +
188
+ ` return (<>\n` +
189
+ ` {/* @ts-expect-error wrong param key */}\n` +
190
+ ` <Link to="/blog/:id" params={{ wrong: "1" }}>x</Link>\n` +
191
+ ` {/* @ts-expect-error missing required param */}\n` +
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` +
195
+ ` <button onClick={() => {\n` +
196
+ ` // @ts-expect-error wrong param key in navigate\n` +
197
+ ` void navigate("/blog/:id", { params: { wrong: "1" } });\n` +
198
+ ` }}>go</button>\n` +
199
+ ` {/* @ts-expect-error /blog/:id has no \`nope\` param */}\n` +
200
+ ` <span>{p.nope}</span>\n` +
201
+ ` </>);\n` +
202
+ `}\n`,
203
+ );
204
+
205
+ const { code, output } = await runTsc(app);
206
+ // Exit 0 means: positives compiled AND every @ts-expect-error was satisfied
207
+ // by a real error. A silently-loose type would leave directives unused → TS2578.
208
+ // (`output` may carry bunx "Resolving dependencies" noise — key on TS errors.)
209
+ expect(output).not.toContain("TS2578"); // unused @ts-expect-error → typing too loose
210
+ expect(output).not.toMatch(/error TS/);
211
+ expect(code).toBe(0);
212
+ }, 60_000);
213
+
214
+ test("un-registered app (no codegen): loose string fallback still compiles", async () => {
215
+ if (!tscAvailable) return;
216
+
217
+ const app = join(TMP, "loose");
218
+ await mkdir(app, { recursive: true });
219
+ await writeFile(join(app, "tsconfig.json"), tsconfig("."));
220
+ // No route-types.gen.ts → Register stays empty → everything falls back to string.
221
+ await writeFile(
222
+ join(app, "usage.tsx"),
223
+ `import { Link, useNavigate, useParams } from "@bractjs/bractjs";\n` +
224
+ `export function App({ href }: { href: string }) {\n` +
225
+ ` const navigate = useNavigate();\n` +
226
+ ` const p = useParams();\n` +
227
+ ` return (<>\n` +
228
+ ` <Link to={href}>any string</Link>\n` +
229
+ ` <Link to="/anything/at/all">arbitrary literal</Link>\n` +
230
+ ` <button onClick={() => { void navigate("/wherever"); }}>{p.x}</button>\n` +
231
+ ` </>);\n` +
232
+ `}\n`,
233
+ );
234
+
235
+ const { code, output } = await runTsc(app);
236
+ expect(output).not.toMatch(/error TS/);
237
+ expect(code).toBe(0);
238
+ }, 60_000);
239
+ });