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