@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
package/README.md CHANGED
@@ -34,7 +34,7 @@ This README is a **step-by-step guide to every function and feature** BractJS ex
34
34
  15. [Sessions: `createCookieSession`](#15-sessions)
35
35
  16. [Lifecycle hooks: `defineLifecycle`](#16-lifecycle-hooks)
36
36
  17. [Environment variables & `*.server.ts`](#17-environment-variables)
37
- 18. [Typed routes codegen](#18-typed-routes-codegen)
37
+ 18. [Typed routes](#18-typed-routes)
38
38
  19. [Internationalization (i18n) utilities](#19-internationalization-utilities)
39
39
  20. [Image optimization (`<Image>` + `/_image`)](#20-image-optimization)
40
40
  21. [Build & run: CLI + programmatic API (`createDevServer`, `runBuild`, `loadUserConfig`)](#21-build--run)
@@ -175,14 +175,18 @@ import type { LoaderArgs, ActionArgs, MetaArgs } from "@bractjs/bractjs";
175
175
  import { redirect, json, HttpError } from "@bractjs/bractjs";
176
176
 
177
177
  // 1) loader — runs on every GET. Return value → useLoaderData().
178
- export async function loader({ request, params, context }: LoaderArgs) {
178
+ // `search` is the validated output of searchSchema (below), or the raw
179
+ // string record when the route has no schema.
180
+ export async function loader({ request, params, context, search }: LoaderArgs) {
179
181
  const post = await db.post.findById(params.id);
180
182
  if (!post) throw new HttpError(404, "Not found"); // → 404 page
181
183
  return { post };
182
184
  }
183
185
  export type LoaderData = Awaited<ReturnType<typeof loader>>;
184
186
 
185
- // 2) action — runs on POST / PUT / DELETE / PATCH.
187
+ // 2) action — runs on POST / PUT / DELETE / PATCH. For one route handling
188
+ // several buttons, compose it from `defineActions` (§5a) instead of a
189
+ // hand-rolled `intent` switch.
186
190
  export async function action({ request, params, context, formData }: ActionArgs) {
187
191
  await db.post.update(params.id, { title: formData.get("title") as string });
188
192
  return redirect("/blog");
@@ -210,7 +214,35 @@ export function ErrorBoundary({ error }: { error: unknown }) {
210
214
  return <p>Something broke: {error instanceof Error ? error.message : String(error)}</p>;
211
215
  }
212
216
 
213
- // 6) defaultthe page component (required for a renderable route).
217
+ // 6) searchSchemavalidate/coerce search params BEFORE loaders run (Zod,
218
+ // Valibot, or anything with parse/safeParse). Failure → 400; use
219
+ // .catch()/.default() per field for URLs that must tolerate junk.
220
+ // Loaders receive the OUTPUT via `search`; the client reads it with
221
+ // useSearch() — numbers stay numbers. (Typed end-to-end after codegen, §18.)
222
+ export const searchSchema = z.object({
223
+ page: z.coerce.number().int().positive().catch(1),
224
+ q: z.string().optional(),
225
+ });
226
+
227
+ // 7) shouldRevalidate — veto automatic data refetches (the SWR background
228
+ // refetch, and the revalidation after <Form>/fetcher mutations).
229
+ export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }) {
230
+ if (formMethod === "DELETE") return true; // always refetch after deletes
231
+ return defaultShouldRevalidate;
232
+ }
233
+
234
+ // 8) ssr + Fallback — selective SSR (see also §21 for app-wide SPA mode):
235
+ // true (default) full document SSR
236
+ // "data-only" loaders run on the server; component renders client-only
237
+ // false loader + component skipped during document SSR; the
238
+ // client fetches /_data after hydration.
239
+ // beforeLoad ALWAYS runs on the server — ssr:false is not an auth bypass.
240
+ export const ssr = "data-only";
241
+ export function Fallback() {
242
+ return <p>Loading dashboard…</p>; // SSR'd in the component's place
243
+ }
244
+
245
+ // 9) default — the page component (required for a renderable route).
214
246
  export default function BlogPost() {
215
247
  const { post } = useLoaderData<LoaderData>();
216
248
  return <article><h1>{post.title}</h1></article>;
@@ -220,7 +252,7 @@ export default function BlogPost() {
220
252
  **Execution order for a request:**
221
253
 
222
254
  ```
223
- beforeLoad → (action, if mutating method) → loaders (root + layouts + route, in parallel) → render
255
+ searchSchema → beforeLoad → (action, if mutating method) → loaders (root + layouts + route, in parallel) → render
224
256
  ```
225
257
 
226
258
  - **Loaders run concurrently** (root, every layout, and the route loader all in one `Promise.all`).
@@ -228,6 +260,44 @@ beforeLoad → (action, if mutating method) → loaders (root + layouts + route,
228
260
 
229
261
  > **Security:** put auth checks in `beforeLoad` (per route) or middleware (cross-cutting) — never in a component. `/_data` (used by `<Link>` soft-nav) runs `beforeLoad` and the loader, so a component-only check would still leak loader JSON. See §14.
230
262
 
263
+ ### Less boilerplate
264
+
265
+ **Infer loader data — `useLoaderData<typeof loader>()`.** Pass the loader function type and the data type is inferred from its return (no `LoaderData` alias to maintain). The `Response` redirect branch is excluded; `Deferred` fields (from `defer()`, §8) are preserved for `<Await>`. Same for `useActionData<typeof action>()`.
266
+ ```tsx
267
+ export async function loader() { return { post: await db.post.find() }; }
268
+ export default function Post() {
269
+ const { post } = useLoaderData<typeof loader>(); // typed — no hand-written type
270
+ }
271
+ ```
272
+
273
+ **Type search params on the args — `LoaderArgs<T>`.** Parameterize to drop the cast (the schema's output type):
274
+ ```tsx
275
+ export async function loader({ search }: LoaderArgs<{ page: number }>) {
276
+ return db.posts({ page: search.page }); // search.page is a number
277
+ }
278
+ // After codegen (§18), `LoaderArgsFor<"/posts">` types params + context + search from the route.
279
+ ```
280
+
281
+ ### `defineActions` — multi-button forms without an `intent` switch
282
+
283
+ A route action that handles several buttons usually devolves into `if (intent === …)`. `defineActions` composes it from one handler per intent, and `<Form intent="…">` (§10) renders the matching hidden input. An unknown/missing intent returns a 400 automatically (dev lists the known intents).
284
+ ```tsx
285
+ import { defineActions, safeValidate, formText } from "@bractjs/bractjs";
286
+
287
+ export const action = defineActions({
288
+ add: async ({ formData }) => {
289
+ const r = await safeValidate(TitleSchema, formData); // §13
290
+ if (!r.ok) return { error: r.firstError };
291
+ addTodo(r.data.title); return {};
292
+ },
293
+ toggle: ({ formData }) => { toggleTodo(formText(formData, "id")); return {}; },
294
+ delete: ({ formData }) => { deleteTodo(formText(formData, "id")); return {}; },
295
+ });
296
+ ```
297
+ ```tsx
298
+ <Form method="post" intent="toggle"><input type="hidden" name="id" value={id} /><button>Toggle</button></Form>
299
+ ```
300
+
231
301
  ---
232
302
 
233
303
  ## 6. Response helpers
@@ -317,22 +387,22 @@ export async function loader({ params }: LoaderArgs) {
317
387
  }
318
388
 
319
389
  export default function BlogPost() {
320
- const { post, comments } = useLoaderData<LoaderData>();
390
+ // useLoaderData<typeof loader>() infers the shape — `comments` stays a
391
+ // Deferred<Comment[]>, which <Await> accepts directly.
392
+ const { post, comments } = useLoaderData<typeof loader>();
321
393
  return (
322
394
  <article>
323
395
  <h1>{post.title}</h1>
324
- <Suspense fallback={<p>Loading comments…</p>}>
325
- <Await resolve={comments}>
326
- {(c) => <CommentList comments={c} />}
327
- </Await>
328
- </Suspense>
396
+ <Await resolve={comments} fallback={<p>Loading comments…</p>}>
397
+ {(c) => <CommentList comments={c} />}
398
+ </Await>
329
399
  </article>
330
400
  );
331
401
  }
332
402
  ```
333
403
 
334
- ### `<Await resolve={promise} fallback={…}>{(data) => …}</Await>`
335
- Unwraps a promise with React 19's `use()` inside its own `<Suspense>`. `isDeferred(value)` and the `Deferred` class are exported if you need to detect/construct deferred values manually.
404
+ ### `<Await resolve={promise | Deferred} fallback={…}>{(data) => …}</Await>`
405
+ Unwraps a promise (or a `Deferred` field from a `defer()` loader) with React 19's `use()` inside its own `<Suspense>`. `isDeferred(value)` and the `Deferred` class are exported if you need to detect/construct deferred values manually.
336
406
 
337
407
  ---
338
408
 
@@ -341,45 +411,93 @@ Unwraps a promise with React 19's `use()` inside its own `<Suspense>`. `isDeferr
341
411
  All hooks are SSR-safe (they return sensible values during SSR) and imported from `@bractjs/bractjs`.
342
412
 
343
413
  ### `useLoaderData<T>()` → `T`
344
- The current route's loader return value.
414
+ The current route's loader return value. **Pass the loader function type** to infer it (`Response` branch excluded, `Deferred` fields preserved) — no hand-written type to keep in sync. An explicit object type still works.
345
415
  ```ts
346
- const { post } = useLoaderData<LoaderData>();
416
+ const { post } = useLoaderData<typeof loader>(); // inferred from loader()
417
+ const { post } = useLoaderData<LoaderData>(); // or an explicit type
347
418
  ```
348
419
 
349
420
  ### `useActionData<T>()` → `T | null`
350
- The most recent action return value (null until an action runs).
421
+ The most recent action return value (null until an action runs). Like `useLoaderData`, accepts the action function type.
351
422
  ```ts
352
- const result = useActionData<{ error?: string }>();
423
+ const result = useActionData<typeof action>();
353
424
  ```
354
425
 
355
426
  ### `useParams<T>()` → `T`
356
- URL dynamic params. Pass a generic for typed params.
427
+ URL dynamic params. Pass the **route pattern** as a generic to type the result against your codegen'd routes (see §18); an object shape also works.
428
+ ```ts
429
+ const { id } = useParams<"/blog/:id">(); // { id: string } — typed from routes
430
+ const { id } = useParams<{ id: string }>(); // or a hand-written shape
431
+ ```
432
+ > The pattern is supplied by the caller because the framework can't infer the active route at the type level (React Router's `useParams` works the same way).
433
+
434
+ ### `useLocation()` → `{ pathname, search, hash, state, key }`
435
+ The current location — reactive on the client, request-derived during SSR (`hash` is always `""` there). `key` is the history entry's identity (what scroll restoration uses); `state` is whatever you passed via `navigate(to, { state })`.
357
436
  ```ts
358
- const { id } = useParams<{ id: string }>();
437
+ const location = useLocation();
438
+ const isActive = location.pathname.startsWith("/blog");
359
439
  ```
360
440
 
361
441
  ### `useNavigation()` → `{ state }`
362
- `"idle" | "loading" | "submitting"`.
442
+ `"idle" | "loading" | "submitting"`. Form/`submit()` mutations walk `"submitting"` → `"loading"` (revalidation) → `"idle"`.
363
443
  ```ts
364
444
  const { state } = useNavigation();
365
445
  if (state === "loading") return <Spinner />;
366
446
  ```
367
447
 
448
+ ### `useNavigate()` → `(to, { params?, search?, replace?, state? }) => Promise<void>`
449
+ Imperative soft navigation — the counterpart to `<Link>`. `to` autocompletes your routes (after codegen, §18); `params` and `search` are typed per route; any string is still accepted.
450
+ ```ts
451
+ const navigate = useNavigate();
452
+ await navigate("/blog/:id", { params: { id: "42" } }); // typed
453
+ await navigate("/posts", { search: { page: 2 } }); // typed search → /posts?page=2
454
+ await navigate("/login", { replace: true }); // replaceState, no history entry
455
+ await navigate("/wizard/2", { state: { from: "step1" } }); // read via useLocation().state
456
+ ```
457
+
458
+ ### `useRevalidator()` → `{ revalidate, state }`
459
+ Manually re-run the current route's loaders — for "Refresh" buttons, polling, or after out-of-band changes (e.g. a WebSocket event). Respects the route's `shouldRevalidate`. `state` is `"idle" | "loading"` and tracks only revalidation (navigations are `useNavigation()`).
460
+ ```ts
461
+ const { revalidate, state } = useRevalidator();
462
+ <button onClick={() => void revalidate()} disabled={state === "loading"}>Refresh</button>
463
+ ```
464
+
465
+ ### `useSearch()` / `useSetSearch()` — typed, validated search params
466
+ `useSearch` returns the route's **validated** search object (the `searchSchema` output — numbers are numbers, defaults applied). Validation runs once on the server; the client never re-runs the schema. `useSetSearch` merges a patch, writes the URL, and soft-navigates so loaders re-run. Set a key to `undefined` to delete it.
467
+ ```ts
468
+ const search = useSearch<"/posts">(); // { page: number; q?: string }
469
+ const setSearch = useSetSearch<"/posts">();
470
+ setSearch({ page: search.page + 1 }); // patch → /posts?page=2&q=…
471
+ setSearch((prev) => ({ q: undefined }), { replace: true }); // delete + replaceState
472
+ ```
473
+
368
474
  ### `useSearchParams<T>()` → `{ searchParams, getParam, setSearchParams }`
369
- Read/write URL query params; writing triggers a soft-nav loader re-run.
475
+ The low-level escape hatch: raw string `URLSearchParams` read/write; writing triggers a soft-nav loader re-run. Prefer `useSearch`/`useSetSearch` (above) when the route has a `searchSchema`.
370
476
  ```ts
371
- const { searchParams, getParam, setSearchParams } = useSearchParams<{ q: string }>();
372
- const q = getParam("q"); // string | null
373
- setSearchParams({ q: "bun" }); // replace all params
477
+ const { searchParams, getParam, setSearchParams } = useSearchParams<"/blog/:id">();
478
+ const q = getParam("q"); // string | null
479
+ setSearchParams({ q: "bun" }); // replace all params
374
480
  setSearchParams((prev) => { prev.set("page", "2"); return prev; }); // update
375
481
  ```
376
482
 
377
- ### `useFetcher()` → `{ data, state, load, submit }`
378
- Background fetch without navigating.
483
+ ### `useFetcher({ key? })` → `{ data, state, formData, formMethod, load, submit, Form, key }`
484
+ Background fetch/mutation without navigating. After a `submit`, the active route's loaders revalidate automatically (gated by `shouldRevalidate`). `formData`/`formMethod` are set from the moment `submit` is called — render them for **optimistic UI**. A `key` gives the fetcher a stable identity shared across components and surviving remounts; unkeyed fetchers are component-bound.
379
485
  ```ts
380
- const fetcher = useFetcher();
486
+ const fetcher = useFetcher({ key: `delete-${id}` });
381
487
  await fetcher.load("/products?q=bun"); // GET loader data
382
488
  await fetcher.submit("/cart", { method: "post", body: { id: "1" } });
489
+ const optimisticTitle = fetcher.formData?.get("title"); // while submitting
490
+ <fetcher.Form method="post" action="/cart">…</fetcher.Form> {/* scoped form, no navigation */}
491
+ ```
492
+
493
+ ### `useFetchers()` → `FetcherEntry[]`
494
+ Every active fetcher — the cross-component view for optimistic UI (e.g. dim each table row whose keyed delete fetcher is in flight elsewhere in the tree).
495
+ ```ts
496
+ const deleting = new Set(
497
+ useFetchers()
498
+ .filter((f) => f.state === "submitting" && f.key.startsWith("delete-"))
499
+ .map((f) => f.key.slice("delete-".length)),
500
+ );
383
501
  ```
384
502
 
385
503
  ### `useFetcher<T>({ stream: true })` → `{ connect }`
@@ -415,15 +533,37 @@ export default function BlogLayout() {
415
533
  }
416
534
  ```
417
535
 
418
- ### `<Link to prefetch? viewTransition?>`
419
- Soft-navigates without a full reload.
536
+ ### `<Link to params? search? prefetch? replace? viewTransition?>`
537
+ Soft-navigates without a full reload. After codegen (§18), `to` autocompletes your routes; for a dynamic route pass typed `params`, and `search` is typed by the target's `searchSchema`. Building the URL yourself still works, so existing links need no changes.
420
538
  ```tsx
421
- <Link to="/blog/42">Read</Link>
422
- <Link to="/about" prefetch="hover">About</Link> {/* preload chunk + loader on hover */}
423
- <Link to="/gallery" viewTransition>Gallery</Link> {/* use View Transitions API */}
539
+ <Link to="/blog/:id" params={{ id: "42" }}>Read</Link> {/* typed route + params */}
540
+ <Link to={`/blog/${id}`}>Read</Link> {/* built string also fine */}
541
+ <Link to="/posts" search={{ page: 2 }}>Page 2</Link> {/* typed search params */}
542
+ <Link to="/about" prefetch="intent">About</Link> {/* preload on hover/focus intent */}
543
+ <Link to="/gallery" viewTransition>Gallery</Link> {/* use View Transitions API */}
424
544
  ```
425
545
  Modifier-clicks (ctrl/cmd/shift/alt) fall back to native browser navigation.
426
546
 
547
+ **Prefetch modes** — prefetching warms the route chunk (`modulepreload`) *and* the loader cache, so the click commits instantly (prefetched data stays fresh ≥ 30s; concurrent data prefetches are capped at 6 so long lists can't stampede the server):
548
+
549
+ | mode | when |
550
+ |---|---|
551
+ | `"none"` (default) | never |
552
+ | `"intent"` | hover **or focus**, after a 100 ms delay (canceled on fly-by) — best default |
553
+ | `"hover"` | immediately on mouseenter (legacy alias) |
554
+ | `"viewport"` | when the link scrolls into view (one shared IntersectionObserver) — for lists |
555
+ | `"render"` | as soon as the link mounts |
556
+
557
+ ### `<ScrollRestoration getKey? storageKey?>`
558
+ Restores the window scroll position on back/forward (and reload), scrolls to top — or to the `#hash` element — on new navigations. Render it **once** in `app/root.tsx`, next to `<Scripts />`. Positions persist in `sessionStorage`. Pass `getKey={(location) => location.pathname}` to share one position across every visit to the same path.
559
+ ```tsx
560
+ <body>
561
+ <Outlet />
562
+ <ScrollRestoration />
563
+ <Scripts />
564
+ </body>
565
+ ```
566
+
427
567
  ### `<Form method action?>`
428
568
  Fetch-based submission that re-runs the current route's loader after the action.
429
569
  ```tsx
@@ -561,6 +701,27 @@ export async function action({ formData }: ActionArgs) {
561
701
  - Repeated `FormData` keys become arrays automatically.
562
702
  - On failure it throws a `Response.json({ errors }, { status: 400 })`. The exported `ValidationError` type and `FieldErrors` shape describe the structure.
563
703
 
704
+ ### `safeValidate` — validate without try/catch (recommended for inline form errors)
705
+
706
+ Returns a result instead of throwing — the clean idiom when you want to render field errors rather than a 400 page. `firstError` is the first field message.
707
+ ```ts
708
+ import { safeValidate } from "@bractjs/bractjs";
709
+
710
+ export const action = defineActions({
711
+ create: async ({ formData }) => {
712
+ const r = await safeValidate(Schema, formData);
713
+ if (!r.ok) return { error: r.firstError, fieldErrors: r.fieldErrors };
714
+ await db.post.create(r.data); // r.data is typed + coerced
715
+ return redirect("/blog");
716
+ },
717
+ });
718
+ ```
719
+ If you prefer to keep calling `validate()` and catching: `isValidationResponse(err)` narrows the thrown 400, and `readValidationError(res)` parses it into `{ fieldErrors, firstError }`.
720
+
721
+ ### FormData helpers
722
+
723
+ `formText(formData, key)` returns a string (`""` for missing or File values) — no more `String(formData.get(k) ?? "")`. `formValues(formData, keys?)` collects string fields into an object (all, or a named subset).
724
+
564
725
  ---
565
726
 
566
727
  ## 14. Middleware
@@ -602,10 +763,13 @@ pipeline.use(authGuard({ session, required: true })); // 401 if no session.user
602
763
  pipeline.use(csp({
603
764
  directives: { "img-src": "'self' data: https://cdn.example", "frame-ancestors": "'none'" },
604
765
  reportOnly: false, // true → Content-Security-Policy-Report-Only
766
+ strict: false, // true → drop 'unsafe-inline' from style-src (see below)
605
767
  }));
606
768
  ```
607
769
  Read the nonce inside a component/middleware with `getCspNonce(context)` (key: `CSP_NONCE_KEY`) to nonce your own inline scripts.
608
770
 
771
+ `script-src` is always nonce-based (`'nonce-…' 'strict-dynamic'`), so injected `<script>` cannot execute. The default `style-src` allows `'unsafe-inline'` for ergonomics (React inline styles, CSS-in-JS); this leaves inline-style injection possible. Pass `strict: true` (or override `style-src` with a nonce/hash) if your app serves all styles from same-origin stylesheets.
772
+
609
773
  ### Custom middleware
610
774
 
611
775
  ```ts
@@ -738,48 +902,55 @@ On the server, read env via `Bun.env.*` directly.
738
902
 
739
903
  ---
740
904
 
741
- ## 18. Typed routes codegen
905
+ ## 18. Typed routes
742
906
 
743
- Generate per-route param types and a type-safe URL builder from your route files.
907
+ Generate type-safe routing from your route files one command wires `<Link>`, `useNavigate`, `useParams`, and `useSearchParams` to your actual routes.
744
908
 
745
909
  ```sh
746
910
  bractjs codegen # ./app → ./app/route-types.gen.ts
747
911
  bractjs codegen ./app ./app/types.ts # explicit paths
748
912
  ```
749
913
 
750
- Runs automatically during `bractjs build`. The generated file provides:
914
+ **You rarely run this by hand.** `bractjs new` generates it on scaffold, `bractjs dev` regenerates it on boot and whenever you add/remove/rename a route file, and `bractjs build` runs it too. The output is deterministic (route-sorted) and carries a `// bractjs:routes <hash>` fingerprint, so re-runs that change nothing don't rewrite the file (no editor reload loops); on boot the dev server prints precisely what drifted. Make sure the generated file is part of your TypeScript program (it is, if your `tsconfig.json` `include`s `app/`). It augments BractJS's `Register` interface, after which the runtime components and hooks become type-safe — **no per-route imports needed**:
751
915
 
752
- ```ts
753
- export type AppRoutes = "/" | "/blog/:id" | "/org/:orgId/repo/:repoId";
754
-
755
- export type RouteParams<T extends AppRoutes> =
756
- T extends "/blog/:id" ? { id: string } : Record<never, never>;
916
+ ```tsx
917
+ <Link to="/blog/:id" params={{ id }} /> // ✅ "/blog/:id" autocompletes; params typed
918
+ <Link to="/blgo/:id" params={{ id }} /> // ❌ typo'd route — compile error
919
+ <Link to="/blog/:id" params={{ x: id }} /> // ❌ wrong param key — compile error
757
920
 
758
- export type TypedLoaderArgs<T extends AppRoutes> = { request: Request; params: RouteParams<T>; context: Record<string, unknown> };
759
- export type TypedActionArgs<T extends AppRoutes> = TypedLoaderArgs<T> & { formData: FormData };
921
+ const navigate = useNavigate();
922
+ navigate("/blog/:id", { params: { id } }); // same typing as <Link>
760
923
 
761
- export const routes = {
762
- "/": () => "/",
763
- "/blog/:id": (p: { id: string }) => `/blog/${p.id}`,
764
- } as const;
924
+ const { id } = useParams<"/blog/:id">(); // id: string
765
925
  ```
766
926
 
767
- Use them for typed loaders and safe navigation:
927
+ Building the URL yourself (`<Link to={`/blog/${id}`}>`) still type-checks, so adopting codegen never breaks existing links.
928
+
929
+ The generated file also exports types/helpers for typed loaders and explicit URL building:
768
930
 
769
931
  ```ts
770
- import type { TypedLoaderArgs, RouteParams } from "../route-types.gen.ts";
932
+ import type { TypedLoaderArgs } from "../route-types.gen.ts";
771
933
  import { routes } from "../route-types.gen.ts";
772
934
 
773
935
  export async function loader({ params }: TypedLoaderArgs<"/blog/:id">) {
774
- return db.post.findById(params.id); // params.id: string
936
+ return db.post.findById(params.id); // params.id: string
775
937
  }
938
+ routes["/blog/:id"]({ id: "123" }); // → "/blog/123" (typo'd routes won't compile)
939
+ ```
940
+
941
+ **Type a route's search params or context** by augmenting the package interfaces — `SearchParams<T>` / `Context<T>` and `useSearchParams<T>()` pick it up:
776
942
 
777
- const { id } = useParams<RouteParams<"/blog/:id">>();
778
- routes["/blog/:id"]({ id: "123" }); // → "/blog/123" (typo'd routes won't compile)
943
+ ```ts
944
+ declare module "@bractjs/bractjs" {
945
+ interface RouteSearchParamsMap { "/blog": { page: string; sort: string } }
946
+ interface RouteContextMap { "/admin": { user: { id: string; role: "admin" } } }
947
+ }
779
948
  ```
780
949
 
781
950
  You can also call `writeRouteTypes(appDir, outPath?)` / `generateRouteTypes(appDir)` programmatically.
782
951
 
952
+ > **Heads up:** earlier versions documented `useParams<RouteParams<"/blog/:id">>()` and untyped `<Link to={string}>`. The route-literal form (`useParams<"/blog/:id">()`) and typed `<Link>`/`useNavigate` are the current API; the old forms still compile.
953
+
783
954
  ---
784
955
 
785
956
  ## 19. Internationalization utilities
@@ -917,6 +1088,37 @@ Bun.serve({ port: 3000, fetch: handler });
917
1088
 
918
1089
  `renderRoute(options)` (low-level SSR render) and the `RenderOptions`/`ServerManifest`/`BractJSConfig` types are also exported for advanced embedding.
919
1090
 
1091
+ ### Rendering modes
1092
+
1093
+ Three levels of SSR control, all opt-in:
1094
+
1095
+ **Per-route selective SSR** — `export const ssr = false | "data-only"` plus a `Fallback` component on any route (§5 item 8). The document SSRs the Fallback; the client swaps in the real component after hydration. `beforeLoad` always runs server-side.
1096
+
1097
+ **App-wide SPA mode** — `ssr: false` in `bractjs.config.ts`:
1098
+
1099
+ ```ts
1100
+ // bractjs.config.ts
1101
+ export default {
1102
+ ssr: false, // every document GET serves one static shell
1103
+ };
1104
+ ```
1105
+
1106
+ SPA mode means **"no document SSR", not "no server"**: the Bun server keeps serving `/_data` (loaders), `/_action`/mutations (CSRF gate intact), `/_image`, API routes, and static assets. `bractjs build` emits the shell at `build/client/__spa.html`; in dev it renders on the fly so `root.tsx` edits show up. Constraints: the root component renders without loader data and with a `/` location, so keep location/loader-dependent markup out of `root.tsx`; route-level `meta()` only applies after hydration (no SEO for route content).
1107
+
1108
+ **Build-time prerendering (SSG)** — `prerender` in `bractjs.config.ts`:
1109
+
1110
+ ```ts
1111
+ // bractjs.config.ts
1112
+ export default {
1113
+ prerender: ["/", "/about", "/blog/intro"],
1114
+ // or resolve dynamically: prerender: async () => (await db.posts()).map(p => `/blog/${p.slug}`),
1115
+ };
1116
+ ```
1117
+
1118
+ `bractjs build` runs the real loaders in-process (anything they need — DB, env — must be available at build time), writing each path's HTML **and** its `/_data` payload (used by client navigations *into* a prerendered page) under `build/client/_prerender/`. In production, clean URLs are served from these files before dynamic SSR; **a query string opts the request back into SSR** (the file was rendered without one). Paths must be concrete — expand `"/blog/:slug"` yourself; the build fails on patterns. `runPrerender(options)` is exported for custom pipelines.
1119
+
1120
+ Deployment notes: with `bun build --compile`, ship `build/client/` (including `_prerender/`) alongside the binary — or pass it via `--asset`. On Cloudflare, upload `build/client/` as static assets so the platform serves prerendered files before the worker runs.
1121
+
920
1122
  ---
921
1123
 
922
1124
  ## 22. Single-binary deployment (`bun build --compile`)
@@ -1028,11 +1230,17 @@ plugins: [
1028
1230
 
1029
1231
  ## 25. Configuration reference
1030
1232
 
1031
- All fields optional. Put them in `bractjs.config.ts` (default export) or pass to `createServer` / `createDevServer` / `runBuild`.
1233
+ All fields optional. Wrap the default export in `defineConfig()` for autocomplete + type-checking (it's a no-op identity helper), or pass these to `createServer` / `createDevServer` / `runBuild`.
1234
+
1235
+ ```ts
1236
+ import { defineConfig } from "@bractjs/bractjs";
1237
+ export default defineConfig({ port: 3000, clientEnv: ["PUBLIC_API_URL"] });
1238
+ ```
1032
1239
 
1033
1240
  | Field | Type | Default | Description |
1034
1241
  |-------|------|---------|-------------|
1035
1242
  | `port` | `number` | `3000` | TCP port |
1243
+ | `hmrPort` | `number` | `3001` | Dev HMR WebSocket port (`bractjs dev` only) |
1036
1244
  | `appDir` | `string` | `"./app"` | Contains `routes/` and `root.tsx` |
1037
1245
  | `publicDir` | `string` | `"./public"` | Static assets (served no-cache) |
1038
1246
  | `buildDir` | `string` | `"./build"` | Build output |
@@ -1043,6 +1251,8 @@ All fields optional. Put them in `bractjs.config.ts` (default export) or pass to
1043
1251
  | `plugins` | `BunPlugin[]` | `[]` | Extra client-build plugins |
1044
1252
  | `adapter` | `BractAdapter` | `BunAdapter` | Custom server adapter |
1045
1253
  | `i18n` | `I18nConfig` | — | Locale config consumed by the i18n utilities |
1254
+ | `ssr` | `boolean` | `true` | `false` → SPA mode: static shell for every document GET (§21) |
1255
+ | `prerender` | `string[] \| () => paths` | — | Paths to prerender at build time (§21) |
1046
1256
  | `onStart` / `onShutdown` / `onError` | hooks | — | Lifecycle (§16) |
1047
1257
 
1048
1258
  `loadUserConfig()` validates these shapes and throws a clear error on an obvious mistake (e.g. a string `port`).
@@ -1053,7 +1263,7 @@ All fields optional. Put them in `bractjs.config.ts` (default export) or pass to
1053
1263
 
1054
1264
  Everything importable from `@bractjs/bractjs` ([src/index.ts](src/index.ts)):
1055
1265
 
1056
- **Server / runtime:** `createServer`, `buildFetchHandler`, `renderRoute`, `redirect`, `json`, `error`, `defineContext`, `route`, `validate`, `BunAdapter`, `defineLifecycle`
1266
+ **Server / runtime:** `createServer`, `buildFetchHandler`, `renderRoute`, `redirect`, `json`, `error`, `defineContext`, `route`, `validate`, `safeValidate`, `isValidationResponse`, `readValidationError`, `validateSearch`, `searchParamsToObject`, `formText`, `formValues`, `defineActions`, `BunAdapter`, `defineLifecycle`, `renderSpaShell`
1057
1267
 
1058
1268
  **Errors:** `BractJSError`, `HttpError`, `isRedirect`, `isHttpError`, `isBractJSError`
1059
1269
 
@@ -1065,15 +1275,17 @@ Everything importable from `@bractjs/bractjs` ([src/index.ts](src/index.ts)):
1065
1275
 
1066
1276
  **Sessions:** `createCookieSession`
1067
1277
 
1068
- **Components:** `Outlet`, `Link`, `Form`, `Scripts`, `LiveReload`, `Await`, `Image`
1278
+ **Components:** `Outlet`, `Link`, `Form`, `Scripts`, `LiveReload`, `Await`, `Image`, `ScrollRestoration`
1279
+
1280
+ **Hooks:** `useLoaderData`, `useActionData`, `useLocation`, `useParams`, `useNavigation`, `useNavigate`, `useFetcher`, `useFetchers`, `useRevalidator`, `useSearch`, `useSetSearch`, `useSearchParams`, `useBlocker`, `useLocale`, `useLocalizedLink`
1069
1281
 
1070
- **Hooks:** `useLoaderData`, `useActionData`, `useParams`, `useNavigation`, `useFetcher`, `useSearchParams`, `useBlocker`, `useLocale`, `useLocalizedLink`
1282
+ **Search serialization:** `serializeSearch`
1071
1283
 
1072
1284
  **i18n:** `wrapRoutesWithLocale`, `stripLocale`, `localizedDataPath`
1073
1285
 
1074
1286
  **Client RPC:** `createClient`
1075
1287
 
1076
- **Build / programmatic:** `createDevServer`, `runBuild`, `loadUserConfig`
1288
+ **Build / programmatic:** `createDevServer`, `runBuild`, `loadUserConfig`, `defineConfig`, `runPrerender`
1077
1289
 
1078
1290
  **Codegen:** `writeModuleRegistries`, `writeManifestModule`, `generateRouteRegistry`, `generateActionRegistry`, `generateManifestModule`
1079
1291
 
@@ -1081,7 +1293,20 @@ Everything importable from `@bractjs/bractjs` ([src/index.ts](src/index.ts)):
1081
1293
 
1082
1294
  **Adapters:** `createCloudflareAdapter`, `makeCloudflareHandler`
1083
1295
 
1084
- **Types:** `LoaderArgs`, `ActionArgs`, `MetaArgs`, `MetaDescriptor`, `LoaderFunction`, `ActionFunction`, `MetaFunction`, `RouteModule`, `RouteDefinition`, `RouteFile`, `Segment`, `BractJSConfig`, `RenderOptions`, `ServerManifest`, `ContextFactory`, `ApiRouteDefinition`, `AppApiRoutes`, `FieldErrors`, `ValidationError`, `BractAdapter`, `LifecycleHooks`, `MiddlewareFn`, `MiddlewareContext`, `CorsOptions`, `AuthGuardOptions`, `CspOptions`, `SessionStorageLike`, `SessionLike`, `Session`, `SessionStorage`, `SessionData`, `CookieSessionOptions`, `CommitOptions`, `ImageProps`, `ImageFormat`, `ImageFit`, `SearchParamsResult`, `I18nConfig`, `DevServerOptions`, `DevServer`, `BuildConfig`, `CodegenResult`, `ModuleRegistry`, `BractJSContextValue`, `RouteManifest`
1296
+ **Types:** `LoaderArgs`, `ActionArgs`, `MetaArgs`, `MetaDescriptor`, `LoaderFunction`, `ActionFunction`, `MetaFunction`, `RouteModule`, `RouteDefinition`, `RouteFile`, `Segment`, `RouterLocation`, `ShouldRevalidateArgs`, `ShouldRevalidateFunction`, `BractJSConfig`, `RenderOptions`, `ServerManifest`, `ContextFactory`, `ApiRouteDefinition`, `AppApiRoutes`, `FieldErrors`, `ValidationError`, `BractAdapter`, `LifecycleHooks`, `MiddlewareFn`, `MiddlewareContext`, `CorsOptions`, `AuthGuardOptions`, `CspOptions`, `SessionStorageLike`, `SessionLike`, `Session`, `SessionStorage`, `SessionData`, `CookieSessionOptions`, `CommitOptions`, `ImageProps`, `ImageFormat`, `ImageFit`, `SearchParamsResult`, `SetSearchFn`, `SetSearchOptions`, `SearchOutputFor`, `InferSchemaOutput`, `LoaderData`, `ActionData`, `SafeValidateResult`, `FetcherResult`, `FetcherEntry`, `FetcherState`, `FetcherFormProps`, `UseFetcherOptions`, `Revalidator`, `ScrollRestorationProps`, `PrerenderOptions`, `PrerenderResult`, `I18nConfig`, `DevServerOptions`, `DevServer`, `BuildConfig`, `CodegenResult`, `ModuleRegistry`, `BractJSContextValue`, `RouteManifest`
1297
+
1298
+ ---
1299
+
1300
+ ## 27. Security model
1301
+
1302
+ BractJS ships secure defaults, but a few behaviors are worth understanding so you don't accidentally widen your attack surface.
1303
+
1304
+ - **What `"use server"` publishes.** Every exported **function** of a `"use server"` module becomes an unauthenticated RPC endpoint reachable via `POST /_action` and `GET /_stream`. In files under `routes/`, framework exports (`loader`, `action`, `default`, `meta`, `beforeLoad`, `context`, `ErrorBoundary`, `Fallback`, `config`, `searchSchema`, `ssr`) are **not** registered as actions — but any *other* exported function is. Treat each exported action as a public endpoint: **do your own authorization inside the function body** (read the session, check the user). The CSRF gate only proves the call is same-origin; it does not authenticate the user.
1305
+ - **`/_stream` calls actions with no arguments.** A streaming action invoked over `GET /_stream` receives no caller input. It must be safe to call with none and must authorize itself.
1306
+ - **CORS + credentials.** Listing an origin in `cors({ origin: [...], credentials: true })` fully trusts that origin for credentialed cross-origin reads. Only list origins you control. `credentials:true` with `origin:"*"` is refused at setup. Never add `X-BractJS-Action` to `Access-Control-Allow-Headers` — it is the CSRF gate.
1307
+ - **Error messages.** In production, loader/action errors are surfaced to the client as a generic message; the real message + stack appear only in dev (`NODE_ENV=development`). For user-facing structured errors throw an `HttpError` (its message *is* shown) — never put secrets in a raw `Error.message`.
1308
+ - **CSP `style-src`.** The opt-in `csp()` middleware nonces all scripts, but its default `style-src` includes `'unsafe-inline'` for ergonomics. Pass `csp({ strict: true })` (or override `style-src` with a nonce/hash) if you want to block inline-style injection.
1309
+ - **Already handled for you:** path traversal + symlink escape on `/public` and `/_image`, open-redirect neutralization (`redirect()` requires `{ allowExternal: true }` to go off-origin), XSS-safe SSR data island (`safeStringify`), prototype-pollution rejection on action JSON bodies, request body-size caps, signed/constant-time-verified cookie sessions, and CSRF via layered `Sec-Fetch-Site` + custom-header + `Origin` checks.
1085
1310
 
1086
1311
  ---
1087
1312
 
@@ -1098,7 +1323,7 @@ See [CHANGELOG.md](CHANGELOG.md) for release history.
1098
1323
  - **Streaming SSR** — `renderToReadableStream()` with `defer()` and `<Await>`.
1099
1324
  - **File-based routing** — drop a file in `app/routes/`.
1100
1325
  - **Full-stack** — loaders, actions, sessions, server actions, typed API routes, middleware.
1101
- - **Typed routes** — codegen produces per-route params and a type-safe URL builder.
1326
+ - **Typed routes** — codegen wires `<Link>`, `useNavigate`, and `useParams` to your routes (autocompleted paths, typed params), plus a type-safe URL builder.
1102
1327
  - **Single-binary** — `bun build --compile` to one executable.
1103
1328
 
1104
1329
  ## License
package/bin/cli.ts CHANGED
@@ -43,6 +43,10 @@ async function scaffoldNew(appName: string): Promise<void> {
43
43
  try {
44
44
  const { writeModuleRegistries } = await import("../src/codegen/module-registry.ts");
45
45
  await writeModuleRegistries(join(appDir, "app"));
46
+ // Generate typed routes so the scaffold has working <Link>/useParams typing
47
+ // out of the box (no manual `bractjs codegen` step before first dev run).
48
+ const { writeRouteTypes } = await import("../src/codegen/route-codegen.ts");
49
+ await writeRouteTypes(join(appDir, "app"));
46
50
  // Manifest stub — overwritten by `bractjs codegen:manifest` after a build
47
51
  const stubManifest = [
48
52
  "// Stub manifest — replaced by `bractjs codegen:manifest` after running",
@@ -101,6 +105,16 @@ switch (command) {
101
105
  const { loadUserConfig } = await import("../src/config/load.ts");
102
106
  const userCfg = await loadUserConfig();
103
107
  await runBuild({ appDir: "./app", buildDir: "./build", ...userCfg });
108
+ if (userCfg.prerender) {
109
+ const { runPrerender } = await import("../src/build/prerender.ts");
110
+ const { written } = await runPrerender({
111
+ prerender: userCfg.prerender,
112
+ appDir: userCfg.appDir ?? "./app",
113
+ publicDir: userCfg.publicDir,
114
+ buildDir: userCfg.buildDir ?? "./build",
115
+ });
116
+ console.log(`[bract] prerender → ${written.length} files`);
117
+ }
104
118
  break;
105
119
  }
106
120
 
@@ -109,7 +123,10 @@ switch (command) {
109
123
  // output. Users can still override with `NODE_ENV=staging bractjs start`.
110
124
  if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
111
125
  const { createServer } = await import("../src/server/serve.ts");
112
- createServer({ port: 3000, buildDir: "./build" });
126
+ const { loadUserConfig } = await import("../src/config/load.ts");
127
+ // The config carries runtime-relevant fields too (ssr, port, dirs).
128
+ const userCfg = await loadUserConfig();
129
+ createServer({ port: 3000, buildDir: "./build", ...userCfg });
113
130
  break;
114
131
  }
115
132
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bractjs/bractjs",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "Production-grade SSR framework for Bun + React 19. File-based routing, streaming SSR, server actions, typed routes.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/bractjs/bractjs#readme",
@@ -0,0 +1,29 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { buildPath } from "../client/build-path.ts";
3
+
4
+ describe("buildPath", () => {
5
+ test("substitutes a single :param", () => {
6
+ expect(buildPath("/blog/:id", { id: "42" })).toBe("/blog/42");
7
+ });
8
+
9
+ test("substitutes multiple params", () => {
10
+ expect(buildPath("/u/:user/post/:post", { user: "ann", post: "7" })).toBe("/u/ann/post/7");
11
+ });
12
+
13
+ test("passes static patterns through untouched", () => {
14
+ expect(buildPath("/about", {})).toBe("/about");
15
+ expect(buildPath("/", {})).toBe("/");
16
+ });
17
+
18
+ test("URL-encodes param values", () => {
19
+ expect(buildPath("/search/:q", { q: "a b/c" })).toBe("/search/a%20b%2Fc");
20
+ });
21
+
22
+ test("coerces numbers to strings", () => {
23
+ expect(buildPath("/n/:id", { id: 7 })).toBe("/n/7");
24
+ });
25
+
26
+ test("leaves an absent param's segment intact (surfaces as an obvious bad URL)", () => {
27
+ expect(buildPath("/blog/:id", {})).toBe("/blog/:id");
28
+ });
29
+ });