@bractjs/bractjs 0.1.24 → 0.1.26
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 +755 -466
- package/bin/cli.ts +23 -3
- package/package.json +1 -1
- 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/build/bundler.ts +46 -20
- package/src/build/directives.ts +2 -2
- package/src/build/env-plugin.ts +76 -5
- package/src/build/react-dedupe.ts +41 -0
- package/src/client/ClientRouter.tsx +22 -8
- package/src/client/components/Form.tsx +10 -1
- package/src/client/hooks/useFetcher.ts +17 -1
- package/src/client/nav-utils.ts +54 -3
- package/src/client/types.ts +3 -0
- 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 +13 -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 +20 -2
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 codegen](#18-typed-routes-codegen)
|
|
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,123 @@ export default function BlogPost() {
|
|
|
139
331
|
}
|
|
140
332
|
```
|
|
141
333
|
|
|
142
|
-
|
|
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.
|
|
143
336
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
### `<Link>`
|
|
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
|
-
```tsx
|
|
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 a generic for typed params.
|
|
357
|
+
```ts
|
|
358
|
+
const { id } = useParams<{ id: string }>();
|
|
183
359
|
```
|
|
184
360
|
|
|
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
|
-
|
|
361
|
+
### `useNavigation()` → `{ state }`
|
|
362
|
+
`"idle" | "loading" | "submitting"`.
|
|
363
|
+
```ts
|
|
202
364
|
const { state } = useNavigation();
|
|
203
365
|
if (state === "loading") return <Spinner />;
|
|
204
|
-
|
|
205
|
-
const fetcher = useFetcher();
|
|
206
|
-
fetcher.load("/api/suggestions?q=bun");
|
|
207
366
|
```
|
|
208
367
|
|
|
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
|
-
/>
|
|
368
|
+
### `useSearchParams<T>()` → `{ searchParams, getParam, setSearchParams }`
|
|
369
|
+
Read/write URL query params; writing triggers a soft-nav loader re-run.
|
|
370
|
+
```ts
|
|
371
|
+
const { searchParams, getParam, setSearchParams } = useSearchParams<{ q: string }>();
|
|
372
|
+
const q = getParam("q"); // string | null
|
|
373
|
+
setSearchParams({ q: "bun" }); // replace all params
|
|
374
|
+
setSearchParams((prev) => { prev.set("page", "2"); return prev; }); // update
|
|
234
375
|
```
|
|
235
376
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
| `src` | `string` | — | Path under `/public/` |
|
|
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
|
|
377
|
+
### `useFetcher()` → `{ data, state, load, submit }`
|
|
378
|
+
Background fetch without navigating.
|
|
379
|
+
```ts
|
|
380
|
+
const fetcher = useFetcher();
|
|
381
|
+
await fetcher.load("/products?q=bun"); // GET loader data
|
|
382
|
+
await fetcher.submit("/cart", { method: "post", body: { id: "1" } });
|
|
261
383
|
```
|
|
262
384
|
|
|
263
|
-
|
|
264
|
-
|
|
385
|
+
### `useFetcher<T>({ stream: true })` → `{ connect }`
|
|
386
|
+
Consume an async-generator server action as an SSE stream.
|
|
265
387
|
```ts
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
// Per-route typed params
|
|
270
|
-
export type RouteParams<T extends AppRoutes> =
|
|
271
|
-
T extends "/blog/:id" ? { id: string } :
|
|
272
|
-
T extends "/org/:orgId/repo/:repoId" ? { orgId: string; repoId: string } :
|
|
273
|
-
Record<never, never>;
|
|
388
|
+
const { connect } = useFetcher<string>({ stream: true });
|
|
389
|
+
for await (const chunk of connect(actionId)) { /* … */ }
|
|
390
|
+
```
|
|
274
391
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
392
|
+
### `useBlocker(shouldBlock)`
|
|
393
|
+
Prompt before leaving when there are unsaved changes (intercepts back/forward and `<Link>` navigations).
|
|
394
|
+
```ts
|
|
395
|
+
useBlocker(() => formIsDirty);
|
|
396
|
+
```
|
|
278
397
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
398
|
+
### `useLocale(defaultLocale?)` → `string` and `useLocalizedLink(defaultLocale?)` → `(path) => string`
|
|
399
|
+
For i18n prefix routing (§19).
|
|
400
|
+
```ts
|
|
401
|
+
const locale = useLocale("en"); // reads params.locale
|
|
402
|
+
const localized = useLocalizedLink("en");
|
|
403
|
+
<Link to={localized("/about")} /> // → /en/about
|
|
284
404
|
```
|
|
285
405
|
|
|
286
|
-
|
|
406
|
+
---
|
|
287
407
|
|
|
288
|
-
|
|
289
|
-
// Typed loader — params.id is string, not string | undefined
|
|
290
|
-
import type { TypedLoaderArgs, RouteParams } from "../route-types.gen.ts";
|
|
408
|
+
## 10. Client components
|
|
291
409
|
|
|
292
|
-
|
|
293
|
-
|
|
410
|
+
### `<Outlet />`
|
|
411
|
+
Renders the matched child route inside a layout (or the route tree inside `root.tsx`).
|
|
412
|
+
```tsx
|
|
413
|
+
export default function BlogLayout() {
|
|
414
|
+
return <div><nav>Blog</nav><Outlet /></div>;
|
|
294
415
|
}
|
|
416
|
+
```
|
|
295
417
|
|
|
296
|
-
|
|
297
|
-
|
|
418
|
+
### `<Link to prefetch? viewTransition?>`
|
|
419
|
+
Soft-navigates without a full reload.
|
|
420
|
+
```tsx
|
|
421
|
+
<Link to="/blog/42">Read</Link>
|
|
422
|
+
<Link to="/about" prefetch="hover">About</Link> {/* preload chunk + loader on hover */}
|
|
423
|
+
<Link to="/gallery" viewTransition>Gallery</Link> {/* use View Transitions API */}
|
|
424
|
+
```
|
|
425
|
+
Modifier-clicks (ctrl/cmd/shift/alt) fall back to native browser navigation.
|
|
298
426
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
427
|
+
### `<Form method action?>`
|
|
428
|
+
Fetch-based submission that re-runs the current route's loader after the action.
|
|
429
|
+
```tsx
|
|
430
|
+
<Form method="post">
|
|
431
|
+
<input name="title" />
|
|
432
|
+
<button type="submit">Create</button>
|
|
433
|
+
</Form>
|
|
434
|
+
<Form method="post" action="/blog/new">…</Form>
|
|
303
435
|
```
|
|
436
|
+
Submits as `multipart/form-data` with the `X-BractJS-Action` header (CSRF gate). If the action returns a redirect, the form follows it.
|
|
437
|
+
|
|
438
|
+
### `<Scripts />` and `<LiveReload />`
|
|
439
|
+
Markers used inside `root.tsx` (§3). `<Scripts />` is where the client bundle + bootstrap data go; `<LiveReload />` is the dev-only HMR client.
|
|
440
|
+
|
|
441
|
+
### `<Image />`
|
|
442
|
+
Responsive, format-converted images via the built-in `/_image` endpoint — see §20.
|
|
304
443
|
|
|
305
444
|
---
|
|
306
445
|
|
|
307
|
-
## Server Actions & Client Components
|
|
446
|
+
## 11. Server Actions & Client Components
|
|
308
447
|
|
|
309
448
|
### `"use server"` — Server Actions
|
|
310
449
|
|
|
311
|
-
|
|
450
|
+
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
451
|
|
|
313
452
|
```ts
|
|
314
453
|
// app/actions.ts
|
|
@@ -326,7 +465,7 @@ export async function deletePost(id: string) {
|
|
|
326
465
|
```
|
|
327
466
|
|
|
328
467
|
```tsx
|
|
329
|
-
// app/routes/new.tsx — import
|
|
468
|
+
// app/routes/new.tsx — import as normal; the client bundle gets a fetch proxy
|
|
330
469
|
import { createPost } from "../actions.ts";
|
|
331
470
|
|
|
332
471
|
export default function NewPost() {
|
|
@@ -339,11 +478,15 @@ export default function NewPost() {
|
|
|
339
478
|
}
|
|
340
479
|
```
|
|
341
480
|
|
|
342
|
-
|
|
481
|
+
- Accepts a single `FormData` (sent as `multipart/form-data`) **or** a JSON-serializable argument array.
|
|
482
|
+
- Unknown action IDs return 404 — only functions registered at startup are callable.
|
|
483
|
+
- Bodies are size-capped (1 MiB JSON) and prototype-pollution scanned.
|
|
484
|
+
|
|
485
|
+
**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
486
|
|
|
344
487
|
### `"use client"` — Client-Only Components
|
|
345
488
|
|
|
346
|
-
|
|
489
|
+
Mark a component browser-only. During server builds the module is stubbed to `null` to prevent `window`/`document`/`localStorage` crashes during SSR.
|
|
347
490
|
|
|
348
491
|
```tsx
|
|
349
492
|
// app/components/Counter.tsx
|
|
@@ -358,53 +501,148 @@ export function Counter() {
|
|
|
358
501
|
|
|
359
502
|
---
|
|
360
503
|
|
|
361
|
-
##
|
|
504
|
+
## 12. Typed API routes
|
|
362
505
|
|
|
363
|
-
|
|
506
|
+
Define type-safe JSON endpoints under `/api/*` with `route`, and call them with a fully-typed `createClient`.
|
|
507
|
+
|
|
508
|
+
### Define routes with `route(method, path, handler)`
|
|
364
509
|
|
|
365
510
|
```ts
|
|
366
|
-
|
|
511
|
+
// app/api/users.ts
|
|
512
|
+
import { route } from "@bractjs/bractjs";
|
|
513
|
+
import { db } from "../db.server.ts";
|
|
514
|
+
|
|
515
|
+
export const listUsers = route("GET", "/api/users", async () => {
|
|
516
|
+
return db.users.findAll();
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
export const createUser = route("POST", "/api/users", async (input: { name: string }) => {
|
|
520
|
+
return db.users.create(input);
|
|
521
|
+
});
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
- `GET`/`DELETE`: no body parsed. `POST`/`PUT`/`PATCH`: JSON or form body parsed into `input`.
|
|
525
|
+
- Bodies are capped at 1 MiB. Errors return a generic 500 in production (full message in dev).
|
|
526
|
+
- Handlers also receive the raw `Request` as the 2nd arg.
|
|
527
|
+
- `:param` segments match any non-empty value; **read and validate params from `request.url` yourself** (they aren't injected into `input`).
|
|
528
|
+
|
|
529
|
+
### Call them with `createClient<AppApiRoutes>()`
|
|
530
|
+
|
|
531
|
+
```ts
|
|
532
|
+
import { createClient } from "@bractjs/bractjs";
|
|
533
|
+
import type { AppApiRoutes } from "@bractjs/bractjs"; // union of your route defs
|
|
534
|
+
|
|
535
|
+
const client = createClient<AppApiRoutes>(); // optional baseUrl arg
|
|
536
|
+
const users = await client["/api/users"].GET(); // typed output
|
|
537
|
+
await client["/api/users"].POST({ name: "Alice" }); // typed input
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
The proxy builds `METHOD path` from the property chain. Non-2xx responses throw an `Error` with `.status` and `.response` attached.
|
|
541
|
+
|
|
542
|
+
---
|
|
543
|
+
|
|
544
|
+
## 13. Input validation: `validate`
|
|
545
|
+
|
|
546
|
+
Validate `FormData` or a plain object against any **Zod- or Valibot-compatible** schema (anything with `.safeParse()` or `.parse()`).
|
|
547
|
+
|
|
548
|
+
```ts
|
|
549
|
+
import { validate } from "@bractjs/bractjs";
|
|
550
|
+
import { z } from "zod";
|
|
551
|
+
|
|
552
|
+
const Schema = z.object({ title: z.string().min(1), tags: z.array(z.string()) });
|
|
553
|
+
|
|
554
|
+
export async function action({ formData }: ActionArgs) {
|
|
555
|
+
// Throws a 400 Response with { errors: { field: [msgs] } } on failure.
|
|
556
|
+
const data = await validate(Schema, formData);
|
|
557
|
+
await db.post.create(data); // data is fully typed + coerced
|
|
558
|
+
}
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
- Repeated `FormData` keys become arrays automatically.
|
|
562
|
+
- On failure it throws a `Response.json({ errors }, { status: 400 })`. The exported `ValidationError` type and `FieldErrors` shape describe the structure.
|
|
563
|
+
|
|
564
|
+
---
|
|
565
|
+
|
|
566
|
+
## 14. Middleware
|
|
567
|
+
|
|
568
|
+
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.
|
|
569
|
+
|
|
570
|
+
```ts
|
|
571
|
+
import { pipeline, requestLogger, cors, authGuard, csp } from "@bractjs/bractjs";
|
|
572
|
+
import type { MiddlewareFn } from "@bractjs/bractjs";
|
|
367
573
|
|
|
368
574
|
pipeline
|
|
369
575
|
.use(requestLogger())
|
|
370
576
|
.use(cors({ origin: "https://myapp.com" }))
|
|
577
|
+
.use(csp())
|
|
371
578
|
.use(authGuard({ session }));
|
|
372
579
|
```
|
|
373
580
|
|
|
374
|
-
|
|
581
|
+
### Built-in middleware
|
|
582
|
+
|
|
583
|
+
| Middleware | What it does |
|
|
375
584
|
|---|---|
|
|
376
|
-
| `requestLogger()` | Logs
|
|
377
|
-
| `cors(options)` | Sets CORS headers, handles `OPTIONS` preflight |
|
|
378
|
-
| `authGuard(options)` | Reads session,
|
|
585
|
+
| `requestLogger()` | Logs `[METHOD] /path → status in Xms`. Never logs the query string or headers (token-leak safe). |
|
|
586
|
+
| `cors(options)` | Sets CORS headers, handles `OPTIONS` preflight (204), always sets `Vary: Origin`, refuses `credentials:true` + `origin:"*"`. |
|
|
587
|
+
| `authGuard(options)` | Reads the session, sets `ctx.context.user`; with `required:true` returns 401 when unauthenticated. |
|
|
588
|
+
| `csp(options?)` | Opt-in nonce-based Content-Security-Policy (see below). |
|
|
379
589
|
|
|
380
|
-
|
|
590
|
+
**`cors(options)`** — `{ origin: string | string[]; methods?: string[]; credentials?: boolean }`:
|
|
591
|
+
```ts
|
|
592
|
+
pipeline.use(cors({ origin: ["https://a.com", "https://b.com"], credentials: true }));
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
**`authGuard(options)`** — `{ session: SessionStorageLike; required?: boolean }`:
|
|
596
|
+
```ts
|
|
597
|
+
pipeline.use(authGuard({ session, required: true })); // 401 if no session.user
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
**`csp(options?)`** — generates a per-request nonce, applies it to the scripts BractJS injects (via `renderToReadableStream`'s `nonce`), and sets the CSP header:
|
|
601
|
+
```ts
|
|
602
|
+
pipeline.use(csp({
|
|
603
|
+
directives: { "img-src": "'self' data: https://cdn.example", "frame-ancestors": "'none'" },
|
|
604
|
+
reportOnly: false, // true → Content-Security-Policy-Report-Only
|
|
605
|
+
}));
|
|
606
|
+
```
|
|
607
|
+
Read the nonce inside a component/middleware with `getCspNonce(context)` (key: `CSP_NONCE_KEY`) to nonce your own inline scripts.
|
|
608
|
+
|
|
609
|
+
### Custom middleware
|
|
381
610
|
|
|
382
611
|
```ts
|
|
383
612
|
import type { MiddlewareFn } from "@bractjs/bractjs";
|
|
384
613
|
|
|
385
614
|
const trace: MiddlewareFn = async (ctx, next) => {
|
|
386
615
|
ctx.context.requestId = crypto.randomUUID();
|
|
387
|
-
|
|
616
|
+
const res = await next();
|
|
617
|
+
res.headers.set("X-Request-Id", ctx.context.requestId as string);
|
|
618
|
+
return res;
|
|
388
619
|
};
|
|
620
|
+
pipeline.use(trace);
|
|
389
621
|
```
|
|
390
622
|
|
|
391
|
-
|
|
623
|
+
You can also construct an isolated `new MiddlewarePipeline()` and `.run(ctx, handler)` it yourself (used internally and in tests).
|
|
392
624
|
|
|
393
625
|
---
|
|
394
626
|
|
|
395
|
-
## Sessions
|
|
627
|
+
## 15. Sessions
|
|
628
|
+
|
|
629
|
+
Signed, tamper-proof cookie sessions (HMAC-SHA256, constant-time verify, secret rotation).
|
|
396
630
|
|
|
397
631
|
```ts
|
|
398
632
|
import { createCookieSession } from "@bractjs/bractjs";
|
|
399
633
|
|
|
400
634
|
const session = createCookieSession({
|
|
401
635
|
name: "__session",
|
|
402
|
-
secrets: [Bun.env.SESSION_SECRET],
|
|
403
|
-
maxAge: 60 * 60 * 24 * 7, // 1 week
|
|
404
|
-
secure: true,
|
|
405
|
-
sameSite: "
|
|
636
|
+
secrets: [Bun.env.SESSION_SECRET!], // first signs; all verify (rotate by prepending)
|
|
637
|
+
maxAge: 60 * 60 * 24 * 7, // seconds; 1 week
|
|
638
|
+
secure: true, // false only for local HTTP dev
|
|
639
|
+
sameSite: "Lax", // "Strict" | "Lax" | "None"
|
|
406
640
|
});
|
|
641
|
+
```
|
|
407
642
|
|
|
643
|
+
Read in a loader, write in an action:
|
|
644
|
+
|
|
645
|
+
```ts
|
|
408
646
|
export async function loader({ request }: LoaderArgs) {
|
|
409
647
|
const s = await session.getSession(request.headers.get("Cookie"));
|
|
410
648
|
return { user: s.get("user") };
|
|
@@ -412,406 +650,457 @@ export async function loader({ request }: LoaderArgs) {
|
|
|
412
650
|
|
|
413
651
|
export async function action({ request }: ActionArgs) {
|
|
414
652
|
const s = await session.getSession(request.headers.get("Cookie"));
|
|
415
|
-
s.set("user", { id: 1, name: "Alice" });
|
|
653
|
+
s.set("user", { id: 1, name: "Alice" }); // also: s.get, s.has, s.delete
|
|
416
654
|
return redirect("/dashboard", {
|
|
417
|
-
headers: { "Set-Cookie": await session.commitSession(s) },
|
|
655
|
+
headers: { "Set-Cookie": await session.commitSession(s) }, // opt: { maxAge }
|
|
418
656
|
});
|
|
419
657
|
}
|
|
420
658
|
```
|
|
421
659
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
660
|
+
- Each secret must be ≥16 chars; `secrets` must be non-empty (throws otherwise).
|
|
661
|
+
- Tampered cookies are silently rejected → empty session.
|
|
662
|
+
- Generate a secret: `openssl rand -base64 32`.
|
|
425
663
|
|
|
426
664
|
---
|
|
427
665
|
|
|
428
|
-
##
|
|
666
|
+
## 16. Lifecycle hooks: `defineLifecycle`
|
|
429
667
|
|
|
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.
|
|
668
|
+
Run code on server start, shutdown, and unexpected errors. Shutdown fires on **any** exit signal (`SIGTERM`, `SIGINT`, `SIGUSR2`, `beforeExit`, uncaught exceptions).
|
|
446
669
|
|
|
447
670
|
```ts
|
|
448
671
|
// app/lifecycle.ts
|
|
449
672
|
import { defineLifecycle } from "@bractjs/bractjs";
|
|
450
673
|
import { db } from "./db.server.ts";
|
|
451
|
-
import * as Sentry from "@sentry/bun";
|
|
452
674
|
|
|
453
675
|
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
|
-
});
|
|
676
|
+
async onStart() { await db.connect(); },
|
|
677
|
+
async onShutdown(){ await db.disconnect(); },
|
|
678
|
+
onError(err, request) {
|
|
679
|
+
Sentry.captureException(err, { extra: { url: request?.url } });
|
|
467
680
|
},
|
|
468
681
|
});
|
|
469
682
|
```
|
|
470
683
|
|
|
471
|
-
|
|
684
|
+
| Hook | When |
|
|
685
|
+
|------|------|
|
|
686
|
+
| `onStart` | Once, after the server starts listening. |
|
|
687
|
+
| `onShutdown` | Before exit — any signal, programmatic `stop()`, or uncaught exception. |
|
|
688
|
+
| `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
689
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
import
|
|
690
|
+
- **Dev** picks up `app/lifecycle.ts` automatically.
|
|
691
|
+
- **Production**: spread into `createServer()`:
|
|
692
|
+
```ts
|
|
693
|
+
import { createServer } from "@bractjs/bractjs";
|
|
694
|
+
import lifecycle from "./app/lifecycle.ts";
|
|
695
|
+
createServer({ port: 3000, ...lifecycle });
|
|
696
|
+
```
|
|
477
697
|
|
|
478
|
-
createServer(
|
|
479
|
-
```
|
|
698
|
+
`createServer()` returns `{ stop }`. `stop()` runs `onShutdown` and closes the listener but does **not** call `process.exit()` (good for tests/supervisors). Signals do exit.
|
|
480
699
|
|
|
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. |
|
|
700
|
+
---
|
|
486
701
|
|
|
487
|
-
|
|
702
|
+
## 17. Environment variables
|
|
488
703
|
|
|
489
|
-
|
|
704
|
+
| Convention | Behavior |
|
|
705
|
+
|---|---|
|
|
706
|
+
| `*.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. |
|
|
707
|
+
| Keys listed in `clientEnv` | Replaced with string literals in the client bundle. |
|
|
708
|
+
| Any other `process.env.*` | Becomes the literal `"undefined"` in the client bundle. |
|
|
490
709
|
|
|
491
710
|
```ts
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
711
|
+
// db.server.ts — never reaches the browser
|
|
712
|
+
import { Database } from "bun:sqlite";
|
|
713
|
+
export const db = new Database(Bun.env.DATABASE_URL!);
|
|
495
714
|
```
|
|
496
715
|
|
|
497
|
-
|
|
716
|
+
```ts
|
|
717
|
+
// app/routes/posts.tsx — import the server module inside the loader
|
|
718
|
+
import { db } from "../db.server.ts"; // stubbed in the client bundle
|
|
498
719
|
|
|
499
|
-
|
|
720
|
+
export async function loader() {
|
|
721
|
+
return { posts: db.query("SELECT * FROM posts").all() };
|
|
722
|
+
}
|
|
723
|
+
```
|
|
500
724
|
|
|
501
|
-
|
|
725
|
+
> 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
726
|
|
|
503
|
-
|
|
727
|
+
```ts
|
|
728
|
+
// bractjs.config.ts
|
|
729
|
+
export default { clientEnv: ["PUBLIC_API_URL"] };
|
|
730
|
+
```
|
|
504
731
|
|
|
505
|
-
|
|
732
|
+
```ts
|
|
733
|
+
// in a client component — only allow-listed keys survive
|
|
734
|
+
fetch(`${process.env.PUBLIC_API_URL}/items`);
|
|
735
|
+
```
|
|
506
736
|
|
|
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 |
|
|
737
|
+
On the server, read env via `Bun.env.*` directly.
|
|
520
738
|
|
|
521
739
|
---
|
|
522
740
|
|
|
523
|
-
##
|
|
741
|
+
## 18. Typed routes codegen
|
|
524
742
|
|
|
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) |
|
|
743
|
+
Generate per-route param types and a type-safe URL builder from your route files.
|
|
535
744
|
|
|
536
|
-
|
|
745
|
+
```sh
|
|
746
|
+
bractjs codegen # ./app → ./app/route-types.gen.ts
|
|
747
|
+
bractjs codegen ./app ./app/types.ts # explicit paths
|
|
748
|
+
```
|
|
537
749
|
|
|
538
|
-
|
|
750
|
+
Runs automatically during `bractjs build`. The generated file provides:
|
|
539
751
|
|
|
540
|
-
|
|
752
|
+
```ts
|
|
753
|
+
export type AppRoutes = "/" | "/blog/:id" | "/org/:orgId/repo/:repoId";
|
|
541
754
|
|
|
542
|
-
|
|
755
|
+
export type RouteParams<T extends AppRoutes> =
|
|
756
|
+
T extends "/blog/:id" ? { id: string } : Record<never, never>;
|
|
543
757
|
|
|
544
|
-
|
|
758
|
+
export type TypedLoaderArgs<T extends AppRoutes> = { request: Request; params: RouteParams<T>; context: Record<string, unknown> };
|
|
759
|
+
export type TypedActionArgs<T extends AppRoutes> = TypedLoaderArgs<T> & { formData: FormData };
|
|
545
760
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
# bractjs build # writes build/client/* + route-manifest.json
|
|
551
|
-
# bractjs codegen:manifest # snapshots manifest → app/_generated/manifest.ts
|
|
552
|
-
# bun build --compile app/server.ts --outfile ./myapp
|
|
761
|
+
export const routes = {
|
|
762
|
+
"/": () => "/",
|
|
763
|
+
"/blog/:id": (p: { id: string }) => `/blog/${p.id}`,
|
|
764
|
+
} as const;
|
|
553
765
|
```
|
|
554
766
|
|
|
555
|
-
|
|
767
|
+
Use them for typed loaders and safe navigation:
|
|
556
768
|
|
|
557
|
-
```
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
769
|
+
```ts
|
|
770
|
+
import type { TypedLoaderArgs, RouteParams } from "../route-types.gen.ts";
|
|
771
|
+
import { routes } from "../route-types.gen.ts";
|
|
772
|
+
|
|
773
|
+
export async function loader({ params }: TypedLoaderArgs<"/blog/:id">) {
|
|
774
|
+
return db.post.findById(params.id); // params.id: string
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const { id } = useParams<RouteParams<"/blog/:id">>();
|
|
778
|
+
routes["/blog/:id"]({ id: "123" }); // → "/blog/123" (typo'd routes won't compile)
|
|
564
779
|
```
|
|
565
780
|
|
|
566
|
-
|
|
781
|
+
You can also call `writeRouteTypes(appDir, outPath?)` / `generateRouteTypes(appDir)` programmatically.
|
|
567
782
|
|
|
568
|
-
|
|
783
|
+
---
|
|
784
|
+
|
|
785
|
+
## 19. Internationalization utilities
|
|
569
786
|
|
|
570
|
-
|
|
787
|
+
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
788
|
|
|
572
789
|
```ts
|
|
573
|
-
import {
|
|
574
|
-
import {
|
|
575
|
-
import { actionModules } from "./_generated/actions.ts";
|
|
576
|
-
import { manifest } from "./_generated/manifest.ts";
|
|
790
|
+
import { wrapRoutesWithLocale, stripLocale, localizedDataPath } from "@bractjs/bractjs";
|
|
791
|
+
import type { I18nConfig } from "@bractjs/bractjs";
|
|
577
792
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
793
|
+
const i18n: I18nConfig = { locales: ["en", "fr"], defaultLocale: "en" };
|
|
794
|
+
|
|
795
|
+
// Add /:locale-prefixed variants alongside the originals.
|
|
796
|
+
const localized = wrapRoutesWithLocale(routeFiles, i18n);
|
|
797
|
+
|
|
798
|
+
// Split a locale off a pathname.
|
|
799
|
+
const { locale, strippedPathname } = stripLocale("/fr/about", i18n.locales);
|
|
800
|
+
// → { locale: "fr", strippedPathname: "/about" }
|
|
801
|
+
|
|
802
|
+
// Build a locale-aware data path.
|
|
803
|
+
localizedDataPath("/about", "fr"); // → "/fr/about"
|
|
587
804
|
```
|
|
588
805
|
|
|
589
|
-
|
|
806
|
+
On the client, read the active locale and build localized links:
|
|
590
807
|
|
|
591
|
-
|
|
808
|
+
```tsx
|
|
809
|
+
const locale = useLocale("en");
|
|
810
|
+
const to = useLocalizedLink("en");
|
|
811
|
+
<Link to={to("/about")} />; // → /en/about
|
|
812
|
+
```
|
|
592
813
|
|
|
593
|
-
|
|
814
|
+
---
|
|
594
815
|
|
|
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 |
|
|
816
|
+
## 20. Image optimization
|
|
602
817
|
|
|
603
|
-
|
|
604
|
-
import {
|
|
605
|
-
useClientStubPlugin,
|
|
606
|
-
createUseServerProxyPlugin,
|
|
607
|
-
serverOnlyPlugin,
|
|
608
|
-
clientEnvPlugin,
|
|
609
|
-
cssModulesPlugin,
|
|
610
|
-
} from "@bractjs/bractjs";
|
|
818
|
+
`<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
819
|
|
|
612
|
-
|
|
613
|
-
|
|
820
|
+
```tsx
|
|
821
|
+
import { Image } from "@bractjs/bractjs";
|
|
614
822
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
823
|
+
<Image src="/public/hero.jpg" alt="Hero" width={1200} height={600} />
|
|
824
|
+
|
|
825
|
+
{/* above-the-fold: eager + fetchpriority=high */}
|
|
826
|
+
<Image src="/public/hero.jpg" alt="Hero" width={1200} priority />
|
|
827
|
+
|
|
828
|
+
<Image
|
|
829
|
+
src="/public/photo.jpg" alt="Photo" width={800}
|
|
830
|
+
format="avif" quality={70} fit="contain"
|
|
831
|
+
sizes="(max-width: 640px) 100vw, 50vw"
|
|
832
|
+
/>
|
|
622
833
|
```
|
|
623
834
|
|
|
624
|
-
|
|
835
|
+
| Prop | Type | Default | Notes |
|
|
836
|
+
|------|------|---------|-------|
|
|
837
|
+
| `src` | `string` | — | Path under `/public/` (required) |
|
|
838
|
+
| `alt` | `string` | — | Required |
|
|
839
|
+
| `width` / `height` | `number` | — | Intrinsic size |
|
|
840
|
+
| `quality` | `number` | `80` | 1–100 |
|
|
841
|
+
| `format` | `"webp" \| "avif" \| "jpeg" \| "png"` | `"webp"` | `ImageFormat` |
|
|
842
|
+
| `fit` | `"cover" \| "contain" \| "fill"` | `"cover"` | `ImageFit` |
|
|
843
|
+
| `priority` | `boolean` | `false` | Disable lazy load, set `fetchpriority=high` |
|
|
844
|
+
| `sizes` | `string` | `"100vw"` | HTML `sizes` |
|
|
845
|
+
|
|
846
|
+
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).
|
|
847
|
+
|
|
848
|
+
`ImageProps`, `ImageFormat`, and `ImageFit` are exported types.
|
|
625
849
|
|
|
626
850
|
---
|
|
627
851
|
|
|
628
|
-
##
|
|
852
|
+
## 21. Build & run
|
|
853
|
+
|
|
854
|
+
### CLI
|
|
855
|
+
|
|
856
|
+
| Command | Description |
|
|
857
|
+
|---------|-------------|
|
|
858
|
+
| `bractjs new <name>` | Scaffold a new app into `<name>/`. |
|
|
859
|
+
| `bractjs dev` | Dev server with HMR (port 3000, HMR ws 3001). |
|
|
860
|
+
| `bractjs build` | Dual server + client build with content-hashed output. |
|
|
861
|
+
| `bractjs start` | Serve the production build. |
|
|
862
|
+
| `bractjs codegen [app] [out]` | Generate `route-types.gen.ts`. |
|
|
863
|
+
| `bractjs codegen:registry [app]` | Generate `app/_generated/{routes,actions}.ts`. |
|
|
864
|
+
| `bractjs codegen:manifest [app] [build]` | Snapshot manifest → `app/_generated/manifest.ts`. |
|
|
865
|
+
| `bractjs compile [outfile] [entry]` | Full single-binary pipeline. |
|
|
629
866
|
|
|
630
|
-
|
|
867
|
+
The CLI is a thin wrapper — every command delegates to a public function, so you can script the same thing.
|
|
631
868
|
|
|
632
|
-
###
|
|
869
|
+
### Programmatic API
|
|
633
870
|
|
|
871
|
+
**`createDevServer(options?)`** — dev server with HMR.
|
|
634
872
|
```ts
|
|
635
873
|
import { createDevServer } from "@bractjs/bractjs";
|
|
636
874
|
|
|
637
875
|
const dev = await createDevServer({
|
|
638
|
-
port: 3000,
|
|
639
|
-
hmrPort: 3001,
|
|
876
|
+
port: 3000, // default: config.port ?? 3000
|
|
877
|
+
hmrPort: 3001, // HMR websocket
|
|
640
878
|
config: { appDir: "./app", clientEnv: ["PUBLIC_API_URL"] },
|
|
641
|
-
skipUserConfig: false, //
|
|
879
|
+
skipUserConfig: false, // true → don't read bractjs.config.ts
|
|
642
880
|
});
|
|
643
|
-
|
|
644
|
-
// Later — stops the HTTP server and HMR WebSocket server
|
|
645
881
|
dev.stop();
|
|
646
882
|
```
|
|
647
883
|
|
|
648
|
-
|
|
649
|
-
|
|
884
|
+
**`runBuild(config?)`** — production build (accepts only build-relevant fields).
|
|
650
885
|
```ts
|
|
651
886
|
import { runBuild } from "@bractjs/bractjs";
|
|
652
887
|
|
|
653
888
|
await runBuild({
|
|
654
889
|
appDir: "./app",
|
|
655
|
-
buildDir: "./
|
|
890
|
+
buildDir: "./build",
|
|
656
891
|
minify: true,
|
|
657
|
-
sourcemap: "external",
|
|
892
|
+
sourcemap: "external", // "none" | "linked" | "inline" | "external"
|
|
658
893
|
clientEnv: ["PUBLIC_API_URL"],
|
|
894
|
+
plugins: [], // extra Bun plugins
|
|
659
895
|
});
|
|
660
896
|
```
|
|
661
897
|
|
|
662
|
-
|
|
898
|
+
**`loadUserConfig()`** — read `bractjs.config.ts` (or `.js`) from cwd, validated.
|
|
899
|
+
```ts
|
|
900
|
+
import { loadUserConfig } from "@bractjs/bractjs";
|
|
901
|
+
const cfg = await loadUserConfig(); // {} if no file; throws on a malformed shape
|
|
902
|
+
```
|
|
663
903
|
|
|
664
|
-
|
|
904
|
+
**`createServer(config?)`** — production HTTP server. Returns `{ stop }`.
|
|
905
|
+
```ts
|
|
906
|
+
import { createServer } from "@bractjs/bractjs";
|
|
907
|
+
import lifecycle from "./app/lifecycle.ts";
|
|
908
|
+
const srv = createServer({ port: 3000, buildDir: "./build", ...lifecycle });
|
|
909
|
+
```
|
|
665
910
|
|
|
911
|
+
**`buildFetchHandler(config)`** — the adapter-agnostic `(Request) => Promise<Response>` core, if you want to mount BractJS inside another server.
|
|
666
912
|
```ts
|
|
667
|
-
import {
|
|
913
|
+
import { buildFetchHandler } from "@bractjs/bractjs";
|
|
914
|
+
const handler = buildFetchHandler({ appDir: "./app", manifest });
|
|
915
|
+
Bun.serve({ port: 3000, fetch: handler });
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
`renderRoute(options)` (low-level SSR render) and the `RenderOptions`/`ServerManifest`/`BractJSConfig` types are also exported for advanced embedding.
|
|
668
919
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
920
|
+
---
|
|
921
|
+
|
|
922
|
+
## 22. Single-binary deployment (`bun build --compile`)
|
|
923
|
+
|
|
924
|
+
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`**.
|
|
925
|
+
|
|
926
|
+
### One-shot
|
|
927
|
+
|
|
928
|
+
```sh
|
|
929
|
+
bractjs compile ./myapp
|
|
930
|
+
# = codegen:registry → build → codegen:manifest → bun build --compile app/server.ts
|
|
672
931
|
```
|
|
673
932
|
|
|
674
|
-
### `
|
|
933
|
+
### The `app/server.ts` entry
|
|
675
934
|
|
|
676
|
-
|
|
935
|
+
The scaffold includes:
|
|
677
936
|
|
|
678
937
|
```ts
|
|
679
938
|
import { createServer } from "@bractjs/bractjs";
|
|
680
|
-
import
|
|
939
|
+
import { routeFiles, moduleRegistry } from "./_generated/routes.ts";
|
|
940
|
+
import { actionModules } from "./_generated/actions.ts";
|
|
941
|
+
import { manifest } from "./_generated/manifest.ts";
|
|
681
942
|
|
|
682
|
-
createServer({
|
|
943
|
+
createServer({
|
|
944
|
+
port: Number(process.env.PORT ?? 3000),
|
|
945
|
+
appDir: "./app",
|
|
946
|
+
publicDir: "./public",
|
|
947
|
+
manifest, // no manifest read from disk
|
|
948
|
+
routeFiles, // no Bun.Glob route scan
|
|
949
|
+
moduleRegistry, // no dynamic import(absPath)
|
|
950
|
+
actionModules, // no scan/import for "use server" files
|
|
951
|
+
});
|
|
683
952
|
```
|
|
684
953
|
|
|
685
|
-
|
|
954
|
+
When all four are present, the server uses the pre-imported modules for routing, layouts, actions, and assets.
|
|
686
955
|
|
|
687
|
-
|
|
956
|
+
### Manual pipeline
|
|
688
957
|
|
|
958
|
+
```sh
|
|
959
|
+
bractjs codegen:registry # A — scan routes/actions → static imports
|
|
960
|
+
bractjs build # B — client + server bundles + manifest
|
|
961
|
+
bractjs codegen:manifest # C — embed manifest as a TS constant
|
|
962
|
+
bun build --compile app/server.ts \ # D — single binary
|
|
963
|
+
--asset build/client/ --outfile ./myapp
|
|
689
964
|
```
|
|
690
|
-
my-app/
|
|
691
|
-
├── app/
|
|
692
|
-
│ ├── root.tsx # required — <html> shell
|
|
693
|
-
│ ├── server.ts # bun build --compile entrypoint (single-binary build)
|
|
694
|
-
│ ├── lifecycle.ts # optional — onStart / onShutdown / onError hooks
|
|
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
|
|
716
|
-
```
|
|
717
965
|
|
|
718
|
-
|
|
966
|
+
`--asset build/client/` embeds JS/CSS into the binary (true single file); omit it to ship `myapp` + `build/client/` side by side.
|
|
967
|
+
|
|
968
|
+
The codegen functions are exported: `writeModuleRegistries(appDir)`, `writeManifestModule(appDir, buildDir)`, and the lower-level `generateRouteRegistry` / `generateActionRegistry` / `generateManifestModule`.
|
|
969
|
+
|
|
970
|
+
> **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
971
|
|
|
720
972
|
---
|
|
721
973
|
|
|
722
|
-
##
|
|
974
|
+
## 23. Custom adapters
|
|
723
975
|
|
|
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
|
-
```
|
|
976
|
+
The server core is adapter-agnostic. The default is `BunAdapter` (wraps `Bun.serve`); supply your own via `createServer({ adapter })`.
|
|
734
977
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
└─ ClientRouter (RouterContext + NavigationContext)
|
|
739
|
-
└─ Outlet → React.lazy route chunk
|
|
740
|
-
└─ useLoaderData / useParams / useNavigation / …
|
|
741
|
-
```
|
|
978
|
+
```ts
|
|
979
|
+
import type { BractAdapter } from "@bractjs/bractjs";
|
|
980
|
+
import { BunAdapter } from "@bractjs/bractjs";
|
|
742
981
|
|
|
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
|
|
982
|
+
// BractAdapter: { fetch(req): Promise<Response>; listen?(port): void }
|
|
749
983
|
```
|
|
750
984
|
|
|
751
|
-
|
|
752
|
-
```
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
--compile app/server.ts [--asset build/client/]
|
|
985
|
+
**Cloudflare Workers:**
|
|
986
|
+
```ts
|
|
987
|
+
import { buildFetchHandler, makeCloudflareHandler } from "@bractjs/bractjs";
|
|
988
|
+
const handler = buildFetchHandler({ appDir: "./app", manifest });
|
|
989
|
+
export default makeCloudflareHandler(handler);
|
|
990
|
+
// or createCloudflareAdapter(handler) for the BractAdapter-compatible form
|
|
758
991
|
```
|
|
759
992
|
|
|
760
993
|
---
|
|
761
994
|
|
|
762
|
-
##
|
|
995
|
+
## 24. Build plugins
|
|
763
996
|
|
|
997
|
+
If you write your own `Bun.build()` (instead of `bractjs build`), you **must** apply these or face crashes / secret leaks. All are exported.
|
|
998
|
+
|
|
999
|
+
| Bundle | Plugin | Without it |
|
|
1000
|
+
|---|---|---|
|
|
1001
|
+
| Server | `useClientStubPlugin` | Server crashes calling browser-only hooks from `"use client"` modules. |
|
|
1002
|
+
| Client | `createUseServerProxyPlugin(appDir)` | Server-action bodies (DB code, secrets) ship in the browser JS. |
|
|
1003
|
+
| Client | `serverModuleStubPlugin` | `*.server.ts` source (DB drivers, secrets) leaks into the client bundle. |
|
|
1004
|
+
| Client | `clientEnvPlugin(allowedKeys, env)` | Server env vars leak into the browser bundle. |
|
|
1005
|
+
| Client | `cssModulesPlugin` | `*.module.css` imports don't resolve. |
|
|
1006
|
+
|
|
1007
|
+
```ts
|
|
1008
|
+
import {
|
|
1009
|
+
useClientStubPlugin, createUseServerProxyPlugin,
|
|
1010
|
+
serverModuleStubPlugin, clientEnvPlugin, cssModulesPlugin,
|
|
1011
|
+
} from "@bractjs/bractjs";
|
|
1012
|
+
|
|
1013
|
+
// Server bundle (target: "bun"):
|
|
1014
|
+
plugins: [useClientStubPlugin];
|
|
1015
|
+
|
|
1016
|
+
// Client bundle (target: "browser"):
|
|
1017
|
+
plugins: [
|
|
1018
|
+
serverModuleStubPlugin,
|
|
1019
|
+
createUseServerProxyPlugin("./app"), // same appDir as createServer!
|
|
1020
|
+
clientEnvPlugin(["PUBLIC_API_URL"], Bun.env as Record<string, string>),
|
|
1021
|
+
cssModulesPlugin,
|
|
1022
|
+
];
|
|
764
1023
|
```
|
|
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
|
-
```
|
|
1024
|
+
|
|
1025
|
+
> 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
1026
|
|
|
781
1027
|
---
|
|
782
1028
|
|
|
783
|
-
##
|
|
1029
|
+
## 25. Configuration reference
|
|
784
1030
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
1031
|
+
All fields optional. Put them in `bractjs.config.ts` (default export) or pass to `createServer` / `createDevServer` / `runBuild`.
|
|
1032
|
+
|
|
1033
|
+
| Field | Type | Default | Description |
|
|
1034
|
+
|-------|------|---------|-------------|
|
|
1035
|
+
| `port` | `number` | `3000` | TCP port |
|
|
1036
|
+
| `appDir` | `string` | `"./app"` | Contains `routes/` and `root.tsx` |
|
|
1037
|
+
| `publicDir` | `string` | `"./public"` | Static assets (served no-cache) |
|
|
1038
|
+
| `buildDir` | `string` | `"./build"` | Build output |
|
|
1039
|
+
| `imageCacheDir` | `string` | `".bract-image-cache"` | Optimized-image disk cache |
|
|
1040
|
+
| `sourcemap` | `string` | `"external"` | `"none" \| "linked" \| "inline" \| "external"` |
|
|
1041
|
+
| `minify` | `boolean` | `true` | Minify client bundles |
|
|
1042
|
+
| `clientEnv` | `string[]` | `[]` | `process.env` keys exposed to the client |
|
|
1043
|
+
| `plugins` | `BunPlugin[]` | `[]` | Extra client-build plugins |
|
|
1044
|
+
| `adapter` | `BractAdapter` | `BunAdapter` | Custom server adapter |
|
|
1045
|
+
| `i18n` | `I18nConfig` | — | Locale config consumed by the i18n utilities |
|
|
1046
|
+
| `onStart` / `onShutdown` / `onError` | hooks | — | Lifecycle (§16) |
|
|
1047
|
+
|
|
1048
|
+
`loadUserConfig()` validates these shapes and throws a clear error on an obvious mistake (e.g. a string `port`).
|
|
791
1049
|
|
|
792
1050
|
---
|
|
793
1051
|
|
|
794
|
-
##
|
|
1052
|
+
## 26. Full export index
|
|
1053
|
+
|
|
1054
|
+
Everything importable from `@bractjs/bractjs` ([src/index.ts](src/index.ts)):
|
|
1055
|
+
|
|
1056
|
+
**Server / runtime:** `createServer`, `buildFetchHandler`, `renderRoute`, `redirect`, `json`, `error`, `defineContext`, `route`, `validate`, `BunAdapter`, `defineLifecycle`
|
|
1057
|
+
|
|
1058
|
+
**Errors:** `BractJSError`, `HttpError`, `isRedirect`, `isHttpError`, `isBractJSError`
|
|
1059
|
+
|
|
1060
|
+
**Streaming:** `defer`, `Deferred`, `isDeferred`, `Await`
|
|
1061
|
+
|
|
1062
|
+
**Context:** `BractJSContext`, `BractJSProvider`, `useBractJSContext`
|
|
1063
|
+
|
|
1064
|
+
**Middleware:** `pipeline`, `MiddlewarePipeline`, `requestLogger`, `cors`, `authGuard`, `csp`, `getCspNonce`, `CSP_NONCE_KEY`
|
|
1065
|
+
|
|
1066
|
+
**Sessions:** `createCookieSession`
|
|
1067
|
+
|
|
1068
|
+
**Components:** `Outlet`, `Link`, `Form`, `Scripts`, `LiveReload`, `Await`, `Image`
|
|
795
1069
|
|
|
796
|
-
**
|
|
1070
|
+
**Hooks:** `useLoaderData`, `useActionData`, `useParams`, `useNavigation`, `useFetcher`, `useSearchParams`, `useBlocker`, `useLocale`, `useLocalizedLink`
|
|
797
1071
|
|
|
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
|
|
1072
|
+
**i18n:** `wrapRoutesWithLocale`, `stripLocale`, `localizedDataPath`
|
|
810
1073
|
|
|
811
|
-
|
|
1074
|
+
**Client RPC:** `createClient`
|
|
1075
|
+
|
|
1076
|
+
**Build / programmatic:** `createDevServer`, `runBuild`, `loadUserConfig`
|
|
1077
|
+
|
|
1078
|
+
**Codegen:** `writeModuleRegistries`, `writeManifestModule`, `generateRouteRegistry`, `generateActionRegistry`, `generateManifestModule`
|
|
1079
|
+
|
|
1080
|
+
**Build plugins:** `useClientStubPlugin`, `createUseServerProxyPlugin`, `useServerProxyPlugin`, `serverModuleStubPlugin`, `serverOnlyPlugin`, `clientEnvPlugin`, `cssModulesPlugin`, `transformCssModule`
|
|
1081
|
+
|
|
1082
|
+
**Adapters:** `createCloudflareAdapter`, `makeCloudflareHandler`
|
|
1083
|
+
|
|
1084
|
+
**Types:** `LoaderArgs`, `ActionArgs`, `MetaArgs`, `MetaDescriptor`, `LoaderFunction`, `ActionFunction`, `MetaFunction`, `RouteModule`, `RouteDefinition`, `RouteFile`, `Segment`, `BractJSConfig`, `RenderOptions`, `ServerManifest`, `ContextFactory`, `ApiRouteDefinition`, `AppApiRoutes`, `FieldErrors`, `ValidationError`, `BractAdapter`, `LifecycleHooks`, `MiddlewareFn`, `MiddlewareContext`, `CorsOptions`, `AuthGuardOptions`, `CspOptions`, `SessionStorageLike`, `SessionLike`, `Session`, `SessionStorage`, `SessionData`, `CookieSessionOptions`, `CommitOptions`, `ImageProps`, `ImageFormat`, `ImageFit`, `SearchParamsResult`, `I18nConfig`, `DevServerOptions`, `DevServer`, `BuildConfig`, `CodegenResult`, `ModuleRegistry`, `BractJSContextValue`, `RouteManifest`
|
|
812
1085
|
|
|
813
1086
|
---
|
|
814
1087
|
|
|
1088
|
+
## Changelog
|
|
1089
|
+
|
|
1090
|
+
See [CHANGELOG.md](CHANGELOG.md) for release history.
|
|
1091
|
+
|
|
1092
|
+
---
|
|
1093
|
+
|
|
1094
|
+
## Why BractJS
|
|
1095
|
+
|
|
1096
|
+
- **Bun-native** — `Bun.serve`, `Bun.build`, `Bun.file`, `Bun.Glob`, `Bun.watch`. No Node.js.
|
|
1097
|
+
- **Zero framework deps** — only peers are `react` and `react-dom`.
|
|
1098
|
+
- **Streaming SSR** — `renderToReadableStream()` with `defer()` and `<Await>`.
|
|
1099
|
+
- **File-based routing** — drop a file in `app/routes/`.
|
|
1100
|
+
- **Full-stack** — loaders, actions, sessions, server actions, typed API routes, middleware.
|
|
1101
|
+
- **Typed routes** — codegen produces per-route params and a type-safe URL builder.
|
|
1102
|
+
- **Single-binary** — `bun build --compile` to one executable.
|
|
1103
|
+
|
|
815
1104
|
## License
|
|
816
1105
|
|
|
817
1106
|
MIT
|