@bractjs/bractjs 0.1.25 → 0.1.27
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 +773 -465
- package/bin/cli.ts +23 -3
- package/package.json +1 -1
- package/src/__tests__/build-path.test.ts +29 -0
- package/src/__tests__/codegen.test.ts +36 -0
- package/src/__tests__/compile-safety.test.ts +163 -0
- package/src/__tests__/compile-smoke.test.ts +276 -0
- package/src/__tests__/csp.test.ts +80 -0
- package/src/__tests__/fixtures/app/routes/_index.tsx +6 -2
- package/src/__tests__/fixtures/app/routes/protected.tsx +15 -0
- package/src/__tests__/fixtures/app/routes/redirect-action.tsx +13 -0
- package/src/__tests__/integration.test.ts +62 -0
- package/src/__tests__/layout-registry.test.ts +23 -0
- package/src/__tests__/loader.test.ts +23 -0
- package/src/__tests__/middleware.test.ts +22 -0
- package/src/__tests__/programmatic-api.test.ts +41 -2
- package/src/__tests__/response.test.ts +54 -1
- package/src/__tests__/security.test.ts +35 -0
- package/src/__tests__/server-module-stub.test.ts +145 -0
- package/src/__tests__/stream-handler.test.ts +36 -0
- package/src/__tests__/typed-routing.test.ts +189 -0
- package/src/build/bundler.ts +46 -20
- package/src/build/directives.ts +2 -2
- package/src/build/env-plugin.ts +63 -0
- package/src/build/react-dedupe.ts +41 -0
- package/src/client/ClientRouter.tsx +22 -8
- package/src/client/build-path.ts +24 -0
- package/src/client/components/Form.tsx +10 -1
- package/src/client/components/Link.tsx +31 -8
- package/src/client/hooks/useFetcher.ts +17 -1
- package/src/client/hooks/useNavigate.ts +46 -0
- package/src/client/hooks/useParams.ts +15 -4
- package/src/client/hooks/useSearchParams.ts +16 -6
- package/src/client/nav-utils.ts +54 -3
- package/src/client/registry.ts +107 -0
- package/src/client/types.ts +3 -0
- package/src/codegen/route-codegen.ts +62 -23
- package/src/config/load.ts +50 -2
- package/src/dev/devtools.ts +72 -39
- package/src/dev/hmr-module-handler.ts +6 -4
- package/src/dev/rebuilder.ts +16 -1
- package/src/dev/server.ts +3 -0
- package/src/index.ts +30 -3
- package/src/server/csp.ts +92 -0
- package/src/server/csrf.ts +44 -6
- package/src/server/layout.ts +12 -2
- package/src/server/loader.ts +5 -7
- package/src/server/render.ts +29 -10
- package/src/server/request-handler.ts +15 -4
- package/src/server/response.ts +58 -5
- package/src/server/serve.ts +10 -0
- package/src/server/static.ts +11 -1
- package/src/server/stream-handler.ts +8 -7
- package/src/server/use-client-runtime.ts +62 -0
- package/src/shared/meta-tags.tsx +46 -0
- package/types/index.d.ts +67 -5
package/README.md
CHANGED
|
@@ -1,27 +1,152 @@
|
|
|
1
1
|
# BractJS
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@bractjs/bractjs)
|
|
4
|
+
[](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 [src/index.ts](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>`](#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)
|
|
5
46
|
|
|
6
47
|
---
|
|
7
48
|
|
|
8
|
-
##
|
|
49
|
+
## 1. Install & create an app
|
|
50
|
+
|
|
51
|
+
BractJS requires [Bun](https://bun.sh). There is no Node.js runtime path.
|
|
9
52
|
|
|
10
53
|
```sh
|
|
11
|
-
#
|
|
54
|
+
# Scaffold a new app
|
|
12
55
|
bunx bractjs new my-app
|
|
13
56
|
cd my-app
|
|
57
|
+
|
|
58
|
+
# Start the dev server (HMR on http://localhost:3000)
|
|
14
59
|
bun run dev
|
|
15
|
-
# → http://localhost:3000
|
|
16
60
|
```
|
|
17
61
|
|
|
18
|
-
|
|
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.
|
|
19
144
|
|
|
20
145
|
---
|
|
21
146
|
|
|
22
|
-
## File-
|
|
147
|
+
## 4. File-based routing
|
|
23
148
|
|
|
24
|
-
|
|
149
|
+
Drop a file in `app/routes/`; it becomes a route. BractJS scans at startup and builds a trie.
|
|
25
150
|
|
|
26
151
|
| File | URL |
|
|
27
152
|
|------|-----|
|
|
@@ -32,95 +157,162 @@ Place files inside `app/routes/`. BractJS scans them at startup.
|
|
|
32
157
|
| `routes/docs/[...slug].tsx` | `/docs/*` (catch-all) |
|
|
33
158
|
| `routes/blog/layout.tsx` | wraps all `/blog/*` routes |
|
|
34
159
|
|
|
35
|
-
`
|
|
160
|
+
- `[param]` → a dynamic segment, read via `useParams()` / `params` arg.
|
|
161
|
+
- `[...name]` → a catch-all; the rest of the path lands in `params.name`.
|
|
162
|
+
- `layout.tsx` in any directory wraps every route under it (layouts nest: `root → blog/layout → blog/[id]`).
|
|
163
|
+
- Match priority per segment: **static > dynamic > catch-all**.
|
|
164
|
+
|
|
165
|
+
No registration step — the file IS the route.
|
|
36
166
|
|
|
37
167
|
---
|
|
38
168
|
|
|
39
|
-
## Route
|
|
169
|
+
## 5. Route module API
|
|
40
170
|
|
|
41
|
-
Every file in `app/routes/`
|
|
171
|
+
Every file in `app/routes/` (and `root.tsx`/`layout.tsx`) may export any combination of these. Import the arg types from the package.
|
|
42
172
|
|
|
43
173
|
```tsx
|
|
44
174
|
import type { LoaderArgs, ActionArgs, MetaArgs } from "@bractjs/bractjs";
|
|
45
|
-
import { redirect } from "@bractjs/bractjs";
|
|
175
|
+
import { redirect, json, HttpError } from "@bractjs/bractjs";
|
|
46
176
|
|
|
47
|
-
//
|
|
177
|
+
// 1) loader — runs on every GET. Return value → useLoaderData().
|
|
48
178
|
export async function loader({ request, params, context }: LoaderArgs) {
|
|
49
179
|
const post = await db.post.findById(params.id);
|
|
50
|
-
if (!post) throw new
|
|
180
|
+
if (!post) throw new HttpError(404, "Not found"); // → 404 page
|
|
51
181
|
return { post };
|
|
52
182
|
}
|
|
53
|
-
|
|
54
183
|
export type LoaderData = Awaited<ReturnType<typeof loader>>;
|
|
55
184
|
|
|
56
|
-
//
|
|
57
|
-
export async function action({ params, formData }: ActionArgs) {
|
|
185
|
+
// 2) action — runs on POST / PUT / DELETE / PATCH.
|
|
186
|
+
export async function action({ request, params, context, formData }: ActionArgs) {
|
|
58
187
|
await db.post.update(params.id, { title: formData.get("title") as string });
|
|
59
188
|
return redirect("/blog");
|
|
60
189
|
}
|
|
61
190
|
|
|
62
|
-
// SSR <title>
|
|
63
|
-
export function meta({ loaderData }: MetaArgs<LoaderData>) {
|
|
191
|
+
// 3) meta — SSR <title> / <meta>. Receives this route's loaderData slice.
|
|
192
|
+
export function meta({ loaderData, params }: MetaArgs<LoaderData>) {
|
|
64
193
|
return [
|
|
65
194
|
{ title: loaderData.post.title },
|
|
66
195
|
{ name: "description", content: loaderData.post.excerpt },
|
|
196
|
+
{ property: "og:title", content: loaderData.post.title },
|
|
67
197
|
];
|
|
68
198
|
}
|
|
69
199
|
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
200
|
+
// 4) beforeLoad — auth/redirect gate. Runs BEFORE loaders, on full-page GET
|
|
201
|
+
// AND the /_data soft-nav endpoint. Return a Response to short-circuit.
|
|
202
|
+
export function beforeLoad({ context, params, location }) {
|
|
203
|
+
if (!context.user) {
|
|
204
|
+
return redirect(`/login?next=${encodeURIComponent(location.pathname)}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 5) ErrorBoundary — renders when this segment's loader/component throws.
|
|
209
|
+
export function ErrorBoundary({ error }: { error: unknown }) {
|
|
210
|
+
return <p>Something broke: {error instanceof Error ? error.message : String(error)}</p>;
|
|
73
211
|
}
|
|
74
212
|
|
|
75
|
-
//
|
|
213
|
+
// 6) default — the page component (required for a renderable route).
|
|
76
214
|
export default function BlogPost() {
|
|
77
215
|
const { post } = useLoaderData<LoaderData>();
|
|
78
216
|
return <article><h1>{post.title}</h1></article>;
|
|
79
217
|
}
|
|
80
218
|
```
|
|
81
219
|
|
|
220
|
+
**Execution order for a request:**
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
beforeLoad → (action, if mutating method) → loaders (root + layouts + route, in parallel) → render
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
- **Loaders run concurrently** (root, every layout, and the route loader all in one `Promise.all`).
|
|
227
|
+
- 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`.
|
|
228
|
+
|
|
229
|
+
> **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
|
+
|
|
82
231
|
---
|
|
83
232
|
|
|
84
|
-
##
|
|
233
|
+
## 6. Response helpers
|
|
85
234
|
|
|
86
|
-
|
|
235
|
+
Imported from `@bractjs/bractjs`.
|
|
87
236
|
|
|
88
|
-
```
|
|
89
|
-
import {
|
|
237
|
+
```ts
|
|
238
|
+
import { json, redirect, error, HttpError, isRedirect, isHttpError } from "@bractjs/bractjs";
|
|
239
|
+
```
|
|
90
240
|
|
|
91
|
-
|
|
92
|
-
|
|
241
|
+
### `json(data, init?)`
|
|
242
|
+
Serialize a value as `application/json`.
|
|
243
|
+
```ts
|
|
244
|
+
return json({ ok: true }, { status: 201 });
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### `redirect(url, status?, headers?, options?)`
|
|
248
|
+
Throw or return a redirect. **Open-redirect safe by default** — rejects `//evil.com`, `/\evil`, `https://…`, `javascript:` unless you pass `{ allowExternal: true }`.
|
|
249
|
+
```ts
|
|
250
|
+
return redirect("/dashboard"); // 302
|
|
251
|
+
return redirect("/login", 303); // custom status
|
|
252
|
+
return redirect("/x", 302, { "Set-Cookie": cookie }); // with headers
|
|
253
|
+
return redirect("https://other.com", 302, undefined, { allowExternal: true });
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### `error(message, status?)`
|
|
257
|
+
Convenience JSON error: `{ "error": message }` with the given status (default 500).
|
|
258
|
+
```ts
|
|
259
|
+
return error("Bad Request", 400);
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### `HttpError` & `BractJSError`
|
|
263
|
+
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).
|
|
264
|
+
```ts
|
|
265
|
+
throw new HttpError(403); // → 403 Forbidden
|
|
266
|
+
throw new HttpError(404, "No such post"); // → 404 with custom message
|
|
267
|
+
```
|
|
268
|
+
`isRedirect(value)` / `isHttpError(value)` / `isBractJSError(value)` are type guards if you handle errors manually.
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## 7. Per-route context: `defineContext`
|
|
273
|
+
|
|
274
|
+
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.
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
// app/routes/dashboard.tsx
|
|
278
|
+
import { defineContext } from "@bractjs/bractjs";
|
|
279
|
+
import { getUser } from "../auth.server.ts";
|
|
280
|
+
|
|
281
|
+
export const context = defineContext(async ({ request, params }) => ({
|
|
282
|
+
user: await getUser(request),
|
|
283
|
+
}));
|
|
284
|
+
|
|
285
|
+
export function beforeLoad({ context }) {
|
|
286
|
+
if (!context.user) return redirect("/login");
|
|
93
287
|
}
|
|
94
288
|
|
|
95
|
-
export
|
|
96
|
-
return
|
|
97
|
-
<html lang="en">
|
|
98
|
-
<head>{/* BractJS injects <title> and <meta> tags here */}</head>
|
|
99
|
-
<body>
|
|
100
|
-
<Outlet /> {/* current route tree */}
|
|
101
|
-
<Scripts /> {/* client bundle */}
|
|
102
|
-
<LiveReload /> {/* dev-only HMR — no-op in production */}
|
|
103
|
-
</body>
|
|
104
|
-
</html>
|
|
105
|
-
);
|
|
289
|
+
export async function loader({ context }) {
|
|
290
|
+
return { name: context.user.name };
|
|
106
291
|
}
|
|
107
292
|
```
|
|
108
293
|
|
|
294
|
+
The factory runs before `beforeLoad` and its result is merged into `context` for `beforeLoad`, `loader`, and `action` on that route.
|
|
295
|
+
|
|
109
296
|
---
|
|
110
297
|
|
|
111
|
-
##
|
|
298
|
+
## 8. Streaming data
|
|
112
299
|
|
|
113
|
-
|
|
300
|
+
Stream slow data without blocking the initial HTML.
|
|
114
301
|
|
|
115
|
-
```
|
|
116
|
-
import { defer } from "@bractjs/bractjs";
|
|
302
|
+
```ts
|
|
303
|
+
import { defer, Deferred, isDeferred } from "@bractjs/bractjs";
|
|
117
304
|
import { Await } from "@bractjs/bractjs";
|
|
118
305
|
import { Suspense } from "react";
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### `defer(data)`
|
|
309
|
+
Wraps each `Promise` field in a `Deferred`; non-promise fields pass through. Awaited fields are in the initial HTML; promises stream after.
|
|
119
310
|
|
|
311
|
+
```tsx
|
|
120
312
|
export async function loader({ params }: LoaderArgs) {
|
|
121
313
|
return defer({
|
|
122
|
-
post: await db.post.findById(params.id), // awaited
|
|
123
|
-
comments: db.comments.forPost(params.id), // Promise
|
|
314
|
+
post: await db.post.findById(params.id), // awaited → initial HTML
|
|
315
|
+
comments: db.comments.forPost(params.id), // Promise → streamed
|
|
124
316
|
});
|
|
125
317
|
}
|
|
126
318
|
|
|
@@ -139,176 +331,135 @@ export default function BlogPost() {
|
|
|
139
331
|
}
|
|
140
332
|
```
|
|
141
333
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
## Client Primitives
|
|
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.
|
|
145
336
|
|
|
146
|
-
|
|
337
|
+
---
|
|
147
338
|
|
|
148
|
-
|
|
339
|
+
## 9. Client hooks
|
|
149
340
|
|
|
150
|
-
|
|
151
|
-
import { Link } from "@bractjs/bractjs";
|
|
341
|
+
All hooks are SSR-safe (they return sensible values during SSR) and imported from `@bractjs/bractjs`.
|
|
152
342
|
|
|
153
|
-
<
|
|
154
|
-
|
|
343
|
+
### `useLoaderData<T>()` → `T`
|
|
344
|
+
The current route's loader return value.
|
|
345
|
+
```ts
|
|
346
|
+
const { post } = useLoaderData<LoaderData>();
|
|
155
347
|
```
|
|
156
348
|
|
|
157
|
-
###
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
```tsx
|
|
162
|
-
import { Form } from "@bractjs/bractjs";
|
|
163
|
-
|
|
164
|
-
<Form method="post" action="/blog/new">
|
|
165
|
-
<input name="title" />
|
|
166
|
-
<button type="submit">Create</button>
|
|
167
|
-
</Form>
|
|
349
|
+
### `useActionData<T>()` → `T | null`
|
|
350
|
+
The most recent action return value (null until an action runs).
|
|
351
|
+
```ts
|
|
352
|
+
const result = useActionData<{ error?: string }>();
|
|
168
353
|
```
|
|
169
354
|
|
|
170
|
-
###
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
export default function BlogLayout() {
|
|
176
|
-
return (
|
|
177
|
-
<div>
|
|
178
|
-
<nav>Blog</nav>
|
|
179
|
-
<Outlet />
|
|
180
|
-
</div>
|
|
181
|
-
);
|
|
182
|
-
}
|
|
355
|
+
### `useParams<T>()` → `T`
|
|
356
|
+
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.
|
|
357
|
+
```ts
|
|
358
|
+
const { id } = useParams<"/blog/:id">(); // { id: string } — typed from routes
|
|
359
|
+
const { id } = useParams<{ id: string }>(); // or a hand-written shape
|
|
183
360
|
```
|
|
361
|
+
> 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).
|
|
184
362
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
| Hook | Returns | Description |
|
|
190
|
-
|------|---------|-------------|
|
|
191
|
-
| `useLoaderData<T>()` | `T` | Loader return value for the current route |
|
|
192
|
-
| `useActionData<T>()` | `T \| null` | Most recent action return value |
|
|
193
|
-
| `useParams<T>()` | `T` | URL dynamic params (generic for typed params) |
|
|
194
|
-
| `useNavigation()` | `{ state }` | `"idle"` \| `"loading"` \| `"submitting"` |
|
|
195
|
-
| `useFetcher()` | `{ data, state, load, submit }` | Background fetch without navigation |
|
|
196
|
-
|
|
197
|
-
```tsx
|
|
198
|
-
import { useLoaderData, useNavigation, useFetcher } from "@bractjs/bractjs";
|
|
199
|
-
|
|
200
|
-
const { post } = useLoaderData<LoaderData>();
|
|
201
|
-
|
|
363
|
+
### `useNavigation()` → `{ state }`
|
|
364
|
+
`"idle" | "loading" | "submitting"`.
|
|
365
|
+
```ts
|
|
202
366
|
const { state } = useNavigation();
|
|
203
367
|
if (state === "loading") return <Spinner />;
|
|
204
|
-
|
|
205
|
-
const fetcher = useFetcher();
|
|
206
|
-
fetcher.load("/api/suggestions?q=bun");
|
|
207
368
|
```
|
|
208
369
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
import { Image } from "@bractjs/bractjs";
|
|
217
|
-
|
|
218
|
-
// Basic — lazy, WebP, 80% quality, responsive srcset
|
|
219
|
-
<Image src="/public/hero.jpg" alt="Hero" width={1200} height={600} />
|
|
220
|
-
|
|
221
|
-
// Above-the-fold — eager load + fetchpriority=high
|
|
222
|
-
<Image src="/public/hero.jpg" alt="Hero" width={1200} priority />
|
|
223
|
-
|
|
224
|
-
// Custom format / quality / fit
|
|
225
|
-
<Image
|
|
226
|
-
src="/public/photo.jpg"
|
|
227
|
-
alt="Photo"
|
|
228
|
-
width={800}
|
|
229
|
-
format="avif"
|
|
230
|
-
quality={70}
|
|
231
|
-
fit="contain"
|
|
232
|
-
sizes="(max-width: 640px) 100vw, 50vw"
|
|
233
|
-
/>
|
|
370
|
+
### `useNavigate()` → `(to, { params? }) => Promise<void>`
|
|
371
|
+
Imperative soft navigation — the counterpart to `<Link>`. `to` autocompletes your routes (after codegen, §18) and `params` is typed per route; any string is still accepted.
|
|
372
|
+
```ts
|
|
373
|
+
const navigate = useNavigate();
|
|
374
|
+
await navigate("/blog/:id", { params: { id: "42" } }); // typed
|
|
375
|
+
await navigate("/about"); // static
|
|
376
|
+
await navigate(`/blog/${id}`); // built string (also fine)
|
|
234
377
|
```
|
|
235
378
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
| `alt` | `string` | — | Alt text (required) |
|
|
244
|
-
| `width` | `number` | — | Intrinsic width |
|
|
245
|
-
| `height` | `number` | — | Intrinsic height |
|
|
246
|
-
| `quality` | `number` | `80` | 1–100 |
|
|
247
|
-
| `format` | `"webp" \| "avif" \| "jpeg" \| "png"` | `"webp"` | Output format |
|
|
248
|
-
| `fit` | `"cover" \| "contain" \| "fill"` | `"cover"` | Resize mode |
|
|
249
|
-
| `priority` | `boolean` | `false` | Disable lazy loading, set `fetchpriority=high` |
|
|
250
|
-
| `sizes` | `string` | `"100vw"` | HTML `sizes` attribute |
|
|
251
|
-
|
|
252
|
-
---
|
|
253
|
-
|
|
254
|
-
## Typed Routes (Codegen)
|
|
255
|
-
|
|
256
|
-
Run `bractjs codegen` to generate `app/route-types.gen.ts` from your route files. This runs automatically during `bractjs build`.
|
|
257
|
-
|
|
258
|
-
```sh
|
|
259
|
-
bractjs codegen # reads ./app, writes ./app/route-types.gen.ts
|
|
260
|
-
bractjs codegen ./app ./app/route-types.gen.ts # explicit paths
|
|
379
|
+
### `useSearchParams<T>()` → `{ searchParams, getParam, setSearchParams }`
|
|
380
|
+
Read/write URL query params; writing triggers a soft-nav loader re-run. Pass the route pattern as a generic to type the result against `RouteSearchParamsMap` (augment it per route, §18); an object shape also works.
|
|
381
|
+
```ts
|
|
382
|
+
const { searchParams, getParam, setSearchParams } = useSearchParams<"/blog/:id">();
|
|
383
|
+
const q = getParam("q"); // string | null
|
|
384
|
+
setSearchParams({ q: "bun" }); // replace all params
|
|
385
|
+
setSearchParams((prev) => { prev.set("page", "2"); return prev; }); // update
|
|
261
386
|
```
|
|
262
387
|
|
|
263
|
-
|
|
264
|
-
|
|
388
|
+
### `useFetcher()` → `{ data, state, load, submit }`
|
|
389
|
+
Background fetch without navigating.
|
|
265
390
|
```ts
|
|
266
|
-
|
|
267
|
-
|
|
391
|
+
const fetcher = useFetcher();
|
|
392
|
+
await fetcher.load("/products?q=bun"); // GET loader data
|
|
393
|
+
await fetcher.submit("/cart", { method: "post", body: { id: "1" } });
|
|
394
|
+
```
|
|
268
395
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
396
|
+
### `useFetcher<T>({ stream: true })` → `{ connect }`
|
|
397
|
+
Consume an async-generator server action as an SSE stream.
|
|
398
|
+
```ts
|
|
399
|
+
const { connect } = useFetcher<string>({ stream: true });
|
|
400
|
+
for await (const chunk of connect(actionId)) { /* … */ }
|
|
401
|
+
```
|
|
274
402
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
403
|
+
### `useBlocker(shouldBlock)`
|
|
404
|
+
Prompt before leaving when there are unsaved changes (intercepts back/forward and `<Link>` navigations).
|
|
405
|
+
```ts
|
|
406
|
+
useBlocker(() => formIsDirty);
|
|
407
|
+
```
|
|
278
408
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
409
|
+
### `useLocale(defaultLocale?)` → `string` and `useLocalizedLink(defaultLocale?)` → `(path) => string`
|
|
410
|
+
For i18n prefix routing (§19).
|
|
411
|
+
```ts
|
|
412
|
+
const locale = useLocale("en"); // reads params.locale
|
|
413
|
+
const localized = useLocalizedLink("en");
|
|
414
|
+
<Link to={localized("/about")} /> // → /en/about
|
|
284
415
|
```
|
|
285
416
|
|
|
286
|
-
|
|
417
|
+
---
|
|
287
418
|
|
|
288
|
-
|
|
289
|
-
// Typed loader — params.id is string, not string | undefined
|
|
290
|
-
import type { TypedLoaderArgs, RouteParams } from "../route-types.gen.ts";
|
|
419
|
+
## 10. Client components
|
|
291
420
|
|
|
292
|
-
|
|
293
|
-
|
|
421
|
+
### `<Outlet />`
|
|
422
|
+
Renders the matched child route inside a layout (or the route tree inside `root.tsx`).
|
|
423
|
+
```tsx
|
|
424
|
+
export default function BlogLayout() {
|
|
425
|
+
return <div><nav>Blog</nav><Outlet /></div>;
|
|
294
426
|
}
|
|
427
|
+
```
|
|
295
428
|
|
|
296
|
-
|
|
297
|
-
|
|
429
|
+
### `<Link to params? prefetch? viewTransition?>`
|
|
430
|
+
Soft-navigates without a full reload. After codegen (§18), `to` autocompletes your routes; for a dynamic route pass typed `params`. Building the URL yourself still works, so existing links need no changes.
|
|
431
|
+
```tsx
|
|
432
|
+
<Link to="/blog/:id" params={{ id: "42" }}>Read</Link> {/* typed route + params */}
|
|
433
|
+
<Link to={`/blog/${id}`}>Read</Link> {/* built string — also fine */}
|
|
434
|
+
<Link to="/about" prefetch="hover">About</Link> {/* preload chunk + loader on hover */}
|
|
435
|
+
<Link to="/gallery" viewTransition>Gallery</Link> {/* use View Transitions API */}
|
|
436
|
+
```
|
|
437
|
+
Modifier-clicks (ctrl/cmd/shift/alt) fall back to native browser navigation.
|
|
298
438
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
439
|
+
### `<Form method action?>`
|
|
440
|
+
Fetch-based submission that re-runs the current route's loader after the action.
|
|
441
|
+
```tsx
|
|
442
|
+
<Form method="post">
|
|
443
|
+
<input name="title" />
|
|
444
|
+
<button type="submit">Create</button>
|
|
445
|
+
</Form>
|
|
446
|
+
<Form method="post" action="/blog/new">…</Form>
|
|
303
447
|
```
|
|
448
|
+
Submits as `multipart/form-data` with the `X-BractJS-Action` header (CSRF gate). If the action returns a redirect, the form follows it.
|
|
449
|
+
|
|
450
|
+
### `<Scripts />` and `<LiveReload />`
|
|
451
|
+
Markers used inside `root.tsx` (§3). `<Scripts />` is where the client bundle + bootstrap data go; `<LiveReload />` is the dev-only HMR client.
|
|
452
|
+
|
|
453
|
+
### `<Image />`
|
|
454
|
+
Responsive, format-converted images via the built-in `/_image` endpoint — see §20.
|
|
304
455
|
|
|
305
456
|
---
|
|
306
457
|
|
|
307
|
-
## Server Actions & Client Components
|
|
458
|
+
## 11. Server Actions & Client Components
|
|
308
459
|
|
|
309
460
|
### `"use server"` — Server Actions
|
|
310
461
|
|
|
311
|
-
|
|
462
|
+
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)`.
|
|
312
463
|
|
|
313
464
|
```ts
|
|
314
465
|
// app/actions.ts
|
|
@@ -326,7 +477,7 @@ export async function deletePost(id: string) {
|
|
|
326
477
|
```
|
|
327
478
|
|
|
328
479
|
```tsx
|
|
329
|
-
// app/routes/new.tsx — import
|
|
480
|
+
// app/routes/new.tsx — import as normal; the client bundle gets a fetch proxy
|
|
330
481
|
import { createPost } from "../actions.ts";
|
|
331
482
|
|
|
332
483
|
export default function NewPost() {
|
|
@@ -339,11 +490,15 @@ export default function NewPost() {
|
|
|
339
490
|
}
|
|
340
491
|
```
|
|
341
492
|
|
|
342
|
-
|
|
493
|
+
- Accepts a single `FormData` (sent as `multipart/form-data`) **or** a JSON-serializable argument array.
|
|
494
|
+
- Unknown action IDs return 404 — only functions registered at startup are callable.
|
|
495
|
+
- Bodies are size-capped (1 MiB JSON) and prototype-pollution scanned.
|
|
496
|
+
|
|
497
|
+
**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).
|
|
343
498
|
|
|
344
499
|
### `"use client"` — Client-Only Components
|
|
345
500
|
|
|
346
|
-
|
|
501
|
+
Mark a component browser-only. During server builds the module is stubbed to `null` to prevent `window`/`document`/`localStorage` crashes during SSR.
|
|
347
502
|
|
|
348
503
|
```tsx
|
|
349
504
|
// app/components/Counter.tsx
|
|
@@ -358,53 +513,148 @@ export function Counter() {
|
|
|
358
513
|
|
|
359
514
|
---
|
|
360
515
|
|
|
361
|
-
##
|
|
516
|
+
## 12. Typed API routes
|
|
517
|
+
|
|
518
|
+
Define type-safe JSON endpoints under `/api/*` with `route`, and call them with a fully-typed `createClient`.
|
|
519
|
+
|
|
520
|
+
### Define routes with `route(method, path, handler)`
|
|
521
|
+
|
|
522
|
+
```ts
|
|
523
|
+
// app/api/users.ts
|
|
524
|
+
import { route } from "@bractjs/bractjs";
|
|
525
|
+
import { db } from "../db.server.ts";
|
|
526
|
+
|
|
527
|
+
export const listUsers = route("GET", "/api/users", async () => {
|
|
528
|
+
return db.users.findAll();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
export const createUser = route("POST", "/api/users", async (input: { name: string }) => {
|
|
532
|
+
return db.users.create(input);
|
|
533
|
+
});
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
- `GET`/`DELETE`: no body parsed. `POST`/`PUT`/`PATCH`: JSON or form body parsed into `input`.
|
|
537
|
+
- Bodies are capped at 1 MiB. Errors return a generic 500 in production (full message in dev).
|
|
538
|
+
- Handlers also receive the raw `Request` as the 2nd arg.
|
|
539
|
+
- `:param` segments match any non-empty value; **read and validate params from `request.url` yourself** (they aren't injected into `input`).
|
|
540
|
+
|
|
541
|
+
### Call them with `createClient<AppApiRoutes>()`
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
import { createClient } from "@bractjs/bractjs";
|
|
545
|
+
import type { AppApiRoutes } from "@bractjs/bractjs"; // union of your route defs
|
|
546
|
+
|
|
547
|
+
const client = createClient<AppApiRoutes>(); // optional baseUrl arg
|
|
548
|
+
const users = await client["/api/users"].GET(); // typed output
|
|
549
|
+
await client["/api/users"].POST({ name: "Alice" }); // typed input
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
The proxy builds `METHOD path` from the property chain. Non-2xx responses throw an `Error` with `.status` and `.response` attached.
|
|
553
|
+
|
|
554
|
+
---
|
|
555
|
+
|
|
556
|
+
## 13. Input validation: `validate`
|
|
557
|
+
|
|
558
|
+
Validate `FormData` or a plain object against any **Zod- or Valibot-compatible** schema (anything with `.safeParse()` or `.parse()`).
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
import { validate } from "@bractjs/bractjs";
|
|
562
|
+
import { z } from "zod";
|
|
563
|
+
|
|
564
|
+
const Schema = z.object({ title: z.string().min(1), tags: z.array(z.string()) });
|
|
565
|
+
|
|
566
|
+
export async function action({ formData }: ActionArgs) {
|
|
567
|
+
// Throws a 400 Response with { errors: { field: [msgs] } } on failure.
|
|
568
|
+
const data = await validate(Schema, formData);
|
|
569
|
+
await db.post.create(data); // data is fully typed + coerced
|
|
570
|
+
}
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
- Repeated `FormData` keys become arrays automatically.
|
|
574
|
+
- On failure it throws a `Response.json({ errors }, { status: 400 })`. The exported `ValidationError` type and `FieldErrors` shape describe the structure.
|
|
575
|
+
|
|
576
|
+
---
|
|
577
|
+
|
|
578
|
+
## 14. Middleware
|
|
362
579
|
|
|
363
|
-
Middleware runs before routing
|
|
580
|
+
Middleware runs **before routing** on the module-level `pipeline` singleton. Each middleware gets `(ctx, next)` and returns a `Response`. `ctx.context` is threaded into every loader/action.
|
|
364
581
|
|
|
365
582
|
```ts
|
|
366
|
-
import { pipeline, requestLogger, cors, authGuard } from "@bractjs/bractjs";
|
|
583
|
+
import { pipeline, requestLogger, cors, authGuard, csp } from "@bractjs/bractjs";
|
|
584
|
+
import type { MiddlewareFn } from "@bractjs/bractjs";
|
|
367
585
|
|
|
368
586
|
pipeline
|
|
369
587
|
.use(requestLogger())
|
|
370
588
|
.use(cors({ origin: "https://myapp.com" }))
|
|
589
|
+
.use(csp())
|
|
371
590
|
.use(authGuard({ session }));
|
|
372
591
|
```
|
|
373
592
|
|
|
374
|
-
|
|
593
|
+
### Built-in middleware
|
|
594
|
+
|
|
595
|
+
| Middleware | What it does |
|
|
375
596
|
|---|---|
|
|
376
|
-
| `requestLogger()` | Logs
|
|
377
|
-
| `cors(options)` | Sets CORS headers, handles `OPTIONS` preflight |
|
|
378
|
-
| `authGuard(options)` | Reads session,
|
|
597
|
+
| `requestLogger()` | Logs `[METHOD] /path → status in Xms`. Never logs the query string or headers (token-leak safe). |
|
|
598
|
+
| `cors(options)` | Sets CORS headers, handles `OPTIONS` preflight (204), always sets `Vary: Origin`, refuses `credentials:true` + `origin:"*"`. |
|
|
599
|
+
| `authGuard(options)` | Reads the session, sets `ctx.context.user`; with `required:true` returns 401 when unauthenticated. |
|
|
600
|
+
| `csp(options?)` | Opt-in nonce-based Content-Security-Policy (see below). |
|
|
379
601
|
|
|
380
|
-
|
|
602
|
+
**`cors(options)`** — `{ origin: string | string[]; methods?: string[]; credentials?: boolean }`:
|
|
603
|
+
```ts
|
|
604
|
+
pipeline.use(cors({ origin: ["https://a.com", "https://b.com"], credentials: true }));
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
**`authGuard(options)`** — `{ session: SessionStorageLike; required?: boolean }`:
|
|
608
|
+
```ts
|
|
609
|
+
pipeline.use(authGuard({ session, required: true })); // 401 if no session.user
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
**`csp(options?)`** — generates a per-request nonce, applies it to the scripts BractJS injects (via `renderToReadableStream`'s `nonce`), and sets the CSP header:
|
|
613
|
+
```ts
|
|
614
|
+
pipeline.use(csp({
|
|
615
|
+
directives: { "img-src": "'self' data: https://cdn.example", "frame-ancestors": "'none'" },
|
|
616
|
+
reportOnly: false, // true → Content-Security-Policy-Report-Only
|
|
617
|
+
}));
|
|
618
|
+
```
|
|
619
|
+
Read the nonce inside a component/middleware with `getCspNonce(context)` (key: `CSP_NONCE_KEY`) to nonce your own inline scripts.
|
|
620
|
+
|
|
621
|
+
### Custom middleware
|
|
381
622
|
|
|
382
623
|
```ts
|
|
383
624
|
import type { MiddlewareFn } from "@bractjs/bractjs";
|
|
384
625
|
|
|
385
626
|
const trace: MiddlewareFn = async (ctx, next) => {
|
|
386
627
|
ctx.context.requestId = crypto.randomUUID();
|
|
387
|
-
|
|
628
|
+
const res = await next();
|
|
629
|
+
res.headers.set("X-Request-Id", ctx.context.requestId as string);
|
|
630
|
+
return res;
|
|
388
631
|
};
|
|
632
|
+
pipeline.use(trace);
|
|
389
633
|
```
|
|
390
634
|
|
|
391
|
-
|
|
635
|
+
You can also construct an isolated `new MiddlewarePipeline()` and `.run(ctx, handler)` it yourself (used internally and in tests).
|
|
392
636
|
|
|
393
637
|
---
|
|
394
638
|
|
|
395
|
-
## Sessions
|
|
639
|
+
## 15. Sessions
|
|
640
|
+
|
|
641
|
+
Signed, tamper-proof cookie sessions (HMAC-SHA256, constant-time verify, secret rotation).
|
|
396
642
|
|
|
397
643
|
```ts
|
|
398
644
|
import { createCookieSession } from "@bractjs/bractjs";
|
|
399
645
|
|
|
400
646
|
const session = createCookieSession({
|
|
401
647
|
name: "__session",
|
|
402
|
-
secrets: [Bun.env.SESSION_SECRET],
|
|
403
|
-
maxAge: 60 * 60 * 24 * 7, // 1 week
|
|
404
|
-
secure: true,
|
|
405
|
-
sameSite: "
|
|
648
|
+
secrets: [Bun.env.SESSION_SECRET!], // first signs; all verify (rotate by prepending)
|
|
649
|
+
maxAge: 60 * 60 * 24 * 7, // seconds; 1 week
|
|
650
|
+
secure: true, // false only for local HTTP dev
|
|
651
|
+
sameSite: "Lax", // "Strict" | "Lax" | "None"
|
|
406
652
|
});
|
|
653
|
+
```
|
|
407
654
|
|
|
655
|
+
Read in a loader, write in an action:
|
|
656
|
+
|
|
657
|
+
```ts
|
|
408
658
|
export async function loader({ request }: LoaderArgs) {
|
|
409
659
|
const s = await session.getSession(request.headers.get("Cookie"));
|
|
410
660
|
return { user: s.get("user") };
|
|
@@ -412,406 +662,464 @@ export async function loader({ request }: LoaderArgs) {
|
|
|
412
662
|
|
|
413
663
|
export async function action({ request }: ActionArgs) {
|
|
414
664
|
const s = await session.getSession(request.headers.get("Cookie"));
|
|
415
|
-
s.set("user", { id: 1, name: "Alice" });
|
|
665
|
+
s.set("user", { id: 1, name: "Alice" }); // also: s.get, s.has, s.delete
|
|
416
666
|
return redirect("/dashboard", {
|
|
417
|
-
headers: { "Set-Cookie": await session.commitSession(s) },
|
|
667
|
+
headers: { "Set-Cookie": await session.commitSession(s) }, // opt: { maxAge }
|
|
418
668
|
});
|
|
419
669
|
}
|
|
420
670
|
```
|
|
421
671
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
672
|
+
- Each secret must be ≥16 chars; `secrets` must be non-empty (throws otherwise).
|
|
673
|
+
- Tampered cookies are silently rejected → empty session.
|
|
674
|
+
- Generate a secret: `openssl rand -base64 32`.
|
|
425
675
|
|
|
426
676
|
---
|
|
427
677
|
|
|
428
|
-
##
|
|
678
|
+
## 16. Lifecycle hooks: `defineLifecycle`
|
|
429
679
|
|
|
430
|
-
|
|
431
|
-
|---|---|
|
|
432
|
-
| `*.server.ts` / `*.server.tsx` | Import blocked in client bundles at build time — hard error |
|
|
433
|
-
| Keys in `clientEnv` | Replaced with string literals in client bundle |
|
|
434
|
-
| All other `process.env.*` | Become `"undefined"` in client bundles |
|
|
435
|
-
|
|
436
|
-
```ts
|
|
437
|
-
// db.server.ts — never reaches the browser
|
|
438
|
-
export const db = new Database(Bun.env.DATABASE_URL);
|
|
439
|
-
```
|
|
440
|
-
|
|
441
|
-
---
|
|
442
|
-
|
|
443
|
-
## Server Lifecycle Hooks
|
|
444
|
-
|
|
445
|
-
Use `defineLifecycle()` in `app/lifecycle.ts` to run code when the server starts, shuts down, or encounters an error. The shutdown hook runs on **any** exit signal (`SIGTERM`, `SIGINT`, `SIGUSR2`, `beforeExit`, and uncaught exceptions), so database connections are always closed cleanly.
|
|
680
|
+
Run code on server start, shutdown, and unexpected errors. Shutdown fires on **any** exit signal (`SIGTERM`, `SIGINT`, `SIGUSR2`, `beforeExit`, uncaught exceptions).
|
|
446
681
|
|
|
447
682
|
```ts
|
|
448
683
|
// app/lifecycle.ts
|
|
449
684
|
import { defineLifecycle } from "@bractjs/bractjs";
|
|
450
685
|
import { db } from "./db.server.ts";
|
|
451
|
-
import * as Sentry from "@sentry/bun";
|
|
452
686
|
|
|
453
687
|
export default defineLifecycle({
|
|
454
|
-
async onStart()
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
async onShutdown() {
|
|
459
|
-
await db.disconnect();
|
|
460
|
-
console.log("Database disconnected");
|
|
461
|
-
},
|
|
462
|
-
async onError(err, request) {
|
|
463
|
-
// request is undefined for process-level uncaught exceptions
|
|
464
|
-
Sentry.captureException(err, {
|
|
465
|
-
extra: { url: request?.url },
|
|
466
|
-
});
|
|
688
|
+
async onStart() { await db.connect(); },
|
|
689
|
+
async onShutdown(){ await db.disconnect(); },
|
|
690
|
+
onError(err, request) {
|
|
691
|
+
Sentry.captureException(err, { extra: { url: request?.url } });
|
|
467
692
|
},
|
|
468
693
|
});
|
|
469
694
|
```
|
|
470
695
|
|
|
471
|
-
|
|
696
|
+
| Hook | When |
|
|
697
|
+
|------|------|
|
|
698
|
+
| `onStart` | Once, after the server starts listening. |
|
|
699
|
+
| `onShutdown` | Before exit — any signal, programmatic `stop()`, or uncaught exception. |
|
|
700
|
+
| `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. |
|
|
472
701
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
import
|
|
702
|
+
- **Dev** picks up `app/lifecycle.ts` automatically.
|
|
703
|
+
- **Production**: spread into `createServer()`:
|
|
704
|
+
```ts
|
|
705
|
+
import { createServer } from "@bractjs/bractjs";
|
|
706
|
+
import lifecycle from "./app/lifecycle.ts";
|
|
707
|
+
createServer({ port: 3000, ...lifecycle });
|
|
708
|
+
```
|
|
477
709
|
|
|
478
|
-
createServer(
|
|
479
|
-
```
|
|
710
|
+
`createServer()` returns `{ stop }`. `stop()` runs `onShutdown` and closes the listener but does **not** call `process.exit()` (good for tests/supervisors). Signals do exit.
|
|
480
711
|
|
|
481
|
-
|
|
482
|
-
|------|-------------|
|
|
483
|
-
| `onStart` | Once, after the server begins accepting requests |
|
|
484
|
-
| `onShutdown` | Before process exit — any signal, programmatic `stop()`, or uncaught exception |
|
|
485
|
-
| `onError` | Every unexpected error: loader failures, action throws, uncaught exceptions. Redirects and `HttpError` throws are intentional control flow and are **not** reported. |
|
|
712
|
+
---
|
|
486
713
|
|
|
487
|
-
|
|
714
|
+
## 17. Environment variables
|
|
488
715
|
|
|
489
|
-
|
|
716
|
+
| Convention | Behavior |
|
|
717
|
+
|---|---|
|
|
718
|
+
| `*.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. |
|
|
719
|
+
| Keys listed in `clientEnv` | Replaced with string literals in the client bundle. |
|
|
720
|
+
| Any other `process.env.*` | Becomes the literal `"undefined"` in the client bundle. |
|
|
490
721
|
|
|
491
722
|
```ts
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
723
|
+
// db.server.ts — never reaches the browser
|
|
724
|
+
import { Database } from "bun:sqlite";
|
|
725
|
+
export const db = new Database(Bun.env.DATABASE_URL!);
|
|
495
726
|
```
|
|
496
727
|
|
|
497
|
-
|
|
728
|
+
```ts
|
|
729
|
+
// app/routes/posts.tsx — import the server module inside the loader
|
|
730
|
+
import { db } from "../db.server.ts"; // stubbed in the client bundle
|
|
498
731
|
|
|
499
|
-
|
|
732
|
+
export async function loader() {
|
|
733
|
+
return { posts: db.query("SELECT * FROM posts").all() };
|
|
734
|
+
}
|
|
735
|
+
```
|
|
500
736
|
|
|
501
|
-
|
|
737
|
+
> 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.
|
|
502
738
|
|
|
503
|
-
|
|
739
|
+
```ts
|
|
740
|
+
// bractjs.config.ts
|
|
741
|
+
export default { clientEnv: ["PUBLIC_API_URL"] };
|
|
742
|
+
```
|
|
504
743
|
|
|
505
|
-
|
|
744
|
+
```ts
|
|
745
|
+
// in a client component — only allow-listed keys survive
|
|
746
|
+
fetch(`${process.env.PUBLIC_API_URL}/items`);
|
|
747
|
+
```
|
|
506
748
|
|
|
507
|
-
|
|
508
|
-
|-------|------|---------|-------------|
|
|
509
|
-
| `port` | `number` | `3000` | TCP port |
|
|
510
|
-
| `appDir` | `string` | `"./app"` | Directory containing `routes/` and `root.tsx` |
|
|
511
|
-
| `publicDir` | `string` | `"./public"` | Static assets (served with no-cache) |
|
|
512
|
-
| `buildDir` | `string` | `"./build"` | Output for `bractjs build` |
|
|
513
|
-
| `imageCacheDir` | `string` | `".bract-image-cache"` | Disk cache for optimized images |
|
|
514
|
-
| `sourcemap` | `string` | `"external"` | `"none"` \| `"inline"` \| `"external"` |
|
|
515
|
-
| `minify` | `boolean` | `true` | Minify client bundles |
|
|
516
|
-
| `clientEnv` | `string[]` | `[]` | `process.env` keys exposed to the client |
|
|
517
|
-
| `onStart` | `() => void \| Promise<void>` | — | Called once after the server starts listening |
|
|
518
|
-
| `onShutdown` | `() => void \| Promise<void>` | — | Called before process exit on any signal |
|
|
519
|
-
| `onError` | `(err, request?) => void \| Promise<void>` | — | Called on every unexpected error; `request` is `undefined` for process-level exceptions |
|
|
749
|
+
On the server, read env via `Bun.env.*` directly.
|
|
520
750
|
|
|
521
751
|
---
|
|
522
752
|
|
|
523
|
-
##
|
|
753
|
+
## 18. Typed routes
|
|
524
754
|
|
|
525
|
-
|
|
526
|
-
|---------|-------------|
|
|
527
|
-
| `bractjs new <name>` | Scaffold a new app into `<name>/` |
|
|
528
|
-
| `bractjs dev` | Start dev server with HMR on port 3000 |
|
|
529
|
-
| `bractjs build` | Dual server + client build with content-hashed output |
|
|
530
|
-
| `bractjs start` | Serve the production build |
|
|
531
|
-
| `bractjs codegen [app] [out]` | Generate typed route types into `app/route-types.gen.ts` |
|
|
532
|
-
| `bractjs codegen:registry [app]` | Generate `app/_generated/{routes,actions}.ts` (single-binary prep) |
|
|
533
|
-
| `bractjs codegen:manifest [app] [build]` | Snapshot `route-manifest.json` into `app/_generated/manifest.ts` |
|
|
534
|
-
| `bractjs compile [outfile] [entry]` | Full single-binary pipeline (codegen → build → compile) |
|
|
755
|
+
Generate type-safe routing from your route files — one command wires `<Link>`, `useNavigate`, `useParams`, and `useSearchParams` to your actual routes.
|
|
535
756
|
|
|
536
|
-
|
|
757
|
+
```sh
|
|
758
|
+
bractjs codegen # ./app → ./app/route-types.gen.ts
|
|
759
|
+
bractjs codegen ./app ./app/types.ts # explicit paths
|
|
760
|
+
```
|
|
537
761
|
|
|
538
|
-
|
|
762
|
+
Runs automatically during `bractjs build`. 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**:
|
|
539
763
|
|
|
540
|
-
|
|
764
|
+
```tsx
|
|
765
|
+
<Link to="/blog/:id" params={{ id }} /> // ✅ "/blog/:id" autocompletes; params typed
|
|
766
|
+
<Link to="/blgo/:id" params={{ id }} /> // ❌ typo'd route — compile error
|
|
767
|
+
<Link to="/blog/:id" params={{ x: id }} /> // ❌ wrong param key — compile error
|
|
541
768
|
|
|
542
|
-
|
|
769
|
+
const navigate = useNavigate();
|
|
770
|
+
navigate("/blog/:id", { params: { id } }); // ✅ same typing as <Link>
|
|
543
771
|
|
|
544
|
-
|
|
772
|
+
const { id } = useParams<"/blog/:id">(); // id: string
|
|
773
|
+
```
|
|
545
774
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
775
|
+
Building the URL yourself (`<Link to={`/blog/${id}`}>`) still type-checks, so adopting codegen never breaks existing links.
|
|
776
|
+
|
|
777
|
+
The generated file also exports types/helpers for typed loaders and explicit URL building:
|
|
778
|
+
|
|
779
|
+
```ts
|
|
780
|
+
import type { TypedLoaderArgs } from "../route-types.gen.ts";
|
|
781
|
+
import { routes } from "../route-types.gen.ts";
|
|
782
|
+
|
|
783
|
+
export async function loader({ params }: TypedLoaderArgs<"/blog/:id">) {
|
|
784
|
+
return db.post.findById(params.id); // params.id: string
|
|
785
|
+
}
|
|
786
|
+
routes["/blog/:id"]({ id: "123" }); // → "/blog/123" (typo'd routes won't compile)
|
|
553
787
|
```
|
|
554
788
|
|
|
555
|
-
|
|
789
|
+
**Type a route's search params or context** by augmenting the package interfaces — `SearchParams<T>` / `Context<T>` and `useSearchParams<T>()` pick it up:
|
|
556
790
|
|
|
557
|
-
```
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
--asset build/client/ \ # (embeds JS/CSS into the binary)
|
|
563
|
-
--outfile ./myapp
|
|
791
|
+
```ts
|
|
792
|
+
declare module "@bractjs/bractjs" {
|
|
793
|
+
interface RouteSearchParamsMap { "/blog": { page: string; sort: string } }
|
|
794
|
+
interface RouteContextMap { "/admin": { user: { id: string; role: "admin" } } }
|
|
795
|
+
}
|
|
564
796
|
```
|
|
565
797
|
|
|
566
|
-
|
|
798
|
+
You can also call `writeRouteTypes(appDir, outPath?)` / `generateRouteTypes(appDir)` programmatically.
|
|
567
799
|
|
|
568
|
-
|
|
800
|
+
> **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.
|
|
569
801
|
|
|
570
|
-
|
|
802
|
+
---
|
|
803
|
+
|
|
804
|
+
## 19. Internationalization utilities
|
|
805
|
+
|
|
806
|
+
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).
|
|
571
807
|
|
|
572
808
|
```ts
|
|
573
|
-
import {
|
|
574
|
-
import {
|
|
575
|
-
import { actionModules } from "./_generated/actions.ts";
|
|
576
|
-
import { manifest } from "./_generated/manifest.ts";
|
|
809
|
+
import { wrapRoutesWithLocale, stripLocale, localizedDataPath } from "@bractjs/bractjs";
|
|
810
|
+
import type { I18nConfig } from "@bractjs/bractjs";
|
|
577
811
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
812
|
+
const i18n: I18nConfig = { locales: ["en", "fr"], defaultLocale: "en" };
|
|
813
|
+
|
|
814
|
+
// Add /:locale-prefixed variants alongside the originals.
|
|
815
|
+
const localized = wrapRoutesWithLocale(routeFiles, i18n);
|
|
816
|
+
|
|
817
|
+
// Split a locale off a pathname.
|
|
818
|
+
const { locale, strippedPathname } = stripLocale("/fr/about", i18n.locales);
|
|
819
|
+
// → { locale: "fr", strippedPathname: "/about" }
|
|
820
|
+
|
|
821
|
+
// Build a locale-aware data path.
|
|
822
|
+
localizedDataPath("/about", "fr"); // → "/fr/about"
|
|
587
823
|
```
|
|
588
824
|
|
|
589
|
-
|
|
825
|
+
On the client, read the active locale and build localized links:
|
|
590
826
|
|
|
591
|
-
|
|
827
|
+
```tsx
|
|
828
|
+
const locale = useLocale("en");
|
|
829
|
+
const to = useLocalizedLink("en");
|
|
830
|
+
<Link to={to("/about")} />; // → /en/about
|
|
831
|
+
```
|
|
592
832
|
|
|
593
|
-
|
|
833
|
+
---
|
|
594
834
|
|
|
595
|
-
|
|
596
|
-
|---|---|---|
|
|
597
|
-
| Server | `useClientStubPlugin` | Server binary crashes when React tries to invoke browser-only hooks/APIs from `"use client"` modules |
|
|
598
|
-
| Client | `createUseServerProxyPlugin(appDir)` | Server-action bodies (DB queries, secrets) ship inside the browser JS |
|
|
599
|
-
| Client | `serverOnlyPlugin` | Imports of `*.server.ts` leak into the client bundle |
|
|
600
|
-
| Client | `clientEnvPlugin(allowedKeys, env)` | Server env vars leak into the browser bundle |
|
|
601
|
-
| Client | `cssModulesPlugin` | `*.module.css` imports don't resolve |
|
|
835
|
+
## 20. Image optimization
|
|
602
836
|
|
|
603
|
-
|
|
604
|
-
import {
|
|
605
|
-
useClientStubPlugin,
|
|
606
|
-
createUseServerProxyPlugin,
|
|
607
|
-
serverOnlyPlugin,
|
|
608
|
-
clientEnvPlugin,
|
|
609
|
-
cssModulesPlugin,
|
|
610
|
-
} from "@bractjs/bractjs";
|
|
837
|
+
`<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.
|
|
611
838
|
|
|
612
|
-
|
|
613
|
-
|
|
839
|
+
```tsx
|
|
840
|
+
import { Image } from "@bractjs/bractjs";
|
|
614
841
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
842
|
+
<Image src="/public/hero.jpg" alt="Hero" width={1200} height={600} />
|
|
843
|
+
|
|
844
|
+
{/* above-the-fold: eager + fetchpriority=high */}
|
|
845
|
+
<Image src="/public/hero.jpg" alt="Hero" width={1200} priority />
|
|
846
|
+
|
|
847
|
+
<Image
|
|
848
|
+
src="/public/photo.jpg" alt="Photo" width={800}
|
|
849
|
+
format="avif" quality={70} fit="contain"
|
|
850
|
+
sizes="(max-width: 640px) 100vw, 50vw"
|
|
851
|
+
/>
|
|
622
852
|
```
|
|
623
853
|
|
|
624
|
-
|
|
854
|
+
| Prop | Type | Default | Notes |
|
|
855
|
+
|------|------|---------|-------|
|
|
856
|
+
| `src` | `string` | — | Path under `/public/` (required) |
|
|
857
|
+
| `alt` | `string` | — | Required |
|
|
858
|
+
| `width` / `height` | `number` | — | Intrinsic size |
|
|
859
|
+
| `quality` | `number` | `80` | 1–100 |
|
|
860
|
+
| `format` | `"webp" \| "avif" \| "jpeg" \| "png"` | `"webp"` | `ImageFormat` |
|
|
861
|
+
| `fit` | `"cover" \| "contain" \| "fill"` | `"cover"` | `ImageFit` |
|
|
862
|
+
| `priority` | `boolean` | `false` | Disable lazy load, set `fetchpriority=high` |
|
|
863
|
+
| `sizes` | `string` | `"100vw"` | HTML `sizes` |
|
|
864
|
+
|
|
865
|
+
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).
|
|
866
|
+
|
|
867
|
+
`ImageProps`, `ImageFormat`, and `ImageFit` are exported types.
|
|
625
868
|
|
|
626
869
|
---
|
|
627
870
|
|
|
628
|
-
##
|
|
871
|
+
## 21. Build & run
|
|
629
872
|
|
|
630
|
-
|
|
873
|
+
### CLI
|
|
631
874
|
|
|
632
|
-
|
|
875
|
+
| Command | Description |
|
|
876
|
+
|---------|-------------|
|
|
877
|
+
| `bractjs new <name>` | Scaffold a new app into `<name>/`. |
|
|
878
|
+
| `bractjs dev` | Dev server with HMR (port 3000, HMR ws 3001). |
|
|
879
|
+
| `bractjs build` | Dual server + client build with content-hashed output. |
|
|
880
|
+
| `bractjs start` | Serve the production build. |
|
|
881
|
+
| `bractjs codegen [app] [out]` | Generate `route-types.gen.ts`. |
|
|
882
|
+
| `bractjs codegen:registry [app]` | Generate `app/_generated/{routes,actions}.ts`. |
|
|
883
|
+
| `bractjs codegen:manifest [app] [build]` | Snapshot manifest → `app/_generated/manifest.ts`. |
|
|
884
|
+
| `bractjs compile [outfile] [entry]` | Full single-binary pipeline. |
|
|
633
885
|
|
|
886
|
+
The CLI is a thin wrapper — every command delegates to a public function, so you can script the same thing.
|
|
887
|
+
|
|
888
|
+
### Programmatic API
|
|
889
|
+
|
|
890
|
+
**`createDevServer(options?)`** — dev server with HMR.
|
|
634
891
|
```ts
|
|
635
892
|
import { createDevServer } from "@bractjs/bractjs";
|
|
636
893
|
|
|
637
894
|
const dev = await createDevServer({
|
|
638
|
-
port: 3000,
|
|
639
|
-
hmrPort: 3001,
|
|
895
|
+
port: 3000, // default: config.port ?? 3000
|
|
896
|
+
hmrPort: 3001, // HMR websocket
|
|
640
897
|
config: { appDir: "./app", clientEnv: ["PUBLIC_API_URL"] },
|
|
641
|
-
skipUserConfig: false, //
|
|
898
|
+
skipUserConfig: false, // true → don't read bractjs.config.ts
|
|
642
899
|
});
|
|
643
|
-
|
|
644
|
-
// Later — stops the HTTP server and HMR WebSocket server
|
|
645
900
|
dev.stop();
|
|
646
901
|
```
|
|
647
902
|
|
|
648
|
-
|
|
649
|
-
|
|
903
|
+
**`runBuild(config?)`** — production build (accepts only build-relevant fields).
|
|
650
904
|
```ts
|
|
651
905
|
import { runBuild } from "@bractjs/bractjs";
|
|
652
906
|
|
|
653
907
|
await runBuild({
|
|
654
908
|
appDir: "./app",
|
|
655
|
-
buildDir: "./
|
|
909
|
+
buildDir: "./build",
|
|
656
910
|
minify: true,
|
|
657
|
-
sourcemap: "external",
|
|
911
|
+
sourcemap: "external", // "none" | "linked" | "inline" | "external"
|
|
658
912
|
clientEnv: ["PUBLIC_API_URL"],
|
|
913
|
+
plugins: [], // extra Bun plugins
|
|
659
914
|
});
|
|
660
915
|
```
|
|
661
916
|
|
|
662
|
-
|
|
917
|
+
**`loadUserConfig()`** — read `bractjs.config.ts` (or `.js`) from cwd, validated.
|
|
918
|
+
```ts
|
|
919
|
+
import { loadUserConfig } from "@bractjs/bractjs";
|
|
920
|
+
const cfg = await loadUserConfig(); // {} if no file; throws on a malformed shape
|
|
921
|
+
```
|
|
663
922
|
|
|
664
|
-
|
|
923
|
+
**`createServer(config?)`** — production HTTP server. Returns `{ stop }`.
|
|
924
|
+
```ts
|
|
925
|
+
import { createServer } from "@bractjs/bractjs";
|
|
926
|
+
import lifecycle from "./app/lifecycle.ts";
|
|
927
|
+
const srv = createServer({ port: 3000, buildDir: "./build", ...lifecycle });
|
|
928
|
+
```
|
|
665
929
|
|
|
930
|
+
**`buildFetchHandler(config)`** — the adapter-agnostic `(Request) => Promise<Response>` core, if you want to mount BractJS inside another server.
|
|
666
931
|
```ts
|
|
667
|
-
import {
|
|
932
|
+
import { buildFetchHandler } from "@bractjs/bractjs";
|
|
933
|
+
const handler = buildFetchHandler({ appDir: "./app", manifest });
|
|
934
|
+
Bun.serve({ port: 3000, fetch: handler });
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
`renderRoute(options)` (low-level SSR render) and the `RenderOptions`/`ServerManifest`/`BractJSConfig` types are also exported for advanced embedding.
|
|
938
|
+
|
|
939
|
+
---
|
|
940
|
+
|
|
941
|
+
## 22. Single-binary deployment (`bun build --compile`)
|
|
668
942
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
943
|
+
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`**.
|
|
944
|
+
|
|
945
|
+
### One-shot
|
|
946
|
+
|
|
947
|
+
```sh
|
|
948
|
+
bractjs compile ./myapp
|
|
949
|
+
# = codegen:registry → build → codegen:manifest → bun build --compile app/server.ts
|
|
672
950
|
```
|
|
673
951
|
|
|
674
|
-
### `
|
|
952
|
+
### The `app/server.ts` entry
|
|
675
953
|
|
|
676
|
-
|
|
954
|
+
The scaffold includes:
|
|
677
955
|
|
|
678
956
|
```ts
|
|
679
957
|
import { createServer } from "@bractjs/bractjs";
|
|
680
|
-
import
|
|
958
|
+
import { routeFiles, moduleRegistry } from "./_generated/routes.ts";
|
|
959
|
+
import { actionModules } from "./_generated/actions.ts";
|
|
960
|
+
import { manifest } from "./_generated/manifest.ts";
|
|
681
961
|
|
|
682
|
-
createServer({
|
|
962
|
+
createServer({
|
|
963
|
+
port: Number(process.env.PORT ?? 3000),
|
|
964
|
+
appDir: "./app",
|
|
965
|
+
publicDir: "./public",
|
|
966
|
+
manifest, // no manifest read from disk
|
|
967
|
+
routeFiles, // no Bun.Glob route scan
|
|
968
|
+
moduleRegistry, // no dynamic import(absPath)
|
|
969
|
+
actionModules, // no scan/import for "use server" files
|
|
970
|
+
});
|
|
683
971
|
```
|
|
684
972
|
|
|
685
|
-
|
|
973
|
+
When all four are present, the server uses the pre-imported modules for routing, layouts, actions, and assets.
|
|
686
974
|
|
|
687
|
-
|
|
975
|
+
### Manual pipeline
|
|
688
976
|
|
|
689
|
-
```
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
│ ├── route-types.gen.ts # generated by bractjs codegen
|
|
696
|
-
│ ├── _generated/ # generated by bractjs codegen:registry / codegen:manifest
|
|
697
|
-
│ │ ├── routes.ts # static imports for routes + layouts + root
|
|
698
|
-
│ │ ├── actions.ts # static imports for "use server" modules
|
|
699
|
-
│ │ └── manifest.ts # inline ServerManifest constant
|
|
700
|
-
│ ├── actions.ts # "use server" actions
|
|
701
|
-
│ └── routes/
|
|
702
|
-
│ ├── _index.tsx # → /
|
|
703
|
-
│ ├── about.tsx # → /about
|
|
704
|
-
│ ├── blog/
|
|
705
|
-
│ │ ├── layout.tsx # layout for /blog/*
|
|
706
|
-
│ │ ├── _index.tsx # → /blog
|
|
707
|
-
│ │ └── [id].tsx # → /blog/:id
|
|
708
|
-
│ └── docs/
|
|
709
|
-
│ └── [...slug].tsx # → /docs/*
|
|
710
|
-
├── public/
|
|
711
|
-
│ └── favicon.ico
|
|
712
|
-
└── build/ # generated — do not edit
|
|
713
|
-
├── server/
|
|
714
|
-
├── client/
|
|
715
|
-
└── route-manifest.json
|
|
977
|
+
```sh
|
|
978
|
+
bractjs codegen:registry # A — scan routes/actions → static imports
|
|
979
|
+
bractjs build # B — client + server bundles + manifest
|
|
980
|
+
bractjs codegen:manifest # C — embed manifest as a TS constant
|
|
981
|
+
bun build --compile app/server.ts \ # D — single binary
|
|
982
|
+
--asset build/client/ --outfile ./myapp
|
|
716
983
|
```
|
|
717
984
|
|
|
718
|
-
|
|
985
|
+
`--asset build/client/` embeds JS/CSS into the binary (true single file); omit it to ship `myapp` + `build/client/` side by side.
|
|
986
|
+
|
|
987
|
+
The codegen functions are exported: `writeModuleRegistries(appDir)`, `writeManifestModule(appDir, buildDir)`, and the lower-level `generateRouteRegistry` / `generateActionRegistry` / `generateManifestModule`.
|
|
988
|
+
|
|
989
|
+
> **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: `src/__tests__/compile-safety.test.ts` (fast static scan) and `src/__tests__/compile-smoke.test.ts` (compiles and boots a real binary).
|
|
719
990
|
|
|
720
991
|
---
|
|
721
992
|
|
|
722
|
-
##
|
|
993
|
+
## 23. Custom adapters
|
|
723
994
|
|
|
724
|
-
|
|
725
|
-
Request
|
|
726
|
-
└─ Middleware pipeline
|
|
727
|
-
└─ /_action → Server Action registry → fn(...args)
|
|
728
|
-
└─ /_image → ImageMagick transform → LRU cache → Response
|
|
729
|
-
└─ Route trie (static > param > catch-all)
|
|
730
|
-
└─ Layout chain (root → layout → route)
|
|
731
|
-
└─ Parallel loaders (Promise.all)
|
|
732
|
-
└─ renderToReadableStream → streaming Response
|
|
733
|
-
```
|
|
995
|
+
The server core is adapter-agnostic. The default is `BunAdapter` (wraps `Bun.serve`); supply your own via `createServer({ adapter })`.
|
|
734
996
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
└─ ClientRouter (RouterContext + NavigationContext)
|
|
739
|
-
└─ Outlet → React.lazy route chunk
|
|
740
|
-
└─ useLoaderData / useParams / useNavigation / …
|
|
741
|
-
```
|
|
997
|
+
```ts
|
|
998
|
+
import type { BractAdapter } from "@bractjs/bractjs";
|
|
999
|
+
import { BunAdapter } from "@bractjs/bractjs";
|
|
742
1000
|
|
|
743
|
-
|
|
744
|
-
```
|
|
745
|
-
1. codegen → app/route-types.gen.ts
|
|
746
|
-
2. server bundle → Bun.build (target: bun) + useClientStubPlugin
|
|
747
|
-
3. client bundle → Bun.build (target: browser, splitting) + createUseServerProxyPlugin(appDir)
|
|
748
|
-
4. content-hash → rename outputs, write route-manifest.json
|
|
1001
|
+
// BractAdapter: { fetch(req): Promise<Response>; listen?(port): void }
|
|
749
1002
|
```
|
|
750
1003
|
|
|
751
|
-
|
|
752
|
-
```
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
--compile app/server.ts [--asset build/client/]
|
|
1004
|
+
**Cloudflare Workers:**
|
|
1005
|
+
```ts
|
|
1006
|
+
import { buildFetchHandler, makeCloudflareHandler } from "@bractjs/bractjs";
|
|
1007
|
+
const handler = buildFetchHandler({ appDir: "./app", manifest });
|
|
1008
|
+
export default makeCloudflareHandler(handler);
|
|
1009
|
+
// or createCloudflareAdapter(handler) for the BractAdapter-compatible form
|
|
758
1010
|
```
|
|
759
1011
|
|
|
760
1012
|
---
|
|
761
1013
|
|
|
762
|
-
##
|
|
1014
|
+
## 24. Build plugins
|
|
763
1015
|
|
|
1016
|
+
If you write your own `Bun.build()` (instead of `bractjs build`), you **must** apply these or face crashes / secret leaks. All are exported.
|
|
1017
|
+
|
|
1018
|
+
| Bundle | Plugin | Without it |
|
|
1019
|
+
|---|---|---|
|
|
1020
|
+
| Server | `useClientStubPlugin` | Server crashes calling browser-only hooks from `"use client"` modules. |
|
|
1021
|
+
| Client | `createUseServerProxyPlugin(appDir)` | Server-action bodies (DB code, secrets) ship in the browser JS. |
|
|
1022
|
+
| Client | `serverModuleStubPlugin` | `*.server.ts` source (DB drivers, secrets) leaks into the client bundle. |
|
|
1023
|
+
| Client | `clientEnvPlugin(allowedKeys, env)` | Server env vars leak into the browser bundle. |
|
|
1024
|
+
| Client | `cssModulesPlugin` | `*.module.css` imports don't resolve. |
|
|
1025
|
+
|
|
1026
|
+
```ts
|
|
1027
|
+
import {
|
|
1028
|
+
useClientStubPlugin, createUseServerProxyPlugin,
|
|
1029
|
+
serverModuleStubPlugin, clientEnvPlugin, cssModulesPlugin,
|
|
1030
|
+
} from "@bractjs/bractjs";
|
|
1031
|
+
|
|
1032
|
+
// Server bundle (target: "bun"):
|
|
1033
|
+
plugins: [useClientStubPlugin];
|
|
1034
|
+
|
|
1035
|
+
// Client bundle (target: "browser"):
|
|
1036
|
+
plugins: [
|
|
1037
|
+
serverModuleStubPlugin,
|
|
1038
|
+
createUseServerProxyPlugin("./app"), // same appDir as createServer!
|
|
1039
|
+
clientEnvPlugin(["PUBLIC_API_URL"], Bun.env as Record<string, string>),
|
|
1040
|
+
cssModulesPlugin,
|
|
1041
|
+
];
|
|
764
1042
|
```
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
│ ├── server/ # SSR, routing, loaders, actions, sessions, action-registry
|
|
768
|
-
│ ├── client/ # hydrateRoot, contexts, hooks, Link/Form/Image components
|
|
769
|
-
│ ├── build/ # Bun.build orchestration, manifest, hashing, directives
|
|
770
|
-
│ ├── codegen/ # route-types.gen.ts + module-registry codegen (_generated/*)
|
|
771
|
-
│ ├── image/ # /_image handler, ImageMagick optimizer, LRU cache
|
|
772
|
-
│ ├── dev/ # watcher, HMR server + client, error overlay
|
|
773
|
-
│ ├── shared/ # types, errors, deferred, context
|
|
774
|
-
│ └── middleware/ # requestLogger, cors, authGuard
|
|
775
|
-
├── bin/cli.ts
|
|
776
|
-
├── types/ # TypeScript declaration files
|
|
777
|
-
└── templates/
|
|
778
|
-
└── new-app/ # scaffold template
|
|
779
|
-
```
|
|
1043
|
+
|
|
1044
|
+
> 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).
|
|
780
1045
|
|
|
781
1046
|
---
|
|
782
1047
|
|
|
783
|
-
##
|
|
1048
|
+
## 25. Configuration reference
|
|
784
1049
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
1050
|
+
All fields optional. Put them in `bractjs.config.ts` (default export) or pass to `createServer` / `createDevServer` / `runBuild`.
|
|
1051
|
+
|
|
1052
|
+
| Field | Type | Default | Description |
|
|
1053
|
+
|-------|------|---------|-------------|
|
|
1054
|
+
| `port` | `number` | `3000` | TCP port |
|
|
1055
|
+
| `appDir` | `string` | `"./app"` | Contains `routes/` and `root.tsx` |
|
|
1056
|
+
| `publicDir` | `string` | `"./public"` | Static assets (served no-cache) |
|
|
1057
|
+
| `buildDir` | `string` | `"./build"` | Build output |
|
|
1058
|
+
| `imageCacheDir` | `string` | `".bract-image-cache"` | Optimized-image disk cache |
|
|
1059
|
+
| `sourcemap` | `string` | `"external"` | `"none" \| "linked" \| "inline" \| "external"` |
|
|
1060
|
+
| `minify` | `boolean` | `true` | Minify client bundles |
|
|
1061
|
+
| `clientEnv` | `string[]` | `[]` | `process.env` keys exposed to the client |
|
|
1062
|
+
| `plugins` | `BunPlugin[]` | `[]` | Extra client-build plugins |
|
|
1063
|
+
| `adapter` | `BractAdapter` | `BunAdapter` | Custom server adapter |
|
|
1064
|
+
| `i18n` | `I18nConfig` | — | Locale config consumed by the i18n utilities |
|
|
1065
|
+
| `onStart` / `onShutdown` / `onError` | hooks | — | Lifecycle (§16) |
|
|
1066
|
+
|
|
1067
|
+
`loadUserConfig()` validates these shapes and throws a clear error on an obvious mistake (e.g. a string `port`).
|
|
791
1068
|
|
|
792
1069
|
---
|
|
793
1070
|
|
|
794
|
-
##
|
|
1071
|
+
## 26. Full export index
|
|
1072
|
+
|
|
1073
|
+
Everything importable from `@bractjs/bractjs` ([src/index.ts](src/index.ts)):
|
|
1074
|
+
|
|
1075
|
+
**Server / runtime:** `createServer`, `buildFetchHandler`, `renderRoute`, `redirect`, `json`, `error`, `defineContext`, `route`, `validate`, `BunAdapter`, `defineLifecycle`
|
|
1076
|
+
|
|
1077
|
+
**Errors:** `BractJSError`, `HttpError`, `isRedirect`, `isHttpError`, `isBractJSError`
|
|
1078
|
+
|
|
1079
|
+
**Streaming:** `defer`, `Deferred`, `isDeferred`, `Await`
|
|
1080
|
+
|
|
1081
|
+
**Context:** `BractJSContext`, `BractJSProvider`, `useBractJSContext`
|
|
1082
|
+
|
|
1083
|
+
**Middleware:** `pipeline`, `MiddlewarePipeline`, `requestLogger`, `cors`, `authGuard`, `csp`, `getCspNonce`, `CSP_NONCE_KEY`
|
|
1084
|
+
|
|
1085
|
+
**Sessions:** `createCookieSession`
|
|
1086
|
+
|
|
1087
|
+
**Components:** `Outlet`, `Link`, `Form`, `Scripts`, `LiveReload`, `Await`, `Image`
|
|
795
1088
|
|
|
796
|
-
**
|
|
1089
|
+
**Hooks:** `useLoaderData`, `useActionData`, `useParams`, `useNavigation`, `useFetcher`, `useSearchParams`, `useBlocker`, `useLocale`, `useLocalizedLink`
|
|
797
1090
|
|
|
798
|
-
|
|
799
|
-
- Streaming SSR (`renderToReadableStream`) with `defer()` and `<Await>`
|
|
800
|
-
- Client hydration, soft navigation, `popstate`, prefetch
|
|
801
|
-
- HMR with module-level swap (no full reload)
|
|
802
|
-
- Cookie sessions with HMAC-SHA256 and secret rotation
|
|
803
|
-
- Middleware pipeline with `requestLogger`, `cors`, `authGuard`
|
|
804
|
-
- Production build with content-hashed assets and code splitting
|
|
805
|
-
- `<Image>` with on-demand ImageMagick optimization and LRU cache
|
|
806
|
-
- Typed routes codegen (`AppRoutes`, `RouteParams<T>`, `TypedLoaderArgs<T>`, `routes` builder)
|
|
807
|
-
- `"use server"` / `"use client"` directive system with `/_action` endpoint
|
|
808
|
-
- **Programmatic API** — `createDevServer`, `runBuild`, `loadUserConfig` importable without the CLI
|
|
809
|
-
- **Native `bun build --compile` support** — module-registry codegen produces single-binary deployables; build plugins exported from the public API; action IDs use relative paths for cross-machine stability
|
|
1091
|
+
**i18n:** `wrapRoutesWithLocale`, `stripLocale`, `localizedDataPath`
|
|
810
1092
|
|
|
811
|
-
|
|
1093
|
+
**Client RPC:** `createClient`
|
|
1094
|
+
|
|
1095
|
+
**Build / programmatic:** `createDevServer`, `runBuild`, `loadUserConfig`
|
|
1096
|
+
|
|
1097
|
+
**Codegen:** `writeModuleRegistries`, `writeManifestModule`, `generateRouteRegistry`, `generateActionRegistry`, `generateManifestModule`
|
|
1098
|
+
|
|
1099
|
+
**Build plugins:** `useClientStubPlugin`, `createUseServerProxyPlugin`, `useServerProxyPlugin`, `serverModuleStubPlugin`, `serverOnlyPlugin`, `clientEnvPlugin`, `cssModulesPlugin`, `transformCssModule`
|
|
1100
|
+
|
|
1101
|
+
**Adapters:** `createCloudflareAdapter`, `makeCloudflareHandler`
|
|
1102
|
+
|
|
1103
|
+
**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`
|
|
812
1104
|
|
|
813
1105
|
---
|
|
814
1106
|
|
|
1107
|
+
## Changelog
|
|
1108
|
+
|
|
1109
|
+
See [CHANGELOG.md](CHANGELOG.md) for release history.
|
|
1110
|
+
|
|
1111
|
+
---
|
|
1112
|
+
|
|
1113
|
+
## Why BractJS
|
|
1114
|
+
|
|
1115
|
+
- **Bun-native** — `Bun.serve`, `Bun.build`, `Bun.file`, `Bun.Glob`, `Bun.watch`. No Node.js.
|
|
1116
|
+
- **Zero framework deps** — only peers are `react` and `react-dom`.
|
|
1117
|
+
- **Streaming SSR** — `renderToReadableStream()` with `defer()` and `<Await>`.
|
|
1118
|
+
- **File-based routing** — drop a file in `app/routes/`.
|
|
1119
|
+
- **Full-stack** — loaders, actions, sessions, server actions, typed API routes, middleware.
|
|
1120
|
+
- **Typed routes** — codegen wires `<Link>`, `useNavigate`, and `useParams` to your routes (autocompleted paths, typed params), plus a type-safe URL builder.
|
|
1121
|
+
- **Single-binary** — `bun build --compile` to one executable.
|
|
1122
|
+
|
|
815
1123
|
## License
|
|
816
1124
|
|
|
817
1125
|
MIT
|