@bractjs/bractjs 0.1.0
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/LICENSE +21 -0
- package/README.md +586 -0
- package/bin/cli.ts +101 -0
- package/package.json +58 -0
- package/src/__tests__/fixtures/app/root.tsx +9 -0
- package/src/__tests__/fixtures/app/routes/_index.tsx +20 -0
- package/src/__tests__/integration.test.ts +66 -0
- package/src/__tests__/loader.test.ts +89 -0
- package/src/__tests__/matcher.test.ts +69 -0
- package/src/__tests__/meta.test.ts +81 -0
- package/src/__tests__/scanner.test.ts +58 -0
- package/src/__tests__/session.test.ts +103 -0
- package/src/build/bundler.ts +75 -0
- package/src/build/defines.ts +16 -0
- package/src/build/directives.ts +67 -0
- package/src/build/env-plugin.ts +56 -0
- package/src/build/hash.ts +56 -0
- package/src/build/manifest.ts +60 -0
- package/src/client/ClientRouter.tsx +122 -0
- package/src/client/components/Await.tsx +26 -0
- package/src/client/components/Form.tsx +67 -0
- package/src/client/components/Image.tsx +79 -0
- package/src/client/components/Link.tsx +42 -0
- package/src/client/components/LiveReload.tsx +16 -0
- package/src/client/components/Outlet.tsx +64 -0
- package/src/client/components/Scripts.tsx +12 -0
- package/src/client/entry.tsx +49 -0
- package/src/client/form-utils.ts +12 -0
- package/src/client/hooks/useActionData.ts +14 -0
- package/src/client/hooks/useFetcher.ts +51 -0
- package/src/client/hooks/useLoaderData.ts +14 -0
- package/src/client/hooks/useNavigation.ts +12 -0
- package/src/client/hooks/useParams.ts +14 -0
- package/src/client/nav-utils.ts +35 -0
- package/src/client/prefetch.ts +32 -0
- package/src/client/route-cache.ts +20 -0
- package/src/client/router.tsx +54 -0
- package/src/client/types.ts +23 -0
- package/src/codegen/route-codegen.ts +99 -0
- package/src/dev/error-overlay.ts +33 -0
- package/src/dev/hmr-client.ts +43 -0
- package/src/dev/hmr-module-handler.ts +47 -0
- package/src/dev/hmr-server.ts +51 -0
- package/src/dev/rebuilder.ts +95 -0
- package/src/dev/server.ts +38 -0
- package/src/dev/watcher.ts +32 -0
- package/src/image/cache.ts +75 -0
- package/src/image/handler.ts +82 -0
- package/src/image/optimizer.ts +76 -0
- package/src/image/types.ts +27 -0
- package/src/index.ts +51 -0
- package/src/middleware/authGuard.ts +37 -0
- package/src/middleware/cors.ts +36 -0
- package/src/middleware/requestLogger.ts +15 -0
- package/src/server/action-handler.ts +35 -0
- package/src/server/action-registry.ts +41 -0
- package/src/server/env.ts +29 -0
- package/src/server/index.ts +8 -0
- package/src/server/layout.ts +92 -0
- package/src/server/loader.ts +80 -0
- package/src/server/matcher.ts +99 -0
- package/src/server/meta.ts +92 -0
- package/src/server/middleware.ts +47 -0
- package/src/server/render.ts +65 -0
- package/src/server/request-handler.ts +142 -0
- package/src/server/response.ts +17 -0
- package/src/server/scanner.ts +68 -0
- package/src/server/serve.ts +131 -0
- package/src/server/session.ts +111 -0
- package/src/server/static.ts +40 -0
- package/src/shared/context.ts +37 -0
- package/src/shared/deferred.ts +55 -0
- package/src/shared/error-boundary.tsx +58 -0
- package/src/shared/errors.ts +49 -0
- package/src/shared/route-types.ts +51 -0
- package/templates/new-app/app/root.tsx +20 -0
- package/templates/new-app/app/routes/_index.tsx +31 -0
- package/templates/new-app/app/routes/about.tsx +21 -0
- package/templates/new-app/bractjs.config.ts +14 -0
- package/templates/new-app/package.json +20 -0
- package/types/config.d.ts +25 -0
- package/types/index.d.ts +109 -0
- package/types/middleware.d.ts +19 -0
- package/types/route.d.ts +41 -0
- package/types/session.d.ts +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 YOUR_NAME
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
# BractJS
|
|
2
|
+
|
|
3
|
+
> Production-grade SSR framework for Bun + React 19.
|
|
4
|
+
> File-based routing · Parallel loaders · Streaming SSR · Built-in HMR · Server Actions
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Quick Start
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
# Requires Bun — https://bun.sh
|
|
12
|
+
bunx bractjs new my-app
|
|
13
|
+
cd my-app
|
|
14
|
+
bun run dev
|
|
15
|
+
# → http://localhost:3000
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
> **From source (pre-publish):** clone the repo, then `bun run bin/cli.ts new my-app`.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## File-Based Routing
|
|
23
|
+
|
|
24
|
+
Place files inside `app/routes/`. BractJS scans them at startup.
|
|
25
|
+
|
|
26
|
+
| File | URL |
|
|
27
|
+
|------|-----|
|
|
28
|
+
| `routes/_index.tsx` | `/` |
|
|
29
|
+
| `routes/about.tsx` | `/about` |
|
|
30
|
+
| `routes/blog/_index.tsx` | `/blog` |
|
|
31
|
+
| `routes/blog/[id].tsx` | `/blog/:id` |
|
|
32
|
+
| `routes/docs/[...slug].tsx` | `/docs/*` (catch-all) |
|
|
33
|
+
| `routes/blog/layout.tsx` | wraps all `/blog/*` routes |
|
|
34
|
+
|
|
35
|
+
`app/root.tsx` is the outermost layout (always rendered). Match priority per segment: **static > dynamic > catch-all**.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Route Module API
|
|
40
|
+
|
|
41
|
+
Every file in `app/routes/` can export any combination of these:
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
import type { LoaderArgs, ActionArgs, MetaArgs } from "bractjs";
|
|
45
|
+
import { redirect } from "bractjs";
|
|
46
|
+
|
|
47
|
+
// Runs on every GET — return value becomes useLoaderData()
|
|
48
|
+
export async function loader({ request, params, context }: LoaderArgs) {
|
|
49
|
+
const post = await db.post.findById(params.id);
|
|
50
|
+
if (!post) throw new Response("Not Found", { status: 404 });
|
|
51
|
+
return { post };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type LoaderData = Awaited<ReturnType<typeof loader>>;
|
|
55
|
+
|
|
56
|
+
// Runs on POST / PUT / DELETE
|
|
57
|
+
export async function action({ params, formData }: ActionArgs) {
|
|
58
|
+
await db.post.update(params.id, { title: formData.get("title") as string });
|
|
59
|
+
return redirect("/blog");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// SSR <title> and <meta> tags
|
|
63
|
+
export function meta({ loaderData }: MetaArgs<LoaderData>) {
|
|
64
|
+
return [
|
|
65
|
+
{ title: loaderData.post.title },
|
|
66
|
+
{ name: "description", content: loaderData.post.excerpt },
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Error boundary for this route segment
|
|
71
|
+
export function ErrorBoundary({ error }: { error: Error }) {
|
|
72
|
+
return <p>Error: {error.message}</p>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The page component (required)
|
|
76
|
+
export default function BlogPost() {
|
|
77
|
+
const { post } = useLoaderData<LoaderData>();
|
|
78
|
+
return <article><h1>{post.title}</h1></article>;
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Root Layout (`app/root.tsx`)
|
|
85
|
+
|
|
86
|
+
Required. Provides the `<html>` document shell.
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
import { Scripts, LiveReload, Outlet } from "bractjs";
|
|
90
|
+
|
|
91
|
+
export function meta() {
|
|
92
|
+
return [{ title: "My App" }, { name: "viewport", content: "width=device-width, initial-scale=1" }];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default function Root() {
|
|
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
|
+
);
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Deferred / Streaming Data
|
|
112
|
+
|
|
113
|
+
`defer()` streams slow data without blocking the initial HTML response.
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import { defer } from "bractjs";
|
|
117
|
+
import { Await } from "bractjs";
|
|
118
|
+
import { Suspense } from "react";
|
|
119
|
+
|
|
120
|
+
export async function loader({ params }: LoaderArgs) {
|
|
121
|
+
return defer({
|
|
122
|
+
post: await db.post.findById(params.id), // awaited — in initial HTML
|
|
123
|
+
comments: db.comments.forPost(params.id), // Promise — streamed later
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export default function BlogPost() {
|
|
128
|
+
const { post, comments } = useLoaderData<LoaderData>();
|
|
129
|
+
return (
|
|
130
|
+
<article>
|
|
131
|
+
<h1>{post.title}</h1>
|
|
132
|
+
<Suspense fallback={<p>Loading comments…</p>}>
|
|
133
|
+
<Await resolve={comments}>
|
|
134
|
+
{(c) => <CommentList comments={c} />}
|
|
135
|
+
</Await>
|
|
136
|
+
</Suspense>
|
|
137
|
+
</article>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Client Primitives
|
|
145
|
+
|
|
146
|
+
### `<Link>`
|
|
147
|
+
|
|
148
|
+
Soft-navigates without a full reload. `prefetch="hover"` preloads the route chunk + loader data on mouse-enter.
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
import { Link } from "bractjs";
|
|
152
|
+
|
|
153
|
+
<Link to="/blog/42">Read Post</Link>
|
|
154
|
+
<Link to="/about" prefetch="hover">About</Link>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### `<Form>`
|
|
158
|
+
|
|
159
|
+
Fetch-based submission. Re-runs the current route's loader after the action completes.
|
|
160
|
+
|
|
161
|
+
```tsx
|
|
162
|
+
import { Form } from "bractjs";
|
|
163
|
+
|
|
164
|
+
<Form method="post" action="/blog/new">
|
|
165
|
+
<input name="title" />
|
|
166
|
+
<button type="submit">Create</button>
|
|
167
|
+
</Form>
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### `<Outlet>`
|
|
171
|
+
|
|
172
|
+
Renders the matched child route inside a layout.
|
|
173
|
+
|
|
174
|
+
```tsx
|
|
175
|
+
export default function BlogLayout() {
|
|
176
|
+
return (
|
|
177
|
+
<div>
|
|
178
|
+
<nav>Blog</nav>
|
|
179
|
+
<Outlet />
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Hooks
|
|
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";
|
|
199
|
+
|
|
200
|
+
const { post } = useLoaderData<LoaderData>();
|
|
201
|
+
|
|
202
|
+
const { state } = useNavigation();
|
|
203
|
+
if (state === "loading") return <Spinner />;
|
|
204
|
+
|
|
205
|
+
const fetcher = useFetcher();
|
|
206
|
+
fetcher.load("/api/suggestions?q=bun");
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## Image Optimization
|
|
212
|
+
|
|
213
|
+
`<Image>` serves responsively-sized, format-converted images through a built-in `/_image` endpoint. Requires [ImageMagick](https://imagemagick.org) (`magick` or `convert`) — falls back to serving the original if not installed.
|
|
214
|
+
|
|
215
|
+
```tsx
|
|
216
|
+
import { Image } from "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
|
+
/>
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The component generates a `srcset` across up to 7 breakpoints (320 → 1920 px). Optimized images are cached in memory (LRU, 200 slots) and on disk (`.bract-image-cache/`, survives restarts). Both layers respond with `Cache-Control: immutable`.
|
|
237
|
+
|
|
238
|
+
**Props:**
|
|
239
|
+
|
|
240
|
+
| Prop | Type | Default | Description |
|
|
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
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**Generated file provides:**
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
// Every URL pattern as a string literal union
|
|
267
|
+
export type AppRoutes = "/" | "/blog/:id" | "/org/:orgId/repo/:repoId";
|
|
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>;
|
|
274
|
+
|
|
275
|
+
// Typed loader / action args
|
|
276
|
+
export type TypedLoaderArgs<T extends AppRoutes> = { request: Request; params: RouteParams<T>; context: Record<string, unknown> };
|
|
277
|
+
export type TypedActionArgs<T extends AppRoutes> = TypedLoaderArgs<T> & { formData: FormData };
|
|
278
|
+
|
|
279
|
+
// Type-safe route builder
|
|
280
|
+
export const routes = {
|
|
281
|
+
"/": () => "/",
|
|
282
|
+
"/blog/:id": (params: { id: string }) => `/blog/${params.id}`,
|
|
283
|
+
} as const;
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
**Usage:**
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
// Typed loader — params.id is string, not string | undefined
|
|
290
|
+
import type { TypedLoaderArgs, RouteParams } from "../route-types.gen.ts";
|
|
291
|
+
|
|
292
|
+
export async function loader({ params }: TypedLoaderArgs<"/blog/:id">) {
|
|
293
|
+
return db.post.findById(params.id); // ✓ typed
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Typed params hook
|
|
297
|
+
const { id } = useParams<RouteParams<"/blog/:id">>();
|
|
298
|
+
|
|
299
|
+
// Type-safe navigation
|
|
300
|
+
import { routes } from "../route-types.gen.ts";
|
|
301
|
+
routes["/blog/:id"]({ id: "123" }); // → "/blog/123"
|
|
302
|
+
routes["/does-not-exist"](); // ✗ TypeScript error
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Server Actions & Client Components
|
|
308
|
+
|
|
309
|
+
### `"use server"` — Server Actions
|
|
310
|
+
|
|
311
|
+
Add `"use server"` as the first line of a file to mark its exports as Server Actions. On the client, calls are automatically serialized to `POST /_action?id=<hash>`. On the server, the real function runs.
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
// app/actions.ts
|
|
315
|
+
"use server";
|
|
316
|
+
|
|
317
|
+
export async function createPost(formData: FormData) {
|
|
318
|
+
const title = formData.get("title") as string;
|
|
319
|
+
await db.insert(posts).values({ title });
|
|
320
|
+
return { ok: true };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function deletePost(id: string) {
|
|
324
|
+
await db.delete(posts).where(eq(posts.id, id));
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
```tsx
|
|
329
|
+
// app/routes/new.tsx — import used normally; on client it becomes a fetch proxy
|
|
330
|
+
import { createPost } from "../actions.ts";
|
|
331
|
+
|
|
332
|
+
export default function NewPost() {
|
|
333
|
+
return (
|
|
334
|
+
<form action={createPost}>
|
|
335
|
+
<input name="title" />
|
|
336
|
+
<button type="submit">Create</button>
|
|
337
|
+
</form>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Server actions accept `FormData` (sent as `multipart/form-data`) or JSON-serializable argument arrays. Unknown action IDs return 404 — there is no way to call a function that was not registered at startup.
|
|
343
|
+
|
|
344
|
+
### `"use client"` — Client-Only Components
|
|
345
|
+
|
|
346
|
+
Add `"use client"` to mark a component as browser-only. During server builds the module is stubbed to `null` to prevent browser API (`window`, `document`, `localStorage`) crashes.
|
|
347
|
+
|
|
348
|
+
```tsx
|
|
349
|
+
// app/components/Counter.tsx
|
|
350
|
+
"use client";
|
|
351
|
+
import { useState } from "react";
|
|
352
|
+
|
|
353
|
+
export function Counter() {
|
|
354
|
+
const [n, setN] = useState(0);
|
|
355
|
+
return <button onClick={() => setN(n + 1)}>Count: {n}</button>;
|
|
356
|
+
}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
## Middleware
|
|
362
|
+
|
|
363
|
+
Middleware runs before routing. Register on the module-level `pipeline` singleton.
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
import { pipeline, requestLogger, cors, authGuard } from "bractjs";
|
|
367
|
+
|
|
368
|
+
pipeline
|
|
369
|
+
.use(requestLogger())
|
|
370
|
+
.use(cors({ origin: "https://myapp.com" }))
|
|
371
|
+
.use(authGuard({ session }));
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
| Middleware | Description |
|
|
375
|
+
|---|---|
|
|
376
|
+
| `requestLogger()` | Logs method, path, status, duration |
|
|
377
|
+
| `cors(options)` | Sets CORS headers, handles `OPTIONS` preflight |
|
|
378
|
+
| `authGuard(options)` | Reads session, attaches `context.user`, returns 401 if unauthenticated |
|
|
379
|
+
|
|
380
|
+
**Custom middleware:**
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
import type { MiddlewareFn } from "bractjs";
|
|
384
|
+
|
|
385
|
+
const trace: MiddlewareFn = async (ctx, next) => {
|
|
386
|
+
ctx.context.requestId = crypto.randomUUID();
|
|
387
|
+
return next();
|
|
388
|
+
};
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`ctx.context` is threaded into every `loader` and `action` as the `context` argument.
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## Sessions
|
|
396
|
+
|
|
397
|
+
```ts
|
|
398
|
+
import { createCookieSession } from "bractjs";
|
|
399
|
+
|
|
400
|
+
const session = createCookieSession({
|
|
401
|
+
name: "__session",
|
|
402
|
+
secrets: [Bun.env.SESSION_SECRET], // rotate: prepend new secret, keep old ones
|
|
403
|
+
maxAge: 60 * 60 * 24 * 7, // 1 week
|
|
404
|
+
secure: true,
|
|
405
|
+
sameSite: "lax",
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
export async function loader({ request }: LoaderArgs) {
|
|
409
|
+
const s = await session.getSession(request.headers.get("Cookie"));
|
|
410
|
+
return { user: s.get("user") };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export async function action({ request }: ActionArgs) {
|
|
414
|
+
const s = await session.getSession(request.headers.get("Cookie"));
|
|
415
|
+
s.set("user", { id: 1, name: "Alice" });
|
|
416
|
+
return redirect("/dashboard", {
|
|
417
|
+
headers: { "Set-Cookie": await session.commitSession(s) },
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Cookies are signed with HMAC-SHA256 using `crypto.subtle`. Tampered cookies are silently rejected and return an empty session.
|
|
423
|
+
|
|
424
|
+
> Generate a secret: `openssl rand -base64 32`
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
## Environment Variables
|
|
429
|
+
|
|
430
|
+
| Convention | Behavior |
|
|
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
|
+
## Configuration Reference
|
|
444
|
+
|
|
445
|
+
All fields are optional. BractJS works with zero configuration.
|
|
446
|
+
|
|
447
|
+
| Field | Type | Default | Description |
|
|
448
|
+
|-------|------|---------|-------------|
|
|
449
|
+
| `port` | `number` | `3000` | TCP port |
|
|
450
|
+
| `appDir` | `string` | `"./app"` | Directory containing `routes/` and `root.tsx` |
|
|
451
|
+
| `publicDir` | `string` | `"./public"` | Static assets (served with no-cache) |
|
|
452
|
+
| `buildDir` | `string` | `"./build"` | Output for `bractjs build` |
|
|
453
|
+
| `imageCacheDir` | `string` | `".bract-image-cache"` | Disk cache for optimized images |
|
|
454
|
+
| `sourcemap` | `string` | `"external"` | `"none"` \| `"inline"` \| `"external"` |
|
|
455
|
+
| `minify` | `boolean` | `true` | Minify client bundles |
|
|
456
|
+
| `clientEnv` | `string[]` | `[]` | `process.env` keys exposed to the client |
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
## CLI
|
|
461
|
+
|
|
462
|
+
| Command | Description |
|
|
463
|
+
|---------|-------------|
|
|
464
|
+
| `bractjs new <name>` | Scaffold a new app into `<name>/` |
|
|
465
|
+
| `bractjs dev` | Start dev server with HMR on port 3000 |
|
|
466
|
+
| `bractjs build` | Dual server + client build with content-hashed output |
|
|
467
|
+
| `bractjs start` | Serve the production build |
|
|
468
|
+
| `bractjs codegen [app] [out]` | Generate typed route types into `app/route-types.gen.ts` |
|
|
469
|
+
|
|
470
|
+
---
|
|
471
|
+
|
|
472
|
+
## App Directory Structure
|
|
473
|
+
|
|
474
|
+
```
|
|
475
|
+
my-app/
|
|
476
|
+
├── app/
|
|
477
|
+
│ ├── root.tsx # required — <html> shell
|
|
478
|
+
│ ├── route-types.gen.ts # generated by bractjs codegen
|
|
479
|
+
│ ├── actions.ts # "use server" actions
|
|
480
|
+
│ └── routes/
|
|
481
|
+
│ ├── _index.tsx # → /
|
|
482
|
+
│ ├── about.tsx # → /about
|
|
483
|
+
│ ├── blog/
|
|
484
|
+
│ │ ├── layout.tsx # layout for /blog/*
|
|
485
|
+
│ │ ├── _index.tsx # → /blog
|
|
486
|
+
│ │ └── [id].tsx # → /blog/:id
|
|
487
|
+
│ └── docs/
|
|
488
|
+
│ └── [...slug].tsx # → /docs/*
|
|
489
|
+
├── public/
|
|
490
|
+
│ └── favicon.ico
|
|
491
|
+
└── build/ # generated — do not edit
|
|
492
|
+
├── server/
|
|
493
|
+
├── client/
|
|
494
|
+
└── route-manifest.json
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
## Architecture
|
|
500
|
+
|
|
501
|
+
```
|
|
502
|
+
Request
|
|
503
|
+
└─ Middleware pipeline
|
|
504
|
+
└─ /_action → Server Action registry → fn(...args)
|
|
505
|
+
└─ /_image → ImageMagick transform → LRU cache → Response
|
|
506
|
+
└─ Route trie (static > param > catch-all)
|
|
507
|
+
└─ Layout chain (root → layout → route)
|
|
508
|
+
└─ Parallel loaders (Promise.all)
|
|
509
|
+
└─ renderToReadableStream → streaming Response
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
Client:
|
|
513
|
+
```
|
|
514
|
+
hydrateRoot(document)
|
|
515
|
+
└─ ClientRouter (RouterContext + NavigationContext)
|
|
516
|
+
└─ Outlet → React.lazy route chunk
|
|
517
|
+
└─ useLoaderData / useParams / useNavigation / …
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Build pipeline (`bractjs build`):
|
|
521
|
+
```
|
|
522
|
+
1. codegen → app/route-types.gen.ts
|
|
523
|
+
2. server bundle → Bun.build (target: bun) + useClientStubPlugin
|
|
524
|
+
3. client bundle → Bun.build (target: browser, splitting) + useServerProxyPlugin
|
|
525
|
+
4. content-hash → rename outputs, write route-manifest.json
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
---
|
|
529
|
+
|
|
530
|
+
## Package Structure
|
|
531
|
+
|
|
532
|
+
```
|
|
533
|
+
bractjs/
|
|
534
|
+
├── src/
|
|
535
|
+
│ ├── server/ # SSR, routing, loaders, actions, sessions, action-registry
|
|
536
|
+
│ ├── client/ # hydrateRoot, contexts, hooks, Link/Form/Image components
|
|
537
|
+
│ ├── build/ # Bun.build orchestration, manifest, hashing, directives
|
|
538
|
+
│ ├── codegen/ # route-types.gen.ts generator
|
|
539
|
+
│ ├── image/ # /_image handler, ImageMagick optimizer, LRU cache
|
|
540
|
+
│ ├── dev/ # watcher, HMR server + client, error overlay
|
|
541
|
+
│ ├── shared/ # types, errors, deferred, context
|
|
542
|
+
│ └── middleware/ # requestLogger, cors, authGuard
|
|
543
|
+
├── bin/cli.ts
|
|
544
|
+
├── types/ # TypeScript declaration files
|
|
545
|
+
└── templates/
|
|
546
|
+
└── new-app/ # scaffold template
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
---
|
|
550
|
+
|
|
551
|
+
## Why BractJS
|
|
552
|
+
|
|
553
|
+
- **Bun-native** — `Bun.serve`, `Bun.build`, `Bun.file`, `Bun.Glob`, `Bun.watch`. No Node.js.
|
|
554
|
+
- **Zero framework deps** — only peer dependencies are `react` and `react-dom`.
|
|
555
|
+
- **Streaming SSR** — `renderToReadableStream()` with `defer()` for slow data.
|
|
556
|
+
- **File-based routing** — drop a file in `app/routes/`, it's a route.
|
|
557
|
+
- **Full-stack** — loaders, actions, sessions, server actions, and middleware in one package.
|
|
558
|
+
- **Typed routes** — codegen produces per-route param types and a type-safe route builder.
|
|
559
|
+
|
|
560
|
+
---
|
|
561
|
+
|
|
562
|
+
## Status
|
|
563
|
+
|
|
564
|
+
**v0.1.0 complete.** All core phases shipped:
|
|
565
|
+
|
|
566
|
+
- File-based routing with trie matcher and layout chains
|
|
567
|
+
- Streaming SSR (`renderToReadableStream`) with `defer()` and `<Await>`
|
|
568
|
+
- Client hydration, soft navigation, `popstate`, prefetch
|
|
569
|
+
- HMR with module-level swap (no full reload)
|
|
570
|
+
- Cookie sessions with HMAC-SHA256 and secret rotation
|
|
571
|
+
- Middleware pipeline with `requestLogger`, `cors`, `authGuard`
|
|
572
|
+
- Production build with content-hashed assets and code splitting
|
|
573
|
+
|
|
574
|
+
Post-v0.1.0 features also shipped:
|
|
575
|
+
|
|
576
|
+
- `<Image>` with on-demand ImageMagick optimization and LRU cache
|
|
577
|
+
- Typed routes codegen (`AppRoutes`, `RouteParams<T>`, `TypedLoaderArgs<T>`, `routes` builder)
|
|
578
|
+
- `"use server"` / `"use client"` directive system with `/_action` endpoint
|
|
579
|
+
|
|
580
|
+
Remaining on the roadmap: Edge runtime (Cloudflare Workers), CSS modules, i18n routing, streaming `useFetcher()`.
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
## License
|
|
585
|
+
|
|
586
|
+
MIT
|
package/bin/cli.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
export {}; // make this file a module
|
|
5
|
+
|
|
6
|
+
const command = process.argv[2];
|
|
7
|
+
|
|
8
|
+
// ── new <app-name> ──────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
async function scaffoldNew(appName: string): Promise<void> {
|
|
11
|
+
if (!appName) {
|
|
12
|
+
console.error("Usage: bractjs new <app-name>");
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const appDir = resolve(process.cwd(), appName);
|
|
17
|
+
if (existsSync(appDir)) {
|
|
18
|
+
console.error(`Directory "${appName}" already exists.`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const templateDir = join(import.meta.dirname, "../templates/new-app");
|
|
23
|
+
// Absolute path to the bractjs package itself — used as a file: dep before npm publish
|
|
24
|
+
const bractPackageDir = resolve(import.meta.dirname, "..");
|
|
25
|
+
console.log(`Creating ${appName}...`);
|
|
26
|
+
|
|
27
|
+
// Recursively copy template files, substituting {{APP_NAME}} and {{BRACT_PATH}}
|
|
28
|
+
await copyDir(templateDir, appDir, appName, bractPackageDir);
|
|
29
|
+
|
|
30
|
+
// Install dependencies
|
|
31
|
+
console.log("Installing dependencies...");
|
|
32
|
+
const result = Bun.spawnSync(["bun", "install"], { cwd: appDir, stdio: ["inherit", "inherit", "inherit"] });
|
|
33
|
+
if (result.exitCode !== 0) {
|
|
34
|
+
console.error("bun install failed.");
|
|
35
|
+
process.exit(result.exitCode ?? 1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log(`\n✓ Created ${appName}\n`);
|
|
39
|
+
console.log("Next steps:");
|
|
40
|
+
console.log(` cd ${appName}`);
|
|
41
|
+
console.log(" bun run dev");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function copyDir(src: string, dest: string, appName: string, bractPath: string): Promise<void> {
|
|
45
|
+
const glob = new Bun.Glob("**/*");
|
|
46
|
+
for await (const rel of glob.scan({ cwd: src, onlyFiles: true })) {
|
|
47
|
+
const srcPath = join(src, rel);
|
|
48
|
+
const destPath = join(dest, rel);
|
|
49
|
+
// Ensure parent directory exists
|
|
50
|
+
const parentDir = destPath.slice(0, destPath.lastIndexOf("/"));
|
|
51
|
+
await Bun.write(destPath, ""); // creates parent dirs
|
|
52
|
+
let content = await Bun.file(srcPath).text();
|
|
53
|
+
content = content.replaceAll("{{APP_NAME}}", appName);
|
|
54
|
+
content = content.replaceAll("{{BRACT_PATH}}", bractPath);
|
|
55
|
+
await Bun.write(destPath, content);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── dispatch ────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
switch (command) {
|
|
62
|
+
case "new":
|
|
63
|
+
await scaffoldNew(process.argv[3]);
|
|
64
|
+
break;
|
|
65
|
+
|
|
66
|
+
case "dev":
|
|
67
|
+
await import("../src/dev/server.ts");
|
|
68
|
+
break;
|
|
69
|
+
|
|
70
|
+
case "build": {
|
|
71
|
+
const { runBuild } = await import("../src/build/bundler.ts");
|
|
72
|
+
await runBuild({ port: 3000, appDir: "./app", publicDir: "./public", buildDir: "./build", manifest: { clientEntry: "", routes: {} } });
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case "start": {
|
|
77
|
+
const { createServer } = await import("../src/server/serve.ts");
|
|
78
|
+
createServer({ port: 3000, buildDir: "./build" });
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case "codegen": {
|
|
83
|
+
const { writeRouteTypes } = await import("../src/codegen/route-codegen.ts");
|
|
84
|
+
const appDir = resolve(process.cwd(), process.argv[3] ?? "./app");
|
|
85
|
+
const outPath = process.argv[4] ? resolve(process.cwd(), process.argv[4]) : undefined;
|
|
86
|
+
await writeRouteTypes(appDir, outPath);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
default:
|
|
91
|
+
console.log(
|
|
92
|
+
"Usage: bractjs <command>\n" +
|
|
93
|
+
" new <app-name> Scaffold a new BractJS app\n" +
|
|
94
|
+
" dev Start dev server with HMR\n" +
|
|
95
|
+
" build Build for production\n" +
|
|
96
|
+
" start Start production server\n" +
|
|
97
|
+
" codegen [app] [out] Generate typed route types",
|
|
98
|
+
);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|