@bractjs/bractjs 0.1.27 → 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.
- package/README.md +242 -36
- package/bin/cli.ts +18 -1
- package/package.json +1 -1
- package/src/__tests__/codegen-write.test.ts +67 -0
- package/src/__tests__/codegen.test.ts +29 -2
- package/src/__tests__/compile-safety.test.ts +4 -0
- package/src/__tests__/csp.test.ts +10 -0
- package/src/__tests__/define-actions.test.ts +69 -0
- package/src/__tests__/env.test.ts +18 -0
- package/src/__tests__/fetcher-store.test.ts +67 -0
- package/src/__tests__/fixtures/app/root.tsx +7 -2
- package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
- package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
- package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
- package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
- package/src/__tests__/form-data-helpers.test.ts +43 -0
- package/src/__tests__/integration.test.ts +56 -0
- package/src/__tests__/loader.test.ts +32 -1
- package/src/__tests__/nav-utils.test.ts +46 -0
- package/src/__tests__/prerender.test.ts +102 -0
- package/src/__tests__/programmatic-api.test.ts +20 -1
- package/src/__tests__/revalidation.test.ts +65 -0
- package/src/__tests__/route-lint.test.ts +74 -0
- package/src/__tests__/route-table.test.ts +33 -0
- package/src/__tests__/safe-validate.test.ts +96 -0
- package/src/__tests__/scroll-restoration.test.ts +66 -0
- package/src/__tests__/search-serializer.test.ts +42 -0
- package/src/__tests__/search-validation.test.ts +125 -0
- package/src/__tests__/security.test.ts +110 -1
- package/src/__tests__/selective-ssr.test.ts +85 -0
- package/src/__tests__/spa-mode.test.ts +77 -0
- package/src/__tests__/typed-routing.test.ts +51 -1
- package/src/build/bundler.ts +33 -0
- package/src/build/prerender.ts +88 -0
- package/src/build/route-lint.ts +49 -0
- package/src/client/ClientRouter.tsx +239 -47
- package/src/client/cache.ts +8 -0
- package/src/client/components/Await.tsx +9 -2
- package/src/client/components/Form.tsx +23 -34
- package/src/client/components/Link.tsx +80 -9
- package/src/client/components/Outlet.tsx +8 -2
- package/src/client/components/ScrollRestoration.tsx +125 -0
- package/src/client/entry.tsx +39 -2
- package/src/client/fetcher-store.ts +61 -0
- package/src/client/form-utils.ts +3 -0
- package/src/client/hooks/useActionData.ts +7 -3
- package/src/client/hooks/useFetcher.ts +116 -33
- package/src/client/hooks/useFetchers.ts +23 -0
- package/src/client/hooks/useLoaderData.ts +8 -4
- package/src/client/hooks/useLocation.ts +27 -0
- package/src/client/hooks/useNavigate.ts +11 -6
- package/src/client/hooks/useRevalidator.ts +26 -0
- package/src/client/hooks/useSearch.ts +73 -0
- package/src/client/hooks/useSearchParams.ts +7 -2
- package/src/client/nav-utils.ts +26 -0
- package/src/client/prefetch.ts +110 -15
- package/src/client/registry.ts +24 -0
- package/src/client/revalidation.ts +25 -0
- package/src/client/router.tsx +28 -1
- package/src/client/scroll-restoration.ts +48 -0
- package/src/client/search-serializer.ts +40 -0
- package/src/client/types.ts +6 -0
- package/src/codegen/route-codegen.ts +141 -8
- package/src/config/load.ts +21 -0
- package/src/dev/hmr-client.ts +3 -1
- package/src/dev/route-table.ts +27 -0
- package/src/dev/server.ts +106 -8
- package/src/dev/watcher.ts +25 -3
- package/src/index.ts +27 -3
- package/src/server/action-handler.ts +12 -3
- package/src/server/action-registry.ts +35 -0
- package/src/server/csp.ts +10 -1
- package/src/server/csrf.ts +26 -0
- package/src/server/env.ts +26 -5
- package/src/server/layout.ts +31 -1
- package/src/server/loader.ts +14 -8
- package/src/server/render.ts +18 -3
- package/src/server/request-handler.ts +50 -8
- package/src/server/search.ts +43 -0
- package/src/server/serve.ts +88 -1
- package/src/server/spa.ts +62 -0
- package/src/server/stream-handler.ts +10 -1
- package/src/server/validate.ts +85 -13
- package/src/shared/context.ts +5 -0
- package/src/shared/define-actions.ts +39 -0
- package/src/shared/form-data.ts +34 -0
- package/src/shared/route-types.ts +83 -2
- package/templates/new-app/app/root.tsx +2 -1
- package/templates/new-app/bractjs.config.ts +7 -12
- package/types/config.d.ts +21 -0
- package/types/index.d.ts +165 -9
- package/types/route.d.ts +62 -2
|
@@ -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` +
|
package/src/build/bundler.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface BuildConfig {
|
|
|
19
19
|
minify?: boolean;
|
|
20
20
|
clientEnv?: string[];
|
|
21
21
|
plugins?: BunPlugin[];
|
|
22
|
+
/** SPA mode: when `false`, the build also emits the static document shell. */
|
|
23
|
+
ssr?: boolean;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export async function runBuild(config: BuildConfig): Promise<void> {
|
|
@@ -30,6 +32,16 @@ export async function runBuild(config: BuildConfig): Promise<void> {
|
|
|
30
32
|
const routeFilePaths = routes.map((r) => join(appDir, r.filePath));
|
|
31
33
|
const rootFilePath = join(appDir, "root.tsx");
|
|
32
34
|
|
|
35
|
+
// Static route-module lint: surface empty routes and miscased exports at
|
|
36
|
+
// build time (no execution — just source analysis).
|
|
37
|
+
const { lintRouteModuleSource } = await import("./route-lint.ts");
|
|
38
|
+
for (const r of routes) {
|
|
39
|
+
const src = await Bun.file(join(appDir, r.filePath)).text().catch(() => "");
|
|
40
|
+
for (const warning of lintRouteModuleSource(src, r.filePath)) {
|
|
41
|
+
console.warn(`[bract] ${warning}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
// ── 1. Clean stale artefacts ────────────────────────────────────────────
|
|
34
46
|
const buildDir = config.buildDir ?? "build";
|
|
35
47
|
await Promise.all([
|
|
@@ -140,5 +152,26 @@ export async function runBuild(config: BuildConfig): Promise<void> {
|
|
|
140
152
|
// ── 5. Write manifest ──────────────────────────────────────────────────
|
|
141
153
|
const manifest = generateManifest({ clientEntry, rootChunk, routeChunks, mode: "production" });
|
|
142
154
|
await writeManifest(manifest, "build");
|
|
155
|
+
|
|
156
|
+
// ── 6. SPA shell (ssr: false) ───────────────────────────────────────────
|
|
157
|
+
// Emit the static document shell every document GET will serve in SPA mode.
|
|
158
|
+
if (config.ssr === false) {
|
|
159
|
+
const { renderSpaShell } = await import("../server/spa.ts");
|
|
160
|
+
const { installUseClientServerStub } = await import("../server/use-client-runtime.ts");
|
|
161
|
+
// root.tsx is imported from source here — "use client" components inside
|
|
162
|
+
// it must null-render exactly as they do on the running server.
|
|
163
|
+
installUseClientServerStub(appDir);
|
|
164
|
+
const serverManifest = {
|
|
165
|
+
clientEntry,
|
|
166
|
+
rootChunk,
|
|
167
|
+
routes: Object.fromEntries(
|
|
168
|
+
Object.entries(manifest.routes).map(([pat, e]) => [pat, { file: e.chunk, chunk: e.chunk }]),
|
|
169
|
+
),
|
|
170
|
+
};
|
|
171
|
+
const html = await renderSpaShell(appDir, serverManifest);
|
|
172
|
+
await Bun.write(join(buildDir, "client", "__spa.html"), html);
|
|
173
|
+
console.log("[bract] SPA shell → build/client/__spa.html");
|
|
174
|
+
}
|
|
175
|
+
|
|
143
176
|
console.log("[bract] build complete →", Object.keys(manifest.routes).length, "routes");
|
|
144
177
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { buildFetchHandler } from "../server/serve.ts";
|
|
3
|
+
import type { ServerManifest } from "../server/render.ts";
|
|
4
|
+
|
|
5
|
+
export interface PrerenderOptions {
|
|
6
|
+
/** Concrete paths to prerender (or a function resolving them, e.g. from a DB). */
|
|
7
|
+
prerender: string[] | (() => string[] | Promise<string[]>);
|
|
8
|
+
appDir?: string;
|
|
9
|
+
publicDir?: string;
|
|
10
|
+
buildDir?: string;
|
|
11
|
+
/** Override the manifest instead of loading `<buildDir>/route-manifest.json`. */
|
|
12
|
+
manifest?: ServerManifest;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PrerenderResult {
|
|
16
|
+
written: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Where a path's prerendered files live under `<buildDir>/client/_prerender`.
|
|
21
|
+
* Throws on anything that isn't a clean absolute path — these strings come
|
|
22
|
+
* from user config but become filesystem writes.
|
|
23
|
+
*/
|
|
24
|
+
export function prerenderPaths(path: string): { html: string; data: string } {
|
|
25
|
+
if (!path.startsWith("/")) {
|
|
26
|
+
throw new Error(`[bractjs] prerender: paths must start with "/", got ${JSON.stringify(path)}`);
|
|
27
|
+
}
|
|
28
|
+
if (path.includes(":") || path.includes("[") || path.includes("*")) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`[bractjs] prerender: ${JSON.stringify(path)} looks like a route PATTERN — ` +
|
|
31
|
+
`expand dynamic routes to concrete paths (e.g. "/blog/intro", not "/blog/:slug").`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const segments = path.split("/").filter(Boolean);
|
|
35
|
+
if (segments.some((s) => s === ".." || s === ".")) {
|
|
36
|
+
throw new Error(`[bractjs] prerender: refusing path with dot segments: ${JSON.stringify(path)}`);
|
|
37
|
+
}
|
|
38
|
+
const dir = segments.join("/");
|
|
39
|
+
return {
|
|
40
|
+
html: dir === "" ? "index.html" : `${dir}/index.html`,
|
|
41
|
+
data: dir === "" ? "_data.json" : `${dir}/_data.json`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build-time prerendering (SSG): run the production fetch handler in-process
|
|
47
|
+
* against each configured path and write the HTML document plus its `/_data`
|
|
48
|
+
* payload (used by client navigations INTO a prerendered page) under
|
|
49
|
+
* `<buildDir>/client/_prerender/`. The production server serves these before
|
|
50
|
+
* falling back to dynamic SSR — query-carrying requests stay dynamic.
|
|
51
|
+
*
|
|
52
|
+
* Loaders run for real at build time: anything they need (DB, env) must be
|
|
53
|
+
* available to the build.
|
|
54
|
+
*/
|
|
55
|
+
export async function runPrerender(options: PrerenderOptions): Promise<PrerenderResult> {
|
|
56
|
+
const buildDir = options.buildDir ?? "./build";
|
|
57
|
+
const paths = typeof options.prerender === "function" ? await options.prerender() : options.prerender;
|
|
58
|
+
|
|
59
|
+
const handler = buildFetchHandler({
|
|
60
|
+
appDir: options.appDir ?? "./app",
|
|
61
|
+
publicDir: options.publicDir,
|
|
62
|
+
buildDir,
|
|
63
|
+
manifest: options.manifest,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const written: string[] = [];
|
|
67
|
+
for (const path of paths) {
|
|
68
|
+
const out = prerenderPaths(path);
|
|
69
|
+
|
|
70
|
+
const htmlRes = await handler(new Request("http://prerender.local" + path));
|
|
71
|
+
if (htmlRes.status !== 200) {
|
|
72
|
+
throw new Error(`[bractjs] prerender: GET ${path} returned ${htmlRes.status}`);
|
|
73
|
+
}
|
|
74
|
+
const htmlFile = join(buildDir, "client", "_prerender", out.html);
|
|
75
|
+
await Bun.write(htmlFile, await htmlRes.text());
|
|
76
|
+
written.push(htmlFile);
|
|
77
|
+
|
|
78
|
+
const dataRes = await handler(
|
|
79
|
+
new Request("http://prerender.local/_data?path=" + encodeURIComponent(path)),
|
|
80
|
+
);
|
|
81
|
+
if (dataRes.status === 200) {
|
|
82
|
+
const dataFile = join(buildDir, "client", "_prerender", out.data);
|
|
83
|
+
await Bun.write(dataFile, await dataRes.text());
|
|
84
|
+
written.push(dataFile);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { written };
|
|
88
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { extractExports } from "./directives.ts";
|
|
2
|
+
|
|
3
|
+
// The canonical route-module export names. A route file exporting a near-miss
|
|
4
|
+
// (wrong case) of one of these is almost always a mistake — the framework's
|
|
5
|
+
// projection is case-sensitive, so `Loader` is silently ignored.
|
|
6
|
+
export const ROUTE_EXPORT_NAMES = [
|
|
7
|
+
"default", "loader", "action", "meta", "beforeLoad", "shouldRevalidate",
|
|
8
|
+
"searchSchema", "ssr", "Fallback", "handle", "ErrorBoundary", "config",
|
|
9
|
+
"loaderDeps", "context",
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
const CANONICAL_LOWER = new Map(ROUTE_EXPORT_NAMES.map((n) => [n.toLowerCase(), n]));
|
|
13
|
+
const CANONICAL_SET = new Set<string>(ROUTE_EXPORT_NAMES);
|
|
14
|
+
|
|
15
|
+
/** A route is "renderable or does work" if it has any of these. */
|
|
16
|
+
const MEANINGFUL = ["default", "loader", "action", "beforeLoad"];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Static lint of a route module's SOURCE (no execution). Returns human-readable
|
|
20
|
+
* warning strings. Used by the dev rebuilder and the production build to catch
|
|
21
|
+
* two common, silent mistakes: a route that renders nothing, and an export
|
|
22
|
+
* whose casing doesn't match a framework export (so it's ignored).
|
|
23
|
+
*/
|
|
24
|
+
export function lintRouteModuleSource(src: string, filePath: string): string[] {
|
|
25
|
+
const warnings: string[] = [];
|
|
26
|
+
const names = extractExports(src);
|
|
27
|
+
// extractExports misses anonymous `export default () => …` / `export default function() {}`.
|
|
28
|
+
const hasAnonDefault = /^export\s+default\b/m.test(src) && !names.includes("default");
|
|
29
|
+
const exportSet = new Set(names);
|
|
30
|
+
if (hasAnonDefault) exportSet.add("default");
|
|
31
|
+
|
|
32
|
+
if (!MEANINGFUL.some((n) => exportSet.has(n))) {
|
|
33
|
+
warnings.push(
|
|
34
|
+
`${filePath}: route has no default/loader/action/beforeLoad export — it renders an empty page.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const name of exportSet) {
|
|
39
|
+
if (CANONICAL_SET.has(name)) continue;
|
|
40
|
+
const canonical = CANONICAL_LOWER.get(name.toLowerCase());
|
|
41
|
+
if (canonical && canonical !== name) {
|
|
42
|
+
warnings.push(
|
|
43
|
+
`${filePath}: export "${name}" looks like "${canonical}" — route exports are case-sensitive, so "${name}" is ignored.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return warnings;
|
|
49
|
+
}
|