@bractjs/bractjs 0.1.29 → 0.2.1

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 ADDED
@@ -0,0 +1,1449 @@
1
+ # BractJS
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@bractjs/bractjs)](https://www.npmjs.com/package/@bractjs/bractjs)
4
+ [![license](https://img.shields.io/npm/l/@bractjs/bractjs)](LICENSE)
5
+
6
+ > Production-grade SSR framework for **Bun + React 19**.
7
+ > File-based routing · Parallel loaders · Streaming SSR · Built-in HMR · Server Actions · Typed routes · Single-binary deploy.
8
+
9
+ ## Requirements
10
+
11
+ - [Bun](https://bun.sh) ≥ 1.1 — no Node.js support
12
+ - React 19 (peer dependency)
13
+
14
+ This README is a **step-by-step guide to every function and feature** BractJS exports. Each section is self-contained and ordered from "first app" to "advanced". Every symbol shown here is a real export from `@bractjs/bractjs` (see [packages/core/src/index.ts](packages/core/src/index.ts)).
15
+
16
+ ---
17
+
18
+ ## Table of Contents
19
+
20
+ 1. [Install & create an app](#1-install--create-an-app)
21
+ 2. [Project structure](#2-project-structure)
22
+ 3. [The root layout (`app/root.tsx`)](#3-the-root-layout-approotsx)
23
+ 4. [File-based routing](#4-file-based-routing)
24
+ 5. [Route module API: `loader`, `action`, `meta`, `beforeLoad`, `ErrorBoundary`, `default`](#5-route-module-api)
25
+ 6. [Response helpers: `json`, `redirect`, `error`, `HttpError`](#6-response-helpers)
26
+ 7. [Per-route context: `defineContext`](#7-per-route-context-definecontext)
27
+ 8. [Streaming data: `defer`, `Deferred`, `isDeferred`, `<Await>`](#8-streaming-data)
28
+ 9. [Client hooks](#9-client-hooks)
29
+ 10. [Client components: `<Outlet>`, `<Link>`, `<Form>`, `<Scripts>`, `<LiveReload>`, `<Image>`, `<Toaster>`](#10-client-components)
30
+ 11. [Server Actions (`"use server"`) & client-only (`"use client"`)](#11-server-actions--client-components)
31
+ 12. [Typed API routes: `route` + `createClient`](#12-typed-api-routes)
32
+ 13. [Input validation: `validate`](#13-input-validation-validate)
33
+ 14. [Middleware: `pipeline`, `requestLogger`, `cors`, `authGuard`, `csp`](#14-middleware)
34
+ 15. [Sessions: `createCookieSession`](#15-sessions)
35
+ 16. [Lifecycle hooks: `defineLifecycle`](#16-lifecycle-hooks)
36
+ 17. [Environment variables & `*.server.ts`](#17-environment-variables)
37
+ 18. [Typed routes](#18-typed-routes)
38
+ 19. [Internationalization (i18n) utilities](#19-internationalization-utilities)
39
+ 20. [Image optimization (`<Image>` + `/_image`)](#20-image-optimization)
40
+ 21. [Build & run: CLI + programmatic API (`createDevServer`, `runBuild`, `loadUserConfig`)](#21-build--run)
41
+ 22. [Single-binary deployment (`bun build --compile`)](#22-single-binary-deployment)
42
+ 23. [Custom adapters (`BunAdapter`, Cloudflare)](#23-custom-adapters)
43
+ 24. [Build plugins (for custom `Bun.build`)](#24-build-plugins)
44
+ 25. [Configuration reference](#25-configuration-reference)
45
+ 26. [Full export index](#26-full-export-index)
46
+
47
+ ---
48
+
49
+ ## 1. Install & create an app
50
+
51
+ BractJS requires [Bun](https://bun.sh). There is no Node.js runtime path.
52
+
53
+ ```sh
54
+ # Scaffold a new app
55
+ bunx bractjs new my-app
56
+ cd my-app
57
+
58
+ # Start the dev server (HMR on http://localhost:3000)
59
+ bun run dev
60
+ ```
61
+
62
+ `bractjs new <name>` copies the scaffold template, runs `bun install`, and seeds `app/_generated/` so the single-binary entry typechecks before your first build.
63
+
64
+ Add it to an existing project instead:
65
+
66
+ ```sh
67
+ bun add @bractjs/bractjs react react-dom
68
+ ```
69
+
70
+ `react` and `react-dom` v19 are **peer dependencies** — BractJS ships zero other runtime deps.
71
+
72
+ ---
73
+
74
+ ## 2. Project structure
75
+
76
+ ```
77
+ my-app/
78
+ ├── app/
79
+ │ ├── root.tsx # required — the <html> document shell
80
+ │ ├── server.ts # single-binary entry (bun build --compile)
81
+ │ ├── lifecycle.ts # optional — onStart / onShutdown / onError
82
+ │ ├── route-types.gen.ts # generated by `bractjs codegen`
83
+ │ ├── _generated/ # generated by `bractjs codegen:registry` / `:manifest`
84
+ │ ├── actions.ts # "use server" actions (optional)
85
+ │ └── routes/
86
+ │ ├── _index.tsx # → /
87
+ │ ├── about.tsx # → /about
88
+ │ ├── blog/
89
+ │ │ ├── layout.tsx # wraps /blog/*
90
+ │ │ ├── _index.tsx # → /blog
91
+ │ │ └── [id].tsx # → /blog/:id
92
+ │ └── docs/
93
+ │ └── [...slug].tsx # → /docs/* (catch-all)
94
+ ├── public/ # static assets, served at /public/*
95
+ ├── bractjs.config.ts # optional config (see §25)
96
+ └── build/ # generated — do not edit
97
+ ```
98
+
99
+ Defaults: `appDir="./app"`, `publicDir="./public"`, `buildDir="./build"`, `port=3000`. All overridable (§25).
100
+
101
+ ---
102
+
103
+ ## 3. The root layout (`app/root.tsx`)
104
+
105
+ **Required.** It provides the `<html>` document shell and is always rendered. Use the `<Outlet>`, `<Scripts>`, and `<LiveReload>` components from the package.
106
+
107
+ ```tsx
108
+ // app/root.tsx
109
+ import { Outlet, Scripts, LiveReload } from "@bractjs/bractjs";
110
+
111
+ export function meta() {
112
+ return [
113
+ { title: "My App" },
114
+ { name: "viewport", content: "width=device-width, initial-scale=1" },
115
+ ];
116
+ }
117
+
118
+ export default function Root() {
119
+ return (
120
+ <html lang="en">
121
+ <head>
122
+ <meta charSet="utf-8" />
123
+ {/* BractJS hoists <title>/<meta> from every route's meta() into <head> */}
124
+ </head>
125
+ <body>
126
+ <Outlet /> {/* renders the matched route tree */}
127
+ <Scripts /> {/* injects the client bundle + bootstrap data */}
128
+ <LiveReload /> {/* dev-only HMR client; no-op in production */}
129
+ </body>
130
+ </html>
131
+ );
132
+ }
133
+ ```
134
+
135
+ **Step by step:**
136
+
137
+ 1. `export default function Root()` returns the full `<html>` document.
138
+ 2. Put `<Outlet />` where the page content should render.
139
+ 3. Put `<Scripts />` at the end of `<body>` — it's a marker the SSR pipeline replaces with the hashed client entry + `window.__BRACTJS_DATA__`.
140
+ 4. `<LiveReload />` renders an HMR client script in dev and `null` in production.
141
+ 5. Optionally `export function meta()` for site-wide defaults (route `meta()` overrides per descriptor).
142
+
143
+ > `<title>`/`<meta>` tags from any route's `meta()` are rendered into `<head>` via React 19 document-metadata hoisting — you do not place them manually.
144
+
145
+ ---
146
+
147
+ ## 4. File-based routing
148
+
149
+ Drop a file in `app/routes/`; it becomes a route. BractJS scans at startup and builds a trie.
150
+
151
+ | File | URL |
152
+ |------|-----|
153
+ | `routes/_index.tsx` | `/` |
154
+ | `routes/about.tsx` | `/about` |
155
+ | `routes/blog/_index.tsx` | `/blog` |
156
+ | `routes/blog/[id].tsx` | `/blog/:id` |
157
+ | `routes/users/[[id]].tsx` | `/users` **and** `/users/:id` (optional) |
158
+ | `routes/docs/[...slug].tsx` | `/docs/*` (catch-all) |
159
+ | `routes/blog/layout.tsx` | wraps all `/blog/*` routes |
160
+ | `routes/(marketing)/about.tsx` | `/about` (group adds no URL segment) |
161
+
162
+ - `[param]` → a dynamic segment, read via `useParams()` / `params` arg.
163
+ - `[[param]]` → an **optional** dynamic segment: the route matches whether the segment is present or not (when absent, `params.param` is simply unset).
164
+ - `[...name]` → a catch-all; the rest of the path lands in `params.name`.
165
+ - `layout.tsx` in any directory wraps every route under it (layouts nest: `root → blog/layout → blog/[id]`).
166
+ - `(group)/` → a **route group**: the folder organizes files and contributes its `layout.tsx`, but adds **no** URL segment. Use it to give a set of routes a shared layout without a shared path prefix.
167
+ - Match priority per segment: **static > dynamic > optional > catch-all**.
168
+
169
+ No registration step — the file IS the route.
170
+
171
+ ---
172
+
173
+ ## 5. Route module API
174
+
175
+ Every file in `app/routes/` (and `root.tsx`/`layout.tsx`) may export any combination of these. Import the arg types from the package.
176
+
177
+ ```tsx
178
+ import type { LoaderArgs, ActionArgs, MetaArgs, HeadersArgs } from "@bractjs/bractjs";
179
+ import { redirect, json, HttpError } from "@bractjs/bractjs";
180
+
181
+ // 1) loader — runs on every GET. Return value → useLoaderData().
182
+ // `search` is the validated output of searchSchema (below), or the raw
183
+ // string record when the route has no schema.
184
+ export async function loader({ request, params, context, search }: LoaderArgs) {
185
+ const post = await db.post.findById(params.id);
186
+ if (!post) throw new HttpError(404, "Not found"); // → 404 page
187
+ return { post };
188
+ }
189
+ export type LoaderData = Awaited<ReturnType<typeof loader>>;
190
+
191
+ // 2) action — runs on POST / PUT / DELETE / PATCH. For one route handling
192
+ // several buttons, compose it from `defineActions` (§5a) instead of a
193
+ // hand-rolled `intent` switch.
194
+ export async function action({ request, params, context, formData }: ActionArgs) {
195
+ await db.post.update(params.id, { title: formData.get("title") as string });
196
+ return redirect("/blog");
197
+ }
198
+
199
+ // 3) meta — SSR <title> / <meta>. Receives this route's loaderData slice.
200
+ export function meta({ loaderData, params }: MetaArgs<LoaderData>) {
201
+ return [
202
+ { title: loaderData.post.title },
203
+ { name: "description", content: loaderData.post.excerpt },
204
+ { property: "og:title", content: loaderData.post.title },
205
+ ];
206
+ }
207
+
208
+ // 4) beforeLoad — auth/redirect gate. Runs BEFORE loaders, on full-page GET
209
+ // AND the /_data soft-nav endpoint. Return a Response to short-circuit.
210
+ export function beforeLoad({ context, params, location }) {
211
+ if (!context.user) {
212
+ return redirect(`/login?next=${encodeURIComponent(location.pathname)}`);
213
+ }
214
+ }
215
+
216
+ // 5) ErrorBoundary — renders when this segment's loader/component throws.
217
+ export function ErrorBoundary({ error }: { error: unknown }) {
218
+ return <p>Something broke: {error instanceof Error ? error.message : String(error)}</p>;
219
+ }
220
+
221
+ // 6) searchSchema — validate/coerce search params BEFORE loaders run (Zod,
222
+ // Valibot, or anything with parse/safeParse). Failure → 400; use
223
+ // .catch()/.default() per field for URLs that must tolerate junk.
224
+ // Loaders receive the OUTPUT via `search`; the client reads it with
225
+ // useSearch() — numbers stay numbers. (Typed end-to-end after codegen, §18.)
226
+ export const searchSchema = z.object({
227
+ page: z.coerce.number().int().positive().catch(1),
228
+ q: z.string().optional(),
229
+ });
230
+
231
+ // 7) shouldRevalidate — veto automatic data refetches (the SWR background
232
+ // refetch, and the revalidation after <Form>/fetcher mutations).
233
+ export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }) {
234
+ if (formMethod === "DELETE") return true; // always refetch after deletes
235
+ return defaultShouldRevalidate;
236
+ }
237
+
238
+ // 8) ssr + Fallback — selective SSR (see also §21 for app-wide SPA mode):
239
+ // true (default) full document SSR
240
+ // "data-only" loaders run on the server; component renders client-only
241
+ // false loader + component skipped during document SSR; the
242
+ // client fetches /_data after hydration.
243
+ // beforeLoad ALWAYS runs on the server — ssr:false is not an auth bypass.
244
+ export const ssr = "data-only";
245
+ export function Fallback() {
246
+ return <p>Loading dashboard…</p>; // SSR'd in the component's place
247
+ }
248
+
249
+ // 9) headers — set response headers (Cache-Control / ETag / Vary / CDN hints)
250
+ // for this route's document AND /_data responses. Runs root → layout →
251
+ // route; innermost wins per key, and you receive the merged parentHeaders.
252
+ export function headers({ loaderData, parentHeaders }: HeadersArgs<LoaderData>) {
253
+ return { "Cache-Control": "public, max-age=300, s-maxage=3600" };
254
+ }
255
+
256
+ // 10) middleware — nested, server-side. Runs root → layout → route BEFORE
257
+ // beforeLoad/action/loaders, with a shared mutable `context`. Return a
258
+ // Response to short-circuit (a cleaner per-route alternative to beforeLoad,
259
+ // and to a single global pipeline). Protects the document and /_data.
260
+ export const middleware = [
261
+ async (ctx, next) => {
262
+ if (!ctx.context.user) return redirect("/login");
263
+ ctx.context.startedAt = Date.now(); // visible to loaders
264
+ return next();
265
+ },
266
+ ];
267
+
268
+ // 11) clientLoader / clientAction — RR7-style browser-side data. clientLoader
269
+ // runs on navigation and its result becomes useLoaderData(); call
270
+ // serverLoader() for this route's server data. Set clientLoader.hydrate =
271
+ // true to also run on the first hydration of an SSR'd document.
272
+ export async function clientLoader({ serverLoader }) {
273
+ const server = await serverLoader(); // the normal /_data route slice
274
+ return { ...server, fetchedAt: Date.now() };
275
+ }
276
+ // clientLoader.hydrate = true; // opt into running on hydration
277
+ export async function clientAction({ formData, serverAction }) {
278
+ // optimistic local work, then defer to the server action:
279
+ return serverAction();
280
+ }
281
+
282
+ // 12) default — the page component (required for a renderable route).
283
+ export default function BlogPost() {
284
+ const { post } = useLoaderData<LoaderData>();
285
+ return <article><h1>{post.title}</h1></article>;
286
+ }
287
+ ```
288
+
289
+ **Execution order for a request:**
290
+
291
+ ```
292
+ global pipeline → searchSchema → route middleware (root → layout → route) → beforeLoad → (action, if mutating method) → loaders (root + layouts + route, in parallel) → render
293
+ ```
294
+
295
+ - **Route middleware** wraps everything after search validation: it runs in chain order with a shared `context`, can short-circuit with a `Response`, and (being outermost-first) can also post-process the final response. It runs inside the app-wide `pipeline` (§14).
296
+ - **Loaders run concurrently** (root, every layout, and the route loader all in one `Promise.all`).
297
+ - A loader that throws an `HttpError`/redirect `Response` is intentional control flow. Any *other* thrown error is caught, sanitized (generic message in production, full message+stack only when `NODE_ENV=development`), and rendered via the nearest `ErrorBoundary`.
298
+
299
+ > **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.
300
+
301
+ ### Less boilerplate
302
+
303
+ **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>()`.
304
+ ```tsx
305
+ export async function loader() { return { post: await db.post.find() }; }
306
+ export default function Post() {
307
+ const { post } = useLoaderData<typeof loader>(); // typed — no hand-written type
308
+ }
309
+ ```
310
+
311
+ **Type search params on the args — `LoaderArgs<T>`.** Parameterize to drop the cast (the schema's output type):
312
+ ```tsx
313
+ export async function loader({ search }: LoaderArgs<{ page: number }>) {
314
+ return db.posts({ page: search.page }); // search.page is a number
315
+ }
316
+ // After codegen (§18), `LoaderArgsFor<"/posts">` types params + context + search from the route.
317
+ ```
318
+
319
+ ### `defineActions` — multi-button forms without an `intent` switch
320
+
321
+ 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).
322
+ ```tsx
323
+ import { defineActions, safeValidate, formText } from "@bractjs/bractjs";
324
+
325
+ export const action = defineActions({
326
+ add: async ({ formData }) => {
327
+ const r = await safeValidate(TitleSchema, formData); // §13
328
+ if (!r.ok) return { error: r.firstError };
329
+ addTodo(r.data.title); return {};
330
+ },
331
+ toggle: ({ formData }) => { toggleTodo(formText(formData, "id")); return {}; },
332
+ delete: ({ formData }) => { deleteTodo(formText(formData, "id")); return {}; },
333
+ });
334
+ ```
335
+ ```tsx
336
+ <Form method="post" intent="toggle"><input type="hidden" name="id" value={id} /><button>Toggle</button></Form>
337
+ ```
338
+
339
+ ---
340
+
341
+ ## 6. Response helpers
342
+
343
+ Imported from `@bractjs/bractjs`.
344
+
345
+ ```ts
346
+ import { json, redirect, error, HttpError, isRedirect, isHttpError } from "@bractjs/bractjs";
347
+ ```
348
+
349
+ ### `json(data, init?)`
350
+ Serialize a value as `application/json`.
351
+ ```ts
352
+ return json({ ok: true }, { status: 201 });
353
+ ```
354
+
355
+ ### `redirect(url, status?, headers?, options?)`
356
+ Throw or return a redirect. **Open-redirect safe by default** — rejects `//evil.com`, `/\evil`, `https://…`, `javascript:` unless you pass `{ allowExternal: true }`.
357
+ ```ts
358
+ return redirect("/dashboard"); // 302
359
+ return redirect("/login", 303); // custom status
360
+ return redirect("/x", 302, { "Set-Cookie": cookie }); // with headers
361
+ return redirect("https://other.com", 302, undefined, { allowExternal: true });
362
+ ```
363
+
364
+ ### `error(message, status?)`
365
+ Convenience JSON error: `{ "error": message }` with the given status (default 500).
366
+ ```ts
367
+ return error("Bad Request", 400);
368
+ ```
369
+
370
+ ### `HttpError` & `BractJSError`
371
+ Throw a typed HTTP error from a loader/action. The framework converts it to a response with that status (and a default status text if you omit the message).
372
+ ```ts
373
+ throw new HttpError(403); // → 403 Forbidden
374
+ throw new HttpError(404, "No such post"); // → 404 with custom message
375
+ ```
376
+ `isRedirect(value)` / `isHttpError(value)` / `isBractJSError(value)` are type guards if you handle errors manually.
377
+
378
+ ---
379
+
380
+ ## 7. Per-route context: `defineContext`
381
+
382
+ A route can compute request-scoped data once and share it with all its loaders/actions via the `context` argument. Middleware can also populate `context` (§14) — `defineContext` is the per-route version.
383
+
384
+ ```ts
385
+ // app/routes/dashboard.tsx
386
+ import { defineContext } from "@bractjs/bractjs";
387
+ import { getUser } from "../auth.server.ts";
388
+
389
+ export const context = defineContext(async ({ request, params }) => ({
390
+ user: await getUser(request),
391
+ }));
392
+
393
+ export function beforeLoad({ context }) {
394
+ if (!context.user) return redirect("/login");
395
+ }
396
+
397
+ export async function loader({ context }) {
398
+ return { name: context.user.name };
399
+ }
400
+ ```
401
+
402
+ The factory runs before `beforeLoad` and its result is merged into `context` for `beforeLoad`, `loader`, and `action` on that route.
403
+
404
+ ---
405
+
406
+ ## 8. Streaming data
407
+
408
+ Stream slow data without blocking the initial HTML.
409
+
410
+ ```ts
411
+ import { defer, Deferred, isDeferred } from "@bractjs/bractjs";
412
+ import { Await } from "@bractjs/bractjs";
413
+ import { Suspense } from "react";
414
+ ```
415
+
416
+ ### `defer(data)`
417
+ Wraps each `Promise` field in a `Deferred`; non-promise fields pass through. Awaited fields are in the initial HTML; promises stream after.
418
+
419
+ ```tsx
420
+ export async function loader({ params }: LoaderArgs) {
421
+ return defer({
422
+ post: await db.post.findById(params.id), // awaited → initial HTML
423
+ comments: db.comments.forPost(params.id), // Promise → streamed
424
+ });
425
+ }
426
+
427
+ export default function BlogPost() {
428
+ // useLoaderData<typeof loader>() infers the shape — `comments` stays a
429
+ // Deferred<Comment[]>, which <Await> accepts directly.
430
+ const { post, comments } = useLoaderData<typeof loader>();
431
+ return (
432
+ <article>
433
+ <h1>{post.title}</h1>
434
+ <Await resolve={comments} fallback={<p>Loading comments…</p>}>
435
+ {(c) => <CommentList comments={c} />}
436
+ </Await>
437
+ </article>
438
+ );
439
+ }
440
+ ```
441
+
442
+ ### `<Await resolve={promise | Deferred} fallback={…}>{(data) => …}</Await>`
443
+ 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.
444
+
445
+ ---
446
+
447
+ ## 9. Client hooks
448
+
449
+ All hooks are SSR-safe (they return sensible values during SSR) and imported from `@bractjs/bractjs`.
450
+
451
+ ### `useLoaderData<T>()` → `T`
452
+ 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.
453
+ ```ts
454
+ const { post } = useLoaderData<typeof loader>(); // inferred from loader()
455
+ const { post } = useLoaderData<LoaderData>(); // or an explicit type
456
+ ```
457
+
458
+ ### `useActionData<T>()` → `T | null`
459
+ The most recent action return value (null until an action runs). Like `useLoaderData`, accepts the action function type.
460
+ ```ts
461
+ const result = useActionData<typeof action>();
462
+ ```
463
+
464
+ ### `useParams<T>()` → `T`
465
+ 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.
466
+ ```ts
467
+ const { id } = useParams<"/blog/:id">(); // { id: string } — typed from routes
468
+ const { id } = useParams<{ id: string }>(); // or a hand-written shape
469
+ ```
470
+ > 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).
471
+
472
+ ### `useMatches()` → `RouteMatch[]`
473
+ The matched route chain, **outermost → innermost** (root, layouts, then the leaf route). Each entry is `{ id, pathname, params, data, handle }`, where `handle` is that module's static `handle` export. Ideal for breadcrumbs and conditional chrome without threading props through every layout. SSR-safe; updates on soft navigation and revalidation.
474
+ ```tsx
475
+ // routes/blog/[id].tsx
476
+ export const handle = { breadcrumb: "Post" };
477
+
478
+ // a layout
479
+ const crumbs = useMatches()
480
+ .filter((m) => m.handle?.breadcrumb)
481
+ .map((m) => m.handle!.breadcrumb as string);
482
+ ```
483
+ > `handle` travels in the SSR bootstrap and the `/_data` payload, so it must be JSON-serializable (same constraint as loader data).
484
+
485
+ ### `useLocation()` → `{ pathname, search, hash, state, key }`
486
+ 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 })`.
487
+ ```ts
488
+ const location = useLocation();
489
+ const isActive = location.pathname.startsWith("/blog");
490
+ ```
491
+
492
+ ### `useNavigation()` → `{ state }`
493
+ `"idle" | "loading" | "submitting"`. Form/`submit()` mutations walk `"submitting"` → `"loading"` (revalidation) → `"idle"`.
494
+ ```ts
495
+ const { state } = useNavigation();
496
+ if (state === "loading") return <Spinner />;
497
+ ```
498
+
499
+ ### `useNavigate()` → `(to, { params?, search?, replace?, state? }) => Promise<void>`
500
+ 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.
501
+ ```ts
502
+ const navigate = useNavigate();
503
+ await navigate("/blog/:id", { params: { id: "42" } }); // typed
504
+ await navigate("/posts", { search: { page: 2 } }); // typed search → /posts?page=2
505
+ await navigate("/login", { replace: true }); // replaceState, no history entry
506
+ await navigate("/wizard/2", { state: { from: "step1" } }); // read via useLocation().state
507
+ ```
508
+
509
+ ### `useRevalidator()` → `{ revalidate, state }`
510
+ 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()`).
511
+ ```ts
512
+ const { revalidate, state } = useRevalidator();
513
+ <button onClick={() => void revalidate()} disabled={state === "loading"}>Refresh</button>
514
+ ```
515
+
516
+ ### `useSearch()` / `useSetSearch()` — typed, validated search params
517
+ `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.
518
+ ```ts
519
+ const search = useSearch<"/posts">(); // { page: number; q?: string }
520
+ const setSearch = useSetSearch<"/posts">();
521
+ setSearch({ page: search.page + 1 }); // patch → /posts?page=2&q=…
522
+ setSearch((prev) => ({ q: undefined }), { replace: true }); // delete + replaceState
523
+ ```
524
+
525
+ ### `useSearchParams<T>()` → `{ searchParams, getParam, setSearchParams }`
526
+ 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`.
527
+ ```ts
528
+ const { searchParams, getParam, setSearchParams } = useSearchParams<"/blog/:id">();
529
+ const q = getParam("q"); // string | null
530
+ setSearchParams({ q: "bun" }); // replace all params
531
+ setSearchParams((prev) => { prev.set("page", "2"); return prev; }); // update
532
+ ```
533
+
534
+ ### `useFetcher({ key? })` → `{ data, state, formData, formMethod, load, submit, Form, key }`
535
+ 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.
536
+ ```ts
537
+ const fetcher = useFetcher({ key: `delete-${id}` });
538
+ await fetcher.load("/products?q=bun"); // GET loader data
539
+ await fetcher.submit("/cart", { method: "post", body: { id: "1" } });
540
+ const optimisticTitle = fetcher.formData?.get("title"); // while submitting
541
+ <fetcher.Form method="post" action="/cart">…</fetcher.Form> {/* scoped form, no navigation */}
542
+ ```
543
+
544
+ ### `useFetchers()` → `FetcherEntry[]`
545
+ 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).
546
+ ```ts
547
+ const deleting = new Set(
548
+ useFetchers()
549
+ .filter((f) => f.state === "submitting" && f.key.startsWith("delete-"))
550
+ .map((f) => f.key.slice("delete-".length)),
551
+ );
552
+ ```
553
+
554
+ ### `useFetcher<T>({ stream: true })` → `{ connect }`
555
+ Consume an async-generator server action as an SSE stream.
556
+ ```ts
557
+ const { connect } = useFetcher<string>({ stream: true });
558
+ for await (const chunk of connect(actionId)) { /* … */ }
559
+ ```
560
+
561
+ ### `useBlocker(shouldBlock)`
562
+ Prompt before leaving when there are unsaved changes (intercepts back/forward and `<Link>` navigations).
563
+ ```ts
564
+ useBlocker(() => formIsDirty);
565
+ ```
566
+
567
+ ### `useToast()` → `toast` and `useToasts()` → `ToastEntry[]`
568
+ Flash status feedback ("Saved", "Delete completed", …) from anywhere. `useToast()` returns the stable `toast` API; you can also import `toast` directly to fire from non-React code (event handlers, fetcher callbacks). Render a single [`<Toaster />`](#10-client-components) in `root.tsx`. `useToasts()` exposes the live queue if you want to build a custom renderer.
569
+ ```ts
570
+ import { toast, useToast } from "@bractjs/bractjs";
571
+
572
+ toast.success("Saved successfully");
573
+ toast.error("Delete failed", { description: "Try again." });
574
+ toast.info("Heads up"); // also: .warning, .loading
575
+ toast.dismiss(id); // or toast.dismiss() to clear all
576
+
577
+ // Wrap an async action: loading → success / error
578
+ await toast.promise(savePost(data), {
579
+ loading: "Saving…",
580
+ success: "Saved successfully",
581
+ error: (e) => `Save failed: ${e}`,
582
+ });
583
+
584
+ // Optional action button (e.g. undo) + custom auto-dismiss
585
+ toast.success("Delete completed", {
586
+ duration: 6000, // ms; Infinity/0 = sticky
587
+ action: { label: "Undo", onClick: () => restore(id) },
588
+ });
589
+ ```
590
+
591
+ ### `useLocale(defaultLocale?)` → `string` and `useLocalizedLink(defaultLocale?)` → `(path) => string`
592
+ For i18n prefix routing (§19).
593
+ ```ts
594
+ const locale = useLocale("en"); // reads params.locale
595
+ const localized = useLocalizedLink("en");
596
+ <Link to={localized("/about")} /> // → /en/about
597
+ ```
598
+
599
+ ---
600
+
601
+ ## 10. Client components
602
+
603
+ ### `<Outlet />`
604
+ Renders the matched child route inside a layout (or the route tree inside `root.tsx`).
605
+ ```tsx
606
+ export default function BlogLayout() {
607
+ return <div><nav>Blog</nav><Outlet /></div>;
608
+ }
609
+ ```
610
+
611
+ ### `<Link to params? search? prefetch? replace? viewTransition?>`
612
+ 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.
613
+ ```tsx
614
+ <Link to="/blog/:id" params={{ id: "42" }}>Read</Link> {/* typed route + params */}
615
+ <Link to={`/blog/${id}`}>Read</Link> {/* built string — also fine */}
616
+ <Link to="/posts" search={{ page: 2 }}>Page 2</Link> {/* typed search params */}
617
+ <Link to="/about" prefetch="intent">About</Link> {/* preload on hover/focus intent */}
618
+ <Link to="/gallery" viewTransition>Gallery</Link> {/* use View Transitions API */}
619
+ ```
620
+ Modifier-clicks (ctrl/cmd/shift/alt) fall back to native browser navigation.
621
+
622
+ **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):
623
+
624
+ | mode | when |
625
+ |---|---|
626
+ | `"none"` (default) | never |
627
+ | `"intent"` | hover **or focus**, after a 100 ms delay (canceled on fly-by) — best default |
628
+ | `"hover"` | immediately on mouseenter (legacy alias) |
629
+ | `"viewport"` | when the link scrolls into view (one shared IntersectionObserver) — for lists |
630
+ | `"render"` | as soon as the link mounts |
631
+
632
+ ### `<ScrollRestoration getKey? storageKey?>`
633
+ 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.
634
+ ```tsx
635
+ <body>
636
+ <Outlet />
637
+ <ScrollRestoration />
638
+ <Scripts />
639
+ </body>
640
+ ```
641
+
642
+ ### `<Form method action?>`
643
+ Fetch-based submission that re-runs the current route's loader after the action.
644
+ ```tsx
645
+ <Form method="post">
646
+ <input name="title" />
647
+ <button type="submit">Create</button>
648
+ </Form>
649
+ <Form method="post" action="/blog/new">…</Form>
650
+ ```
651
+ Submits as `multipart/form-data` with the `X-BractJS-Action` header (CSRF gate). If the action returns a redirect, the form follows it.
652
+
653
+ ### `<Scripts />` and `<LiveReload />`
654
+ Markers used inside `root.tsx` (§3). `<Scripts />` is where the client bundle + bootstrap data go; `<LiveReload />` is the dev-only HMR client.
655
+
656
+ ### `<Image />`
657
+ Responsive, format-converted images via the built-in `/_image` endpoint — see §20.
658
+
659
+ ### `<Toaster position? gap? renderToast?>`
660
+ Renders the toast queue fired by [`toast` / `useToast()`](#9-client-hooks). Mount it **once** in `app/root.tsx`, next to `<Scripts />`. Self-contained inline styles (no CSS import, CSP-safe — no injected `<style>`/nonce).
661
+ ```tsx
662
+ <body>
663
+ <Outlet />
664
+ <Toaster position="top-right" /> {/* top|bottom + left|center|right */}
665
+ <Scripts />
666
+ </body>
667
+ ```
668
+ Style hooks: target `[data-bract-toaster]` / `[data-bract-toast="success|error|…"]` with your own CSS, or pass `renderToast={(t, dismiss) => <YourCard …/>}` to fully replace the card.
669
+
670
+ ---
671
+
672
+ ## 11. Server Actions & Client Components
673
+
674
+ ### `"use server"` — Server Actions
675
+
676
+ Mark a file's exports as server actions by making `"use server"` the first line. On the **client**, imports become `fetch("/_action?id=<hash>")` proxies; on the **server**, the real function runs. Action IDs are `SHA-256(appDir-relative-path + "#" + name)`.
677
+
678
+ ```ts
679
+ // app/actions.ts
680
+ "use server";
681
+
682
+ export async function createPost(formData: FormData) {
683
+ const title = formData.get("title") as string;
684
+ await db.insert(posts).values({ title });
685
+ return { ok: true };
686
+ }
687
+
688
+ export async function deletePost(id: string) {
689
+ await db.delete(posts).where(eq(posts.id, id));
690
+ }
691
+ ```
692
+
693
+ ```tsx
694
+ // app/routes/new.tsx — import as normal; the client bundle gets a fetch proxy
695
+ import { createPost } from "../actions.ts";
696
+
697
+ export default function NewPost() {
698
+ return (
699
+ <form action={createPost}>
700
+ <input name="title" />
701
+ <button type="submit">Create</button>
702
+ </form>
703
+ );
704
+ }
705
+ ```
706
+
707
+ - Accepts a single `FormData` (sent as `multipart/form-data`) **or** a JSON-serializable argument array.
708
+ - Unknown action IDs return 404 — only functions registered at startup are callable.
709
+ - Bodies are size-capped (1 MiB JSON) and prototype-pollution scanned.
710
+
711
+ **Streaming server actions** (async generators) are consumed with `useFetcher({ stream: true })` (§9) over `/_stream`. The endpoint requires the `X-BractJS-Action` header (set automatically by the client).
712
+
713
+ ### `"use client"` — Client-Only Components
714
+
715
+ Mark a component browser-only. During server builds the module is stubbed to `null` to prevent `window`/`document`/`localStorage` crashes during SSR.
716
+
717
+ ```tsx
718
+ // app/components/Counter.tsx
719
+ "use client";
720
+ import { useState } from "react";
721
+
722
+ export function Counter() {
723
+ const [n, setN] = useState(0);
724
+ return <button onClick={() => setN(n + 1)}>Count: {n}</button>;
725
+ }
726
+ ```
727
+
728
+ ---
729
+
730
+ ## 12. Typed API routes
731
+
732
+ Define type-safe JSON endpoints under `/api/*` with `route`, and call them with a fully-typed `createClient`.
733
+
734
+ ### Define routes with `route(method, path, handler)`
735
+
736
+ ```ts
737
+ // app/api/users.ts
738
+ import { route } from "@bractjs/bractjs";
739
+ import { db } from "../db.server.ts";
740
+
741
+ export const listUsers = route("GET", "/api/users", async () => {
742
+ return db.users.findAll();
743
+ });
744
+
745
+ export const createUser = route("POST", "/api/users", async (input: { name: string }) => {
746
+ return db.users.create(input);
747
+ });
748
+
749
+ // Public, credential-free endpoint (e.g. a webhook): opt out of CSRF.
750
+ export const webhook = route("POST", "/api/webhook", handleWebhook, { csrf: false });
751
+ ```
752
+
753
+ - `GET`/`DELETE`: no body parsed. `POST`/`PUT`/`PATCH`: JSON or form body parsed into `input`.
754
+ - Bodies are capped at 1 MiB. JSON bodies carrying prototype-pollution keys (`__proto__`/`constructor`/`prototype`) are rejected (400). Errors return a generic 500 in production (full message in dev).
755
+ - Handlers also receive the raw `Request` as the 2nd arg.
756
+ - `:param` segments match any non-empty value; **read and validate params from `request.url` yourself** (they aren't injected into `input`).
757
+ - **CSRF — on by default for mutating methods** (`POST`/`PUT`/`PATCH`/`DELETE`): the request must prove same-origin (via `Sec-Fetch-Site`, the `X-BractJS-Action` header `createClient` sends automatically, or a matching `Origin`), exactly like server actions. Cross-site requests get `403`. Pass `{ csrf: false }` **only** for endpoints that don't trust ambient credentials (session cookies / Basic auth) and are meant to be called cross-site (webhooks, token-authenticated or public APIs).
758
+ - Global `pipeline` middleware (`cors`, `csp`, `authGuard`, custom) applies to `/api` responses too — see §14.
759
+
760
+ ### Call them with `createClient<AppApiRoutes>()`
761
+
762
+ ```ts
763
+ import { createClient } from "@bractjs/bractjs";
764
+ import type { AppApiRoutes } from "@bractjs/bractjs"; // union of your route defs
765
+
766
+ const client = createClient<AppApiRoutes>(); // optional baseUrl arg
767
+ const users = await client["/api/users"].GET(); // typed output
768
+ await client["/api/users"].POST({ name: "Alice" }); // typed input
769
+ ```
770
+
771
+ The proxy builds `METHOD path` from the property chain. Non-2xx responses throw an `Error` with `.status` and `.response` attached.
772
+
773
+ ---
774
+
775
+ ## 13. Input validation: `validate`
776
+
777
+ Validate `FormData` or a plain object against any **Zod- or Valibot-compatible** schema (anything with `.safeParse()` or `.parse()`).
778
+
779
+ ```ts
780
+ import { validate } from "@bractjs/bractjs";
781
+ import { z } from "zod";
782
+
783
+ const Schema = z.object({ title: z.string().min(1), tags: z.array(z.string()) });
784
+
785
+ export async function action({ formData }: ActionArgs) {
786
+ // Throws a 400 Response with { errors: { field: [msgs] } } on failure.
787
+ const data = await validate(Schema, formData);
788
+ await db.post.create(data); // data is fully typed + coerced
789
+ }
790
+ ```
791
+
792
+ - Repeated `FormData` keys become arrays automatically.
793
+ - On failure it throws a `Response.json({ errors }, { status: 400 })`. The exported `ValidationError` type and `FieldErrors` shape describe the structure.
794
+
795
+ ### `safeValidate` — validate without try/catch (recommended for inline form errors)
796
+
797
+ 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.
798
+ ```ts
799
+ import { safeValidate } from "@bractjs/bractjs";
800
+
801
+ export const action = defineActions({
802
+ create: async ({ formData }) => {
803
+ const r = await safeValidate(Schema, formData);
804
+ if (!r.ok) return { error: r.firstError, fieldErrors: r.fieldErrors };
805
+ await db.post.create(r.data); // r.data is typed + coerced
806
+ return redirect("/blog");
807
+ },
808
+ });
809
+ ```
810
+ If you prefer to keep calling `validate()` and catching: `isValidationResponse(err)` narrows the thrown 400, and `readValidationError(res)` parses it into `{ fieldErrors, firstError }`.
811
+
812
+ ### FormData helpers
813
+
814
+ `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).
815
+
816
+ ---
817
+
818
+ ## 14. Middleware
819
+
820
+ The module-level `pipeline` singleton wraps the **entire** request — every response flows through it, including typed `/api` routes, server actions (`/_action`, `/_stream`), the image endpoint (`/_image`), static assets, and SSR documents. So `cors()`, `csp()`, `authGuard()`, a rate limiter, or your own logging applies uniformly, not just to page renders. Each middleware gets `(ctx, next)` and returns a `Response`; `ctx.context` is threaded into every loader/action and into nested route middleware.
821
+
822
+ ```ts
823
+ import { pipeline, requestLogger, cors, authGuard, csp } from "@bractjs/bractjs";
824
+ import type { MiddlewareFn } from "@bractjs/bractjs";
825
+
826
+ pipeline
827
+ .use(requestLogger())
828
+ .use(cors({ origin: "https://myapp.com" }))
829
+ .use(csp())
830
+ .use(authGuard({ session }));
831
+ ```
832
+
833
+ ### Built-in middleware
834
+
835
+ | Middleware | What it does |
836
+ |---|---|
837
+ | `requestLogger()` | Logs `[METHOD] /path → status in Xms`. Never logs the query string or headers (token-leak safe). |
838
+ | `cors(options)` | Sets CORS headers, handles `OPTIONS` preflight (204), always sets `Vary: Origin`, refuses `credentials:true` + `origin:"*"`. |
839
+ | `authGuard(options)` | Reads the session, sets `ctx.context.user`; with `required:true` returns 401 when unauthenticated. |
840
+ | `csp(options?)` | Opt-in nonce-based Content-Security-Policy (see below). |
841
+
842
+ **`cors(options)`** — `{ origin: string | string[]; methods?: string[]; credentials?: boolean }`:
843
+ ```ts
844
+ pipeline.use(cors({ origin: ["https://a.com", "https://b.com"], credentials: true }));
845
+ ```
846
+
847
+ **`authGuard(options)`** — `{ session: SessionStorageLike; required?: boolean }`:
848
+ ```ts
849
+ pipeline.use(authGuard({ session, required: true })); // 401 if no session.user
850
+ ```
851
+
852
+ **`csp(options?)`** — generates a per-request nonce, applies it to the scripts BractJS injects (via `renderToReadableStream`'s `nonce`), and sets the CSP header:
853
+ ```ts
854
+ pipeline.use(csp({
855
+ directives: { "img-src": "'self' data: https://cdn.example", "frame-ancestors": "'none'" },
856
+ reportOnly: false, // true → Content-Security-Policy-Report-Only
857
+ strict: false, // true → drop 'unsafe-inline' from style-src (see below)
858
+ }));
859
+ ```
860
+ Read the nonce inside a component/middleware with `getCspNonce(context)` (key: `CSP_NONCE_KEY`) to nonce your own inline scripts.
861
+
862
+ `script-src` is always nonce-based (`'nonce-…' 'strict-dynamic'`), so injected `<script>` cannot execute. Note that with `'strict-dynamic'`, supporting browsers **ignore** the `'self'`/host expressions in `script-src` — trust flows solely through the nonce and the scripts it loads (the `'self'` is kept only as a fallback for older browsers). The default policy also sets `form-action 'self'` (a `<form>` can only submit same-origin), `base-uri 'self'`, `frame-ancestors 'self'`, and `object-src 'none'`. 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.
863
+
864
+ ### Custom middleware
865
+
866
+ ```ts
867
+ import type { MiddlewareFn } from "@bractjs/bractjs";
868
+
869
+ const trace: MiddlewareFn = async (ctx, next) => {
870
+ ctx.context.requestId = crypto.randomUUID();
871
+ const res = await next();
872
+ res.headers.set("X-Request-Id", ctx.context.requestId as string);
873
+ return res;
874
+ };
875
+ pipeline.use(trace);
876
+ ```
877
+
878
+ You can also construct an isolated `new MiddlewarePipeline()` and `.run(ctx, handler)` it yourself (used internally and in tests).
879
+
880
+ ### Nested route middleware
881
+
882
+ The global `pipeline` is app-wide. For middleware scoped to a branch of the route tree, export `middleware` from a route, `layout.tsx`, or `root.tsx` — a single function or an array. It runs **inside** the global pipeline, in chain order (root → layout → route), before `beforeLoad`/action/loaders, sharing the same mutable `context`. Return a `Response` to short-circuit; because the chain is outermost-first, an ancestor can also post-process the response.
883
+
884
+ ```ts
885
+ // routes/admin/layout.tsx — gates every /admin/* route
886
+ import type { RouteMiddlewareFunction } from "@bractjs/bractjs";
887
+ import { redirect } from "@bractjs/bractjs";
888
+
889
+ const requireAdmin: RouteMiddlewareFunction = async (ctx, next) => {
890
+ if (!(ctx.context.user as { admin?: boolean } | undefined)?.admin) {
891
+ return redirect("/login");
892
+ }
893
+ return next(); // continue to child layouts/route + loaders
894
+ };
895
+
896
+ export const middleware = [requireAdmin];
897
+ ```
898
+
899
+ It protects the **document and the `/_data` soft-nav endpoint** alike, so it's a safe place for auth — same guarantee as `beforeLoad`. Prefer route middleware over `beforeLoad` when you want composition (multiple concerns, shared across a folder) or response post-processing; `beforeLoad` remains the lighter single-gate option.
900
+
901
+ ---
902
+
903
+ ## 15. Sessions
904
+
905
+ Signed, tamper-proof cookie sessions (HMAC-SHA256, constant-time verify, secret rotation).
906
+
907
+ ```ts
908
+ import { createCookieSession } from "@bractjs/bractjs";
909
+
910
+ const session = createCookieSession({
911
+ name: "__session",
912
+ secrets: [Bun.env.SESSION_SECRET!], // first signs; all verify (rotate by prepending)
913
+ maxAge: 60 * 60 * 24 * 7, // seconds; 1 week
914
+ secure: true, // false only for local HTTP dev
915
+ sameSite: "Lax", // "Strict" | "Lax" | "None"
916
+ });
917
+ ```
918
+
919
+ Read in a loader, write in an action:
920
+
921
+ ```ts
922
+ export async function loader({ request }: LoaderArgs) {
923
+ const s = await session.getSession(request.headers.get("Cookie"));
924
+ return { user: s.get("user") };
925
+ }
926
+
927
+ export async function action({ request }: ActionArgs) {
928
+ const s = await session.getSession(request.headers.get("Cookie"));
929
+ s.set("user", { id: 1, name: "Alice" }); // also: s.get, s.has, s.delete
930
+ return redirect("/dashboard", {
931
+ headers: { "Set-Cookie": await session.commitSession(s) }, // opt: { maxAge }
932
+ });
933
+ }
934
+ ```
935
+
936
+ - Each secret must be ≥16 chars; `secrets` must be non-empty (throws otherwise).
937
+ - Tampered cookies are silently rejected → empty session.
938
+ - Generate a secret: `openssl rand -base64 32`.
939
+
940
+ ---
941
+
942
+ ## 16. Lifecycle hooks: `defineLifecycle`
943
+
944
+ Run code on server start, shutdown, and unexpected errors. Shutdown fires on **any** exit signal (`SIGTERM`, `SIGINT`, `SIGUSR2`, `beforeExit`, uncaught exceptions).
945
+
946
+ ```ts
947
+ // app/lifecycle.ts
948
+ import { defineLifecycle } from "@bractjs/bractjs";
949
+ import { db } from "./db.server.ts";
950
+
951
+ export default defineLifecycle({
952
+ async onStart() { await db.connect(); },
953
+ async onShutdown(){ await db.disconnect(); },
954
+ onError(err, request) {
955
+ Sentry.captureException(err, { extra: { url: request?.url } });
956
+ },
957
+ });
958
+ ```
959
+
960
+ | Hook | When |
961
+ |------|------|
962
+ | `onStart` | Once, after the server starts listening. |
963
+ | `onShutdown` | Before exit — any signal, programmatic `stop()`, or uncaught exception. |
964
+ | `onError` | Every unexpected error (loader/action throws, uncaught exceptions). Redirects and `HttpError`s are intentional control flow and are **not** reported. `request` is `undefined` for process-level exceptions. |
965
+
966
+ - **Dev** picks up `app/lifecycle.ts` automatically.
967
+ - **Production**: spread into `createServer()`:
968
+ ```ts
969
+ import { createServer } from "@bractjs/bractjs";
970
+ import lifecycle from "./app/lifecycle.ts";
971
+ createServer({ port: 3000, ...lifecycle });
972
+ ```
973
+
974
+ `createServer()` returns `{ stop }`. `stop()` runs `onShutdown` and closes the listener but does **not** call `process.exit()` (good for tests/supervisors). Signals do exit.
975
+
976
+ ---
977
+
978
+ ## 17. Environment variables
979
+
980
+ | Convention | Behavior |
981
+ |---|---|
982
+ | `*.server.ts` / `*.server.tsx` | **Stubbed out of the client bundle.** Import it freely from a route's `loader`/`action`; every export is replaced by an inert stub in the browser build, so the real source (DB drivers, secrets, `bun:sqlite`) never ships. The stub throws if you accidentally call it on the client. |
983
+ | Keys listed in `clientEnv` | Replaced with string literals in the client bundle. |
984
+ | Any other `process.env.*` | Becomes the literal `"undefined"` in the client bundle. |
985
+
986
+ ```ts
987
+ // db.server.ts — never reaches the browser
988
+ import { Database } from "bun:sqlite";
989
+ export const db = new Database(Bun.env.DATABASE_URL!);
990
+ ```
991
+
992
+ ```ts
993
+ // app/routes/posts.tsx — import the server module inside the loader
994
+ import { db } from "../db.server.ts"; // stubbed in the client bundle
995
+
996
+ export async function loader() {
997
+ return { posts: db.query("SELECT * FROM posts").all() };
998
+ }
999
+ ```
1000
+
1001
+ > BractJS ships the whole route module — `loader` and `action` included — to the client, so a server import is reachable from the client graph. The `serverModuleStubPlugin` (applied automatically by `bractjs dev`/`build`) replaces every `*.server.ts` export with a throwing stub: the import resolves, the loader/action are dead code on the client, and **zero** server source is emitted. The stricter, hard-failing `serverOnlyPlugin` is still exported if you'd rather a server import be a build error.
1002
+
1003
+ ```ts
1004
+ // bractjs.config.ts
1005
+ export default { clientEnv: ["PUBLIC_API_URL"] };
1006
+ ```
1007
+
1008
+ ```ts
1009
+ // in a client component — only allow-listed keys survive
1010
+ fetch(`${process.env.PUBLIC_API_URL}/items`);
1011
+ ```
1012
+
1013
+ On the server, read env via `Bun.env.*` directly.
1014
+
1015
+ ---
1016
+
1017
+ ## 18. Typed routes
1018
+
1019
+ Generate type-safe routing from your route files — one command wires `<Link>`, `useNavigate`, `useParams`, and `useSearchParams` to your actual routes.
1020
+
1021
+ ```sh
1022
+ bractjs codegen # ./app → ./app/route-types.gen.ts
1023
+ bractjs codegen ./app ./app/types.ts # explicit paths
1024
+ ```
1025
+
1026
+ **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**:
1027
+
1028
+ ```tsx
1029
+ <Link to="/blog/:id" params={{ id }} /> // ✅ "/blog/:id" autocompletes; params typed
1030
+ <Link to="/blgo/:id" params={{ id }} /> // ❌ typo'd route — compile error
1031
+ <Link to="/blog/:id" params={{ x: id }} /> // ❌ wrong param key — compile error
1032
+
1033
+ const navigate = useNavigate();
1034
+ navigate("/blog/:id", { params: { id } }); // ✅ same typing as <Link>
1035
+
1036
+ const { id } = useParams<"/blog/:id">(); // id: string
1037
+ ```
1038
+
1039
+ Building the URL yourself (`<Link to={`/blog/${id}`}>`) still type-checks, so adopting codegen never breaks existing links.
1040
+
1041
+ The generated file also exports types/helpers for typed loaders and explicit URL building:
1042
+
1043
+ ```ts
1044
+ import type { TypedLoaderArgs } from "../route-types.gen.ts";
1045
+ import { routes } from "../route-types.gen.ts";
1046
+
1047
+ export async function loader({ params }: TypedLoaderArgs<"/blog/:id">) {
1048
+ return db.post.findById(params.id); // params.id: string
1049
+ }
1050
+ routes["/blog/:id"]({ id: "123" }); // → "/blog/123" (typo'd routes won't compile)
1051
+ ```
1052
+
1053
+ **Type a route's search params or context** by augmenting the package interfaces — `SearchParams<T>` / `Context<T>` and `useSearchParams<T>()` pick it up:
1054
+
1055
+ ```ts
1056
+ declare module "@bractjs/bractjs" {
1057
+ interface RouteSearchParamsMap { "/blog": { page: string; sort: string } }
1058
+ interface RouteContextMap { "/admin": { user: { id: string; role: "admin" } } }
1059
+ }
1060
+ ```
1061
+
1062
+ You can also call `writeRouteTypes(appDir, outPath?)` / `generateRouteTypes(appDir)` programmatically.
1063
+
1064
+ > **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.
1065
+
1066
+ ---
1067
+
1068
+ ## 19. Internationalization utilities
1069
+
1070
+ BractJS exports **utilities** for locale-prefixed routing. These are helpers you wire up yourself (there is no fully-automatic locale router yet) plus the `useLocale` / `useLocalizedLink` client hooks (§9).
1071
+
1072
+ ```ts
1073
+ import { wrapRoutesWithLocale, stripLocale, localizedDataPath } from "@bractjs/bractjs";
1074
+ import type { I18nConfig } from "@bractjs/bractjs";
1075
+
1076
+ const i18n: I18nConfig = { locales: ["en", "fr"], defaultLocale: "en" };
1077
+
1078
+ // Add /:locale-prefixed variants alongside the originals.
1079
+ const localized = wrapRoutesWithLocale(routeFiles, i18n);
1080
+
1081
+ // Split a locale off a pathname.
1082
+ const { locale, strippedPathname } = stripLocale("/fr/about", i18n.locales);
1083
+ // → { locale: "fr", strippedPathname: "/about" }
1084
+
1085
+ // Build a locale-aware data path.
1086
+ localizedDataPath("/about", "fr"); // → "/fr/about"
1087
+ ```
1088
+
1089
+ On the client, read the active locale and build localized links:
1090
+
1091
+ ```tsx
1092
+ const locale = useLocale("en");
1093
+ const to = useLocalizedLink("en");
1094
+ <Link to={to("/about")} />; // → /en/about
1095
+ ```
1096
+
1097
+ ---
1098
+
1099
+ ## 20. Image optimization
1100
+
1101
+ `<Image>` serves responsive, format-converted images through the built-in `/_image` endpoint. Requires [ImageMagick](https://imagemagick.org) (`magick` or `convert`) — without it, originals are served as-is.
1102
+
1103
+ ```tsx
1104
+ import { Image } from "@bractjs/bractjs";
1105
+
1106
+ <Image src="/public/hero.jpg" alt="Hero" width={1200} height={600} />
1107
+
1108
+ {/* above-the-fold: eager + fetchpriority=high */}
1109
+ <Image src="/public/hero.jpg" alt="Hero" width={1200} priority />
1110
+
1111
+ <Image
1112
+ src="/public/photo.jpg" alt="Photo" width={800}
1113
+ format="avif" quality={70} fit="contain"
1114
+ sizes="(max-width: 640px) 100vw, 50vw"
1115
+ />
1116
+ ```
1117
+
1118
+ | Prop | Type | Default | Notes |
1119
+ |------|------|---------|-------|
1120
+ | `src` | `string` | — | Path under `/public/` (required) |
1121
+ | `alt` | `string` | — | Required |
1122
+ | `width` / `height` | `number` | — | Intrinsic size |
1123
+ | `quality` | `number` | `80` | 1–100 |
1124
+ | `format` | `"webp" \| "avif" \| "jpeg" \| "png"` | `"webp"` | `ImageFormat` |
1125
+ | `fit` | `"cover" \| "contain" \| "fill"` | `"cover"` | `ImageFit` |
1126
+ | `priority` | `boolean` | `false` | Disable lazy load, set `fetchpriority=high` |
1127
+ | `sizes` | `string` | `"100vw"` | HTML `sizes` |
1128
+
1129
+ Generates a `srcset` across breakpoints (320→1920px). Optimized images are cached in memory (LRU, 200 slots) and on disk (`.bract-image-cache/`, survives restarts), both served `Cache-Control: immutable`. The endpoint validates widths against an allowlist, caps total pixel area, limits concurrency, and kills runaway transforms (DoS hardening).
1130
+
1131
+ `ImageProps`, `ImageFormat`, and `ImageFit` are exported types.
1132
+
1133
+ ---
1134
+
1135
+ ## 21. Build & run
1136
+
1137
+ ### CLI
1138
+
1139
+ | Command | Description |
1140
+ |---------|-------------|
1141
+ | `bractjs new <name>` | Scaffold a new app into `<name>/`. |
1142
+ | `bractjs dev` | Dev server with HMR (port 3000, HMR ws 3001). |
1143
+ | `bractjs build` | Dual server + client build with content-hashed output. |
1144
+ | `bractjs start` | Serve the production build. |
1145
+ | `bractjs codegen [app] [out]` | Generate `route-types.gen.ts`. |
1146
+ | `bractjs codegen:registry [app]` | Generate `app/_generated/{routes,actions}.ts`. |
1147
+ | `bractjs codegen:manifest [app] [build]` | Snapshot manifest → `app/_generated/manifest.ts`. |
1148
+ | `bractjs compile [outfile] [entry]` | Full single-binary pipeline. |
1149
+
1150
+ The CLI is a thin wrapper — every command delegates to a public function, so you can script the same thing.
1151
+
1152
+ ### Programmatic API
1153
+
1154
+ **`createDevServer(options?)`** — dev server with HMR.
1155
+ ```ts
1156
+ import { createDevServer } from "@bractjs/bractjs";
1157
+
1158
+ const dev = await createDevServer({
1159
+ port: 3000, // default: config.port ?? 3000
1160
+ hmrPort: 3001, // HMR websocket
1161
+ config: { appDir: "./app", clientEnv: ["PUBLIC_API_URL"] },
1162
+ skipUserConfig: false, // true → don't read bractjs.config.ts
1163
+ });
1164
+ dev.stop();
1165
+ ```
1166
+
1167
+ **`runBuild(config?)`** — production build (accepts only build-relevant fields).
1168
+ ```ts
1169
+ import { runBuild } from "@bractjs/bractjs";
1170
+
1171
+ await runBuild({
1172
+ appDir: "./app",
1173
+ buildDir: "./build",
1174
+ minify: true,
1175
+ sourcemap: "external", // "none" | "linked" | "inline" | "external"
1176
+ clientEnv: ["PUBLIC_API_URL"],
1177
+ plugins: [], // extra Bun plugins
1178
+ });
1179
+ ```
1180
+
1181
+ **`loadUserConfig()`** — read `bractjs.config.ts` (or `.js`) from cwd, validated.
1182
+ ```ts
1183
+ import { loadUserConfig } from "@bractjs/bractjs";
1184
+ const cfg = await loadUserConfig(); // {} if no file; throws on a malformed shape
1185
+ ```
1186
+
1187
+ **`createServer(config?)`** — production HTTP server. Returns `{ stop }`.
1188
+ ```ts
1189
+ import { createServer } from "@bractjs/bractjs";
1190
+ import lifecycle from "./app/lifecycle.ts";
1191
+ const srv = createServer({ port: 3000, buildDir: "./build", ...lifecycle });
1192
+ ```
1193
+
1194
+ **`buildFetchHandler(config)`** — the adapter-agnostic `(Request) => Promise<Response>` core, if you want to mount BractJS inside another server.
1195
+ ```ts
1196
+ import { buildFetchHandler } from "@bractjs/bractjs";
1197
+ const handler = buildFetchHandler({ appDir: "./app", manifest });
1198
+ Bun.serve({ port: 3000, fetch: handler });
1199
+ ```
1200
+
1201
+ `renderRoute(options)` (low-level SSR render) and the `RenderOptions`/`ServerManifest`/`BractJSConfig` types are also exported for advanced embedding.
1202
+
1203
+ ### Rendering modes
1204
+
1205
+ Three levels of SSR control, all opt-in:
1206
+
1207
+ **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.
1208
+
1209
+ **App-wide SPA mode** — `ssr: false` in `bractjs.config.ts`:
1210
+
1211
+ ```ts
1212
+ // bractjs.config.ts
1213
+ export default {
1214
+ ssr: false, // every document GET serves one static shell
1215
+ };
1216
+ ```
1217
+
1218
+ 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).
1219
+
1220
+ **Build-time prerendering (SSG)** — `prerender` in `bractjs.config.ts`:
1221
+
1222
+ ```ts
1223
+ // bractjs.config.ts
1224
+ export default {
1225
+ prerender: ["/", "/about", "/blog/intro"],
1226
+ // or resolve dynamically: prerender: async () => (await db.posts()).map(p => `/blog/${p.slug}`),
1227
+ };
1228
+ ```
1229
+
1230
+ `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.
1231
+
1232
+ 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.
1233
+
1234
+ ---
1235
+
1236
+ ## 22. Single-binary deployment (`bun build --compile`)
1237
+
1238
+ BractJS compiles to a single executable. Because `bun build --compile` can't trace runtime fs scans or dynamic `import(absPath)`, a codegen step materializes routes, layouts, actions, and the manifest into static imports so the binary boots with **zero filesystem reads of `appDir`**.
1239
+
1240
+ ### One-shot
1241
+
1242
+ ```sh
1243
+ bractjs compile ./myapp
1244
+ # = codegen:registry → build → codegen:manifest → bun build --compile app/server.ts
1245
+ ```
1246
+
1247
+ ### The `app/server.ts` entry
1248
+
1249
+ The scaffold includes:
1250
+
1251
+ ```ts
1252
+ import { createServer } from "@bractjs/bractjs";
1253
+ import { routeFiles, moduleRegistry } from "./_generated/routes.ts";
1254
+ import { actionModules } from "./_generated/actions.ts";
1255
+ import { manifest } from "./_generated/manifest.ts";
1256
+
1257
+ createServer({
1258
+ port: Number(process.env.PORT ?? 3000),
1259
+ appDir: "./app",
1260
+ publicDir: "./public",
1261
+ manifest, // no manifest read from disk
1262
+ routeFiles, // no Bun.Glob route scan
1263
+ moduleRegistry, // no dynamic import(absPath)
1264
+ actionModules, // no scan/import for "use server" files
1265
+ });
1266
+ ```
1267
+
1268
+ When all four are present, the server uses the pre-imported modules for routing, layouts, actions, and assets.
1269
+
1270
+ ### Manual pipeline
1271
+
1272
+ ```sh
1273
+ bractjs codegen:registry # A — scan routes/actions → static imports
1274
+ bractjs build # B — client + server bundles + manifest
1275
+ bractjs codegen:manifest # C — embed manifest as a TS constant
1276
+ bun build --compile app/server.ts \ # D — single binary
1277
+ --asset build/client/ --outfile ./myapp
1278
+ ```
1279
+
1280
+ `--asset build/client/` embeds JS/CSS into the binary (true single file); omit it to ship `myapp` + `build/client/` side by side.
1281
+
1282
+ The codegen functions are exported: `writeModuleRegistries(appDir)`, `writeManifestModule(appDir, buildDir)`, and the lower-level `generateRouteRegistry` / `generateActionRegistry` / `generateManifestModule`.
1283
+
1284
+ > **Contributor note — keep the binary working:** anything on the server request/startup path must avoid runtime `Bun.Glob`/computed-path `import()`, must fall back from `realpath()` to `Bun.file().exists()` for embedded assets, and must preserve every consumed export when projecting route modules. Two tests enforce this: `packages/core/src/__tests__/compile-safety.test.ts` (fast static scan) and `packages/core/src/__tests__/compile-smoke.test.ts` (compiles and boots a real binary).
1285
+
1286
+ ---
1287
+
1288
+ ## 23. Custom adapters
1289
+
1290
+ The server core is adapter-agnostic. The default is `BunAdapter` (wraps `Bun.serve`); supply your own via `createServer({ adapter })`.
1291
+
1292
+ ```ts
1293
+ import type { BractAdapter } from "@bractjs/bractjs";
1294
+ import { BunAdapter } from "@bractjs/bractjs";
1295
+
1296
+ // BractAdapter: { fetch(req): Promise<Response>; listen?(port): void }
1297
+ ```
1298
+
1299
+ **Cloudflare Workers:**
1300
+ ```ts
1301
+ import { buildFetchHandler, makeCloudflareHandler } from "@bractjs/bractjs";
1302
+ const handler = buildFetchHandler({ appDir: "./app", manifest });
1303
+ export default makeCloudflareHandler(handler);
1304
+ // or createCloudflareAdapter(handler) for the BractAdapter-compatible form
1305
+ ```
1306
+
1307
+ ---
1308
+
1309
+ ## 24. Build plugins
1310
+
1311
+ If you write your own `Bun.build()` (instead of `bractjs build`), you **must** apply these or face crashes / secret leaks. All are exported.
1312
+
1313
+ | Bundle | Plugin | Without it |
1314
+ |---|---|---|
1315
+ | Server | `useClientStubPlugin` | Server crashes calling browser-only hooks from `"use client"` modules. |
1316
+ | Client | `createUseServerProxyPlugin(appDir)` | Server-action bodies (DB code, secrets) ship in the browser JS. |
1317
+ | Client | `serverModuleStubPlugin` | `*.server.ts` source (DB drivers, secrets) leaks into the client bundle. |
1318
+ | Client | `clientEnvPlugin(allowedKeys, env)` | Server env vars leak into the browser bundle. |
1319
+ | Client | `cssModulesPlugin` | `*.module.css` imports don't resolve. |
1320
+
1321
+ ```ts
1322
+ import {
1323
+ useClientStubPlugin, createUseServerProxyPlugin,
1324
+ serverModuleStubPlugin, clientEnvPlugin, cssModulesPlugin,
1325
+ } from "@bractjs/bractjs";
1326
+
1327
+ // Server bundle (target: "bun"):
1328
+ plugins: [useClientStubPlugin];
1329
+
1330
+ // Client bundle (target: "browser"):
1331
+ plugins: [
1332
+ serverModuleStubPlugin,
1333
+ createUseServerProxyPlugin("./app"), // same appDir as createServer!
1334
+ clientEnvPlugin(["PUBLIC_API_URL"], Bun.env as Record<string, string>),
1335
+ cssModulesPlugin,
1336
+ ];
1337
+ ```
1338
+
1339
+ > Always pass the **same `appDir`** to `createUseServerProxyPlugin` that you pass to `createServer` — action IDs hash the appDir-relative path, so a mismatch makes every `/_action` return 404. `transformCssModule(filePath)` is exported for custom CSS pipelines; `useServerProxyPlugin` is the legacy absolute-path variant. `serverModuleStubPlugin` stubs `*.server.ts` exports so a route can import a server module inside its loader/action without leaking source; `serverOnlyPlugin` is the stricter predecessor that hard-fails such imports instead (still exported for opt-in use).
1340
+
1341
+ ---
1342
+
1343
+ ## 25. Configuration reference
1344
+
1345
+ 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`.
1346
+
1347
+ ```ts
1348
+ import { defineConfig } from "@bractjs/bractjs";
1349
+ export default defineConfig({ port: 3000, clientEnv: ["PUBLIC_API_URL"] });
1350
+ ```
1351
+
1352
+ | Field | Type | Default | Description |
1353
+ |-------|------|---------|-------------|
1354
+ | `port` | `number` | `3000` | TCP port |
1355
+ | `hmrPort` | `number` | `3001` | Dev HMR WebSocket port (`bractjs dev` only) |
1356
+ | `appDir` | `string` | `"./app"` | Contains `routes/` and `root.tsx` |
1357
+ | `publicDir` | `string` | `"./public"` | Static assets (served no-cache) |
1358
+ | `buildDir` | `string` | `"./build"` | Build output |
1359
+ | `imageCacheDir` | `string` | `".bract-image-cache"` | Optimized-image disk cache |
1360
+ | `maxRequestBodySize` | `number` | `16777216` (16 MiB) | Hard ceiling on any request body, enforced by the Bun adapter (§27) |
1361
+ | `sourcemap` | `string` | `"external"` | `"none" \| "linked" \| "inline" \| "external"` |
1362
+ | `minify` | `boolean` | `true` | Minify client bundles |
1363
+ | `clientEnv` | `string[]` | `[]` | `process.env` keys exposed to the client |
1364
+ | `plugins` | `BunPlugin[]` | `[]` | Extra client-build plugins |
1365
+ | `adapter` | `BractAdapter` | `BunAdapter` | Custom server adapter |
1366
+ | `i18n` | `I18nConfig` | — | Locale config consumed by the i18n utilities |
1367
+ | `ssr` | `boolean` | `true` | `false` → SPA mode: static shell for every document GET (§21) |
1368
+ | `prerender` | `string[] \| () => paths` | — | Paths to prerender at build time (§21) |
1369
+ | `onStart` / `onShutdown` / `onError` | hooks | — | Lifecycle (§16) |
1370
+
1371
+ `loadUserConfig()` validates these shapes and throws a clear error on an obvious mistake (e.g. a string `port`).
1372
+
1373
+ ---
1374
+
1375
+ ## 26. Full export index
1376
+
1377
+ Everything importable from `@bractjs/bractjs` ([packages/core/src/index.ts](packages/core/src/index.ts)):
1378
+
1379
+ **Server / runtime:** `createServer`, `buildFetchHandler`, `renderRoute`, `redirect`, `json`, `error`, `defineContext`, `route`, `validate`, `safeValidate`, `isValidationResponse`, `readValidationError`, `validateSearch`, `searchParamsToObject`, `hasForbiddenKey`, `nullProtoFromEntries`, `formText`, `formValues`, `defineActions`, `BunAdapter`, `defineLifecycle`, `renderSpaShell`
1380
+
1381
+ **Errors:** `BractJSError`, `HttpError`, `isRedirect`, `isHttpError`, `isBractJSError`
1382
+
1383
+ **Streaming:** `defer`, `Deferred`, `isDeferred`, `Await`
1384
+
1385
+ **Context:** `BractJSContext`, `BractJSProvider`, `useBractJSContext`
1386
+
1387
+ **Middleware:** `pipeline`, `MiddlewarePipeline`, `runRouteMiddleware`, `collectRouteMiddleware`, `requestLogger`, `cors`, `authGuard`, `csp`, `getCspNonce`, `CSP_NONCE_KEY`
1388
+
1389
+ **Sessions:** `createCookieSession`
1390
+
1391
+ **Components:** `Outlet`, `Link`, `Form`, `Scripts`, `LiveReload`, `Await`, `Image`, `ScrollRestoration`, `Toaster`
1392
+
1393
+ **Hooks:** `useLoaderData`, `useActionData`, `useLocation`, `useParams`, `useMatches`, `useNavigation`, `useNavigate`, `useFetcher`, `useFetchers`, `useRevalidator`, `useSearch`, `useSetSearch`, `useSearchParams`, `useBlocker`, `useToast`, `useToasts`, `useLocale`, `useLocalizedLink`
1394
+
1395
+ **Toasts:** `toast`, `toastStore`
1396
+
1397
+ **Search serialization:** `serializeSearch`
1398
+
1399
+ **i18n:** `wrapRoutesWithLocale`, `stripLocale`, `localizedDataPath`
1400
+
1401
+ **Client RPC:** `createClient`
1402
+
1403
+ **Build / programmatic:** `createDevServer`, `runBuild`, `loadUserConfig`, `defineConfig`, `runPrerender`
1404
+
1405
+ **Codegen:** `writeModuleRegistries`, `writeManifestModule`, `generateRouteRegistry`, `generateActionRegistry`, `generateManifestModule`
1406
+
1407
+ **Build plugins:** `useClientStubPlugin`, `createUseServerProxyPlugin`, `useServerProxyPlugin`, `serverModuleStubPlugin`, `serverOnlyPlugin`, `clientEnvPlugin`, `cssModulesPlugin`, `transformCssModule`
1408
+
1409
+ **Adapters:** `createCloudflareAdapter`, `makeCloudflareHandler`
1410
+
1411
+ **Types:** `LoaderArgs`, `ActionArgs`, `MetaArgs`, `MetaDescriptor`, `LoaderFunction`, `ActionFunction`, `MetaFunction`, `RouteModule`, `RouteDefinition`, `RouteFile`, `Segment`, `RouterLocation`, `ShouldRevalidateArgs`, `ShouldRevalidateFunction`, `BractJSConfig`, `RenderOptions`, `ServerManifest`, `ContextFactory`, `ApiRouteDefinition`, `ApiRouteOptions`, `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`, `ToasterProps`, `ToastPosition`, `Toast`, `ToastEntry`, `ToastOptions`, `ToastType`, `ToastAction`, `PrerenderOptions`, `PrerenderResult`, `I18nConfig`, `DevServerOptions`, `DevServer`, `BuildConfig`, `CodegenResult`, `ModuleRegistry`, `BractJSContextValue`, `RouteManifest`
1412
+
1413
+ ---
1414
+
1415
+ ## 27. Security model
1416
+
1417
+ BractJS ships secure defaults, but a few behaviors are worth understanding so you don't accidentally widen your attack surface.
1418
+
1419
+ - **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.
1420
+ - **`/_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.
1421
+ - **Typed `/api` routes are CSRF-protected by default.** Mutating routes (`POST`/`PUT`/`PATCH`/`DELETE`) require a same-origin proof just like server actions; cross-site requests get `403`. Opt out with `route(..., { csrf: false })` **only** for endpoints that don't trust ambient credentials (webhooks, token-authenticated/public APIs). As with actions, the CSRF gate is not authentication — authorize inside the handler.
1422
+ - **Global middleware covers every endpoint.** Anything attached to `pipeline.use(...)` — `cors()`, `csp()`, `authGuard()`, a rate limiter, custom logging — runs for typed `/api` routes, `/_action`, `/_stream`, `/_image`, static assets, and SSR documents alike. (This was previously SSR-only; a cross-cutting guard you register globally now actually applies to your API surface.)
1423
+ - **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 part of the CSRF gate; the built-in `cors()` deliberately omits it. If you write your own CORS layer and expose that header cross-origin, you defeat CSRF on both actions and `/api`; add a cryptographic double-submit token if you must. The header-based gate also assumes browsers send `Sec-Fetch-Site` — behind a proxy that strips it, rely on same-origin `Origin` (which `cors()` does not weaken).
1424
+ - **Error messages.** In production, loader/action/api 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`.
1425
+ - **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.
1426
+ - **Request body size.** Beyond the per-handler caps (1 MiB JSON for actions/api, 10 MiB route forms), the Bun adapter enforces a hard `maxRequestBodySize` ceiling (default 16 MiB) so no path can stream an unbounded body into memory. Raise it via the `maxRequestBodySize` config for a dedicated large-upload endpoint.
1427
+ - **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 **and** `/api` JSON bodies plus null-prototype objects for form/search inputs, request body-size caps + global backstop, signed/constant-time-verified cookie sessions, CSP defaults (`script-src` nonce + `strict-dynamic`, `form-action`/`base-uri`/`frame-ancestors` `'self'`, `object-src 'none'`), and CSRF via layered `Sec-Fetch-Site` + custom-header + `Origin` checks across actions, `/_stream`, route mutations, and `/api`.
1428
+
1429
+ ---
1430
+
1431
+ ## Changelog
1432
+
1433
+ See [CHANGELOG.md](CHANGELOG.md) for release history.
1434
+
1435
+ ---
1436
+
1437
+ ## Why BractJS
1438
+
1439
+ - **Bun-native** — `Bun.serve`, `Bun.build`, `Bun.file`, `Bun.Glob`, `Bun.watch`. No Node.js.
1440
+ - **Zero framework deps** — only peers are `react` and `react-dom`.
1441
+ - **Streaming SSR** — `renderToReadableStream()` with `defer()` and `<Await>`.
1442
+ - **File-based routing** — drop a file in `app/routes/`.
1443
+ - **Full-stack** — loaders, actions, sessions, server actions, typed API routes, middleware.
1444
+ - **Typed routes** — codegen wires `<Link>`, `useNavigate`, and `useParams` to your routes (autocompleted paths, typed params), plus a type-safe URL builder.
1445
+ - **Single-binary** — `bun build --compile` to one executable.
1446
+
1447
+ ## License
1448
+
1449
+ MIT