@bractjs/bractjs 0.1.28 → 0.2.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/README.md +98 -17
- package/package.json +8 -7
- package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
- package/src/__tests__/headers.test.ts +111 -0
- package/src/__tests__/integration.test.ts +34 -0
- package/src/__tests__/layout-registry.test.ts +7 -3
- package/src/__tests__/matcher.test.ts +29 -0
- package/src/__tests__/module-registry.test.ts +2 -3
- package/src/__tests__/route-lint.test.ts +5 -0
- package/src/__tests__/route-middleware.test.ts +84 -0
- package/src/__tests__/scanner.test.ts +46 -1
- package/src/__tests__/security-fixes.test.ts +201 -0
- package/src/__tests__/use-matches.test.ts +54 -0
- package/src/build/route-lint.ts +3 -3
- package/src/client/ClientRouter.tsx +118 -18
- package/src/client/hooks/useMatches.ts +32 -0
- package/src/client/router.tsx +7 -1
- package/src/client/rpc.ts +11 -1
- package/src/codegen/module-registry.ts +13 -21
- package/src/codegen/route-codegen.ts +8 -3
- package/src/config/load.ts +1 -0
- package/src/index.ts +11 -3
- package/src/server/action-handler.ts +1 -20
- package/src/server/adapter.ts +16 -0
- package/src/server/api-route.ts +47 -0
- package/src/server/csp.ts +9 -3
- package/src/server/csrf.ts +10 -3
- package/src/server/headers.ts +49 -0
- package/src/server/layout.ts +12 -19
- package/src/server/matcher.ts +29 -2
- package/src/server/matches.ts +50 -0
- package/src/server/middleware.ts +66 -0
- package/src/server/proto-guard.ts +56 -0
- package/src/server/render.ts +34 -16
- package/src/server/request-handler.ts +67 -27
- package/src/server/scanner.ts +45 -3
- package/src/server/search.ts +5 -1
- package/src/server/serve.ts +28 -3
- package/src/server/session.ts +12 -1
- package/src/server/validate.ts +4 -1
- package/src/shared/context.ts +3 -1
- package/src/shared/route-types.ts +108 -0
- package/types/config.d.ts +3 -0
- package/types/index.d.ts +17 -0
- package/types/route.d.ts +76 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
- [Bun](https://bun.sh) ≥ 1.1 — no Node.js support
|
|
12
12
|
- React 19 (peer dependency)
|
|
13
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)).
|
|
14
|
+
This README is a **step-by-step guide to every function and feature** BractJS exports. Each section is self-contained and ordered from "first app" to "advanced". Every symbol shown here is a real export from `@bractjs/bractjs` (see [packages/core/src/index.ts](packages/core/src/index.ts)).
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
@@ -154,13 +154,17 @@ Drop a file in `app/routes/`; it becomes a route. BractJS scans at startup and b
|
|
|
154
154
|
| `routes/about.tsx` | `/about` |
|
|
155
155
|
| `routes/blog/_index.tsx` | `/blog` |
|
|
156
156
|
| `routes/blog/[id].tsx` | `/blog/:id` |
|
|
157
|
+
| `routes/users/[[id]].tsx` | `/users` **and** `/users/:id` (optional) |
|
|
157
158
|
| `routes/docs/[...slug].tsx` | `/docs/*` (catch-all) |
|
|
158
159
|
| `routes/blog/layout.tsx` | wraps all `/blog/*` routes |
|
|
160
|
+
| `routes/(marketing)/about.tsx` | `/about` (group adds no URL segment) |
|
|
159
161
|
|
|
160
162
|
- `[param]` → a dynamic segment, read via `useParams()` / `params` arg.
|
|
163
|
+
- `[[param]]` → an **optional** dynamic segment: the route matches whether the segment is present or not (when absent, `params.param` is simply unset).
|
|
161
164
|
- `[...name]` → a catch-all; the rest of the path lands in `params.name`.
|
|
162
165
|
- `layout.tsx` in any directory wraps every route under it (layouts nest: `root → blog/layout → blog/[id]`).
|
|
163
|
-
-
|
|
166
|
+
- `(group)/` → a **route group**: the folder organizes files and contributes its `layout.tsx`, but adds **no** URL segment. Use it to give a set of routes a shared layout without a shared path prefix.
|
|
167
|
+
- Match priority per segment: **static > dynamic > optional > catch-all**.
|
|
164
168
|
|
|
165
169
|
No registration step — the file IS the route.
|
|
166
170
|
|
|
@@ -171,7 +175,7 @@ No registration step — the file IS the route.
|
|
|
171
175
|
Every file in `app/routes/` (and `root.tsx`/`layout.tsx`) may export any combination of these. Import the arg types from the package.
|
|
172
176
|
|
|
173
177
|
```tsx
|
|
174
|
-
import type { LoaderArgs, ActionArgs, MetaArgs } from "@bractjs/bractjs";
|
|
178
|
+
import type { LoaderArgs, ActionArgs, MetaArgs, HeadersArgs } from "@bractjs/bractjs";
|
|
175
179
|
import { redirect, json, HttpError } from "@bractjs/bractjs";
|
|
176
180
|
|
|
177
181
|
// 1) loader — runs on every GET. Return value → useLoaderData().
|
|
@@ -242,7 +246,40 @@ export function Fallback() {
|
|
|
242
246
|
return <p>Loading dashboard…</p>; // SSR'd in the component's place
|
|
243
247
|
}
|
|
244
248
|
|
|
245
|
-
// 9)
|
|
249
|
+
// 9) headers — set response headers (Cache-Control / ETag / Vary / CDN hints)
|
|
250
|
+
// for this route's document AND /_data responses. Runs root → layout →
|
|
251
|
+
// route; innermost wins per key, and you receive the merged parentHeaders.
|
|
252
|
+
export function headers({ loaderData, parentHeaders }: HeadersArgs<LoaderData>) {
|
|
253
|
+
return { "Cache-Control": "public, max-age=300, s-maxage=3600" };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 10) middleware — nested, server-side. Runs root → layout → route BEFORE
|
|
257
|
+
// beforeLoad/action/loaders, with a shared mutable `context`. Return a
|
|
258
|
+
// Response to short-circuit (a cleaner per-route alternative to beforeLoad,
|
|
259
|
+
// and to a single global pipeline). Protects the document and /_data.
|
|
260
|
+
export const middleware = [
|
|
261
|
+
async (ctx, next) => {
|
|
262
|
+
if (!ctx.context.user) return redirect("/login");
|
|
263
|
+
ctx.context.startedAt = Date.now(); // visible to loaders
|
|
264
|
+
return next();
|
|
265
|
+
},
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
// 11) clientLoader / clientAction — RR7-style browser-side data. clientLoader
|
|
269
|
+
// runs on navigation and its result becomes useLoaderData(); call
|
|
270
|
+
// serverLoader() for this route's server data. Set clientLoader.hydrate =
|
|
271
|
+
// true to also run on the first hydration of an SSR'd document.
|
|
272
|
+
export async function clientLoader({ serverLoader }) {
|
|
273
|
+
const server = await serverLoader(); // the normal /_data route slice
|
|
274
|
+
return { ...server, fetchedAt: Date.now() };
|
|
275
|
+
}
|
|
276
|
+
// clientLoader.hydrate = true; // opt into running on hydration
|
|
277
|
+
export async function clientAction({ formData, serverAction }) {
|
|
278
|
+
// optimistic local work, then defer to the server action:
|
|
279
|
+
return serverAction();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 12) default — the page component (required for a renderable route).
|
|
246
283
|
export default function BlogPost() {
|
|
247
284
|
const { post } = useLoaderData<LoaderData>();
|
|
248
285
|
return <article><h1>{post.title}</h1></article>;
|
|
@@ -252,9 +289,10 @@ export default function BlogPost() {
|
|
|
252
289
|
**Execution order for a request:**
|
|
253
290
|
|
|
254
291
|
```
|
|
255
|
-
searchSchema → beforeLoad → (action, if mutating method) → loaders (root + layouts + route, in parallel) → render
|
|
292
|
+
global pipeline → searchSchema → route middleware (root → layout → route) → beforeLoad → (action, if mutating method) → loaders (root + layouts + route, in parallel) → render
|
|
256
293
|
```
|
|
257
294
|
|
|
295
|
+
- **Route middleware** wraps everything after search validation: it runs in chain order with a shared `context`, can short-circuit with a `Response`, and (being outermost-first) can also post-process the final response. It runs inside the app-wide `pipeline` (§14).
|
|
258
296
|
- **Loaders run concurrently** (root, every layout, and the route loader all in one `Promise.all`).
|
|
259
297
|
- A loader that throws an `HttpError`/redirect `Response` is intentional control flow. Any *other* thrown error is caught, sanitized (generic message in production, full message+stack only when `NODE_ENV=development`), and rendered via the nearest `ErrorBoundary`.
|
|
260
298
|
|
|
@@ -431,6 +469,19 @@ const { id } = useParams<{ id: string }>(); // or a hand-written shape
|
|
|
431
469
|
```
|
|
432
470
|
> The pattern is supplied by the caller because the framework can't infer the active route at the type level (React Router's `useParams` works the same way).
|
|
433
471
|
|
|
472
|
+
### `useMatches()` → `RouteMatch[]`
|
|
473
|
+
The matched route chain, **outermost → innermost** (root, layouts, then the leaf route). Each entry is `{ id, pathname, params, data, handle }`, where `handle` is that module's static `handle` export. Ideal for breadcrumbs and conditional chrome without threading props through every layout. SSR-safe; updates on soft navigation and revalidation.
|
|
474
|
+
```tsx
|
|
475
|
+
// routes/blog/[id].tsx
|
|
476
|
+
export const handle = { breadcrumb: "Post" };
|
|
477
|
+
|
|
478
|
+
// a layout
|
|
479
|
+
const crumbs = useMatches()
|
|
480
|
+
.filter((m) => m.handle?.breadcrumb)
|
|
481
|
+
.map((m) => m.handle!.breadcrumb as string);
|
|
482
|
+
```
|
|
483
|
+
> `handle` travels in the SSR bootstrap and the `/_data` payload, so it must be JSON-serializable (same constraint as loader data).
|
|
484
|
+
|
|
434
485
|
### `useLocation()` → `{ pathname, search, hash, state, key }`
|
|
435
486
|
The current location — reactive on the client, request-derived during SSR (`hash` is always `""` there). `key` is the history entry's identity (what scroll restoration uses); `state` is whatever you passed via `navigate(to, { state })`.
|
|
436
487
|
```ts
|
|
@@ -659,12 +710,17 @@ export const listUsers = route("GET", "/api/users", async () => {
|
|
|
659
710
|
export const createUser = route("POST", "/api/users", async (input: { name: string }) => {
|
|
660
711
|
return db.users.create(input);
|
|
661
712
|
});
|
|
713
|
+
|
|
714
|
+
// Public, credential-free endpoint (e.g. a webhook): opt out of CSRF.
|
|
715
|
+
export const webhook = route("POST", "/api/webhook", handleWebhook, { csrf: false });
|
|
662
716
|
```
|
|
663
717
|
|
|
664
718
|
- `GET`/`DELETE`: no body parsed. `POST`/`PUT`/`PATCH`: JSON or form body parsed into `input`.
|
|
665
|
-
- Bodies are capped at 1 MiB. Errors return a generic 500 in production (full message in dev).
|
|
719
|
+
- Bodies are capped at 1 MiB. JSON bodies carrying prototype-pollution keys (`__proto__`/`constructor`/`prototype`) are rejected (400). Errors return a generic 500 in production (full message in dev).
|
|
666
720
|
- Handlers also receive the raw `Request` as the 2nd arg.
|
|
667
721
|
- `:param` segments match any non-empty value; **read and validate params from `request.url` yourself** (they aren't injected into `input`).
|
|
722
|
+
- **CSRF — on by default for mutating methods** (`POST`/`PUT`/`PATCH`/`DELETE`): the request must prove same-origin (via `Sec-Fetch-Site`, the `X-BractJS-Action` header `createClient` sends automatically, or a matching `Origin`), exactly like server actions. Cross-site requests get `403`. Pass `{ csrf: false }` **only** for endpoints that don't trust ambient credentials (session cookies / Basic auth) and are meant to be called cross-site (webhooks, token-authenticated or public APIs).
|
|
723
|
+
- Global `pipeline` middleware (`cors`, `csp`, `authGuard`, custom) applies to `/api` responses too — see §14.
|
|
668
724
|
|
|
669
725
|
### Call them with `createClient<AppApiRoutes>()`
|
|
670
726
|
|
|
@@ -726,7 +782,7 @@ If you prefer to keep calling `validate()` and catching: `isValidationResponse(e
|
|
|
726
782
|
|
|
727
783
|
## 14. Middleware
|
|
728
784
|
|
|
729
|
-
|
|
785
|
+
The module-level `pipeline` singleton wraps the **entire** request — every response flows through it, including typed `/api` routes, server actions (`/_action`, `/_stream`), the image endpoint (`/_image`), static assets, and SSR documents. So `cors()`, `csp()`, `authGuard()`, a rate limiter, or your own logging applies uniformly, not just to page renders. Each middleware gets `(ctx, next)` and returns a `Response`; `ctx.context` is threaded into every loader/action and into nested route middleware.
|
|
730
786
|
|
|
731
787
|
```ts
|
|
732
788
|
import { pipeline, requestLogger, cors, authGuard, csp } from "@bractjs/bractjs";
|
|
@@ -768,7 +824,7 @@ pipeline.use(csp({
|
|
|
768
824
|
```
|
|
769
825
|
Read the nonce inside a component/middleware with `getCspNonce(context)` (key: `CSP_NONCE_KEY`) to nonce your own inline scripts.
|
|
770
826
|
|
|
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.
|
|
827
|
+
`script-src` is always nonce-based (`'nonce-…' 'strict-dynamic'`), so injected `<script>` cannot execute. Note that with `'strict-dynamic'`, supporting browsers **ignore** the `'self'`/host expressions in `script-src` — trust flows solely through the nonce and the scripts it loads (the `'self'` is kept only as a fallback for older browsers). The default policy also sets `form-action 'self'` (a `<form>` can only submit same-origin), `base-uri 'self'`, `frame-ancestors 'self'`, and `object-src 'none'`. The default `style-src` allows `'unsafe-inline'` for ergonomics (React inline styles, CSS-in-JS); this leaves inline-style injection possible. Pass `strict: true` (or override `style-src` with a nonce/hash) if your app serves all styles from same-origin stylesheets.
|
|
772
828
|
|
|
773
829
|
### Custom middleware
|
|
774
830
|
|
|
@@ -786,6 +842,27 @@ pipeline.use(trace);
|
|
|
786
842
|
|
|
787
843
|
You can also construct an isolated `new MiddlewarePipeline()` and `.run(ctx, handler)` it yourself (used internally and in tests).
|
|
788
844
|
|
|
845
|
+
### Nested route middleware
|
|
846
|
+
|
|
847
|
+
The global `pipeline` is app-wide. For middleware scoped to a branch of the route tree, export `middleware` from a route, `layout.tsx`, or `root.tsx` — a single function or an array. It runs **inside** the global pipeline, in chain order (root → layout → route), before `beforeLoad`/action/loaders, sharing the same mutable `context`. Return a `Response` to short-circuit; because the chain is outermost-first, an ancestor can also post-process the response.
|
|
848
|
+
|
|
849
|
+
```ts
|
|
850
|
+
// routes/admin/layout.tsx — gates every /admin/* route
|
|
851
|
+
import type { RouteMiddlewareFunction } from "@bractjs/bractjs";
|
|
852
|
+
import { redirect } from "@bractjs/bractjs";
|
|
853
|
+
|
|
854
|
+
const requireAdmin: RouteMiddlewareFunction = async (ctx, next) => {
|
|
855
|
+
if (!(ctx.context.user as { admin?: boolean } | undefined)?.admin) {
|
|
856
|
+
return redirect("/login");
|
|
857
|
+
}
|
|
858
|
+
return next(); // continue to child layouts/route + loaders
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
export const middleware = [requireAdmin];
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
It protects the **document and the `/_data` soft-nav endpoint** alike, so it's a safe place for auth — same guarantee as `beforeLoad`. Prefer route middleware over `beforeLoad` when you want composition (multiple concerns, shared across a folder) or response post-processing; `beforeLoad` remains the lighter single-gate option.
|
|
865
|
+
|
|
789
866
|
---
|
|
790
867
|
|
|
791
868
|
## 15. Sessions
|
|
@@ -1169,7 +1246,7 @@ bun build --compile app/server.ts \ # D — single binary
|
|
|
1169
1246
|
|
|
1170
1247
|
The codegen functions are exported: `writeModuleRegistries(appDir)`, `writeManifestModule(appDir, buildDir)`, and the lower-level `generateRouteRegistry` / `generateActionRegistry` / `generateManifestModule`.
|
|
1171
1248
|
|
|
1172
|
-
> **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).
|
|
1249
|
+
> **Contributor note — keep the binary working:** anything on the server request/startup path must avoid runtime `Bun.Glob`/computed-path `import()`, must fall back from `realpath()` to `Bun.file().exists()` for embedded assets, and must preserve every consumed export when projecting route modules. Two tests enforce this: `packages/core/src/__tests__/compile-safety.test.ts` (fast static scan) and `packages/core/src/__tests__/compile-smoke.test.ts` (compiles and boots a real binary).
|
|
1173
1250
|
|
|
1174
1251
|
---
|
|
1175
1252
|
|
|
@@ -1245,6 +1322,7 @@ export default defineConfig({ port: 3000, clientEnv: ["PUBLIC_API_URL"] });
|
|
|
1245
1322
|
| `publicDir` | `string` | `"./public"` | Static assets (served no-cache) |
|
|
1246
1323
|
| `buildDir` | `string` | `"./build"` | Build output |
|
|
1247
1324
|
| `imageCacheDir` | `string` | `".bract-image-cache"` | Optimized-image disk cache |
|
|
1325
|
+
| `maxRequestBodySize` | `number` | `16777216` (16 MiB) | Hard ceiling on any request body, enforced by the Bun adapter (§27) |
|
|
1248
1326
|
| `sourcemap` | `string` | `"external"` | `"none" \| "linked" \| "inline" \| "external"` |
|
|
1249
1327
|
| `minify` | `boolean` | `true` | Minify client bundles |
|
|
1250
1328
|
| `clientEnv` | `string[]` | `[]` | `process.env` keys exposed to the client |
|
|
@@ -1261,9 +1339,9 @@ export default defineConfig({ port: 3000, clientEnv: ["PUBLIC_API_URL"] });
|
|
|
1261
1339
|
|
|
1262
1340
|
## 26. Full export index
|
|
1263
1341
|
|
|
1264
|
-
Everything importable from `@bractjs/bractjs` ([src/index.ts](src/index.ts)):
|
|
1342
|
+
Everything importable from `@bractjs/bractjs` ([packages/core/src/index.ts](packages/core/src/index.ts)):
|
|
1265
1343
|
|
|
1266
|
-
**Server / runtime:** `createServer`, `buildFetchHandler`, `renderRoute`, `redirect`, `json`, `error`, `defineContext`, `route`, `validate`, `safeValidate`, `isValidationResponse`, `readValidationError`, `validateSearch`, `searchParamsToObject`, `formText`, `formValues`, `defineActions`, `BunAdapter`, `defineLifecycle`, `renderSpaShell`
|
|
1344
|
+
**Server / runtime:** `createServer`, `buildFetchHandler`, `renderRoute`, `redirect`, `json`, `error`, `defineContext`, `route`, `validate`, `safeValidate`, `isValidationResponse`, `readValidationError`, `validateSearch`, `searchParamsToObject`, `hasForbiddenKey`, `nullProtoFromEntries`, `formText`, `formValues`, `defineActions`, `BunAdapter`, `defineLifecycle`, `renderSpaShell`
|
|
1267
1345
|
|
|
1268
1346
|
**Errors:** `BractJSError`, `HttpError`, `isRedirect`, `isHttpError`, `isBractJSError`
|
|
1269
1347
|
|
|
@@ -1271,13 +1349,13 @@ Everything importable from `@bractjs/bractjs` ([src/index.ts](src/index.ts)):
|
|
|
1271
1349
|
|
|
1272
1350
|
**Context:** `BractJSContext`, `BractJSProvider`, `useBractJSContext`
|
|
1273
1351
|
|
|
1274
|
-
**Middleware:** `pipeline`, `MiddlewarePipeline`, `requestLogger`, `cors`, `authGuard`, `csp`, `getCspNonce`, `CSP_NONCE_KEY`
|
|
1352
|
+
**Middleware:** `pipeline`, `MiddlewarePipeline`, `runRouteMiddleware`, `collectRouteMiddleware`, `requestLogger`, `cors`, `authGuard`, `csp`, `getCspNonce`, `CSP_NONCE_KEY`
|
|
1275
1353
|
|
|
1276
1354
|
**Sessions:** `createCookieSession`
|
|
1277
1355
|
|
|
1278
1356
|
**Components:** `Outlet`, `Link`, `Form`, `Scripts`, `LiveReload`, `Await`, `Image`, `ScrollRestoration`
|
|
1279
1357
|
|
|
1280
|
-
**Hooks:** `useLoaderData`, `useActionData`, `useLocation`, `useParams`, `useNavigation`, `useNavigate`, `useFetcher`, `useFetchers`, `useRevalidator`, `useSearch`, `useSetSearch`, `useSearchParams`, `useBlocker`, `useLocale`, `useLocalizedLink`
|
|
1358
|
+
**Hooks:** `useLoaderData`, `useActionData`, `useLocation`, `useParams`, `useMatches`, `useNavigation`, `useNavigate`, `useFetcher`, `useFetchers`, `useRevalidator`, `useSearch`, `useSetSearch`, `useSearchParams`, `useBlocker`, `useLocale`, `useLocalizedLink`
|
|
1281
1359
|
|
|
1282
1360
|
**Search serialization:** `serializeSearch`
|
|
1283
1361
|
|
|
@@ -1293,7 +1371,7 @@ Everything importable from `@bractjs/bractjs` ([src/index.ts](src/index.ts)):
|
|
|
1293
1371
|
|
|
1294
1372
|
**Adapters:** `createCloudflareAdapter`, `makeCloudflareHandler`
|
|
1295
1373
|
|
|
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`
|
|
1374
|
+
**Types:** `LoaderArgs`, `ActionArgs`, `MetaArgs`, `MetaDescriptor`, `LoaderFunction`, `ActionFunction`, `MetaFunction`, `RouteModule`, `RouteDefinition`, `RouteFile`, `Segment`, `RouterLocation`, `ShouldRevalidateArgs`, `ShouldRevalidateFunction`, `BractJSConfig`, `RenderOptions`, `ServerManifest`, `ContextFactory`, `ApiRouteDefinition`, `ApiRouteOptions`, `AppApiRoutes`, `FieldErrors`, `ValidationError`, `BractAdapter`, `LifecycleHooks`, `MiddlewareFn`, `MiddlewareContext`, `CorsOptions`, `AuthGuardOptions`, `CspOptions`, `SessionStorageLike`, `SessionLike`, `Session`, `SessionStorage`, `SessionData`, `CookieSessionOptions`, `CommitOptions`, `ImageProps`, `ImageFormat`, `ImageFit`, `SearchParamsResult`, `SetSearchFn`, `SetSearchOptions`, `SearchOutputFor`, `InferSchemaOutput`, `LoaderData`, `ActionData`, `SafeValidateResult`, `FetcherResult`, `FetcherEntry`, `FetcherState`, `FetcherFormProps`, `UseFetcherOptions`, `Revalidator`, `ScrollRestorationProps`, `PrerenderOptions`, `PrerenderResult`, `I18nConfig`, `DevServerOptions`, `DevServer`, `BuildConfig`, `CodegenResult`, `ModuleRegistry`, `BractJSContextValue`, `RouteManifest`
|
|
1297
1375
|
|
|
1298
1376
|
---
|
|
1299
1377
|
|
|
@@ -1303,10 +1381,13 @@ BractJS ships secure defaults, but a few behaviors are worth understanding so yo
|
|
|
1303
1381
|
|
|
1304
1382
|
- **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
1383
|
- **`/_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
|
-
- **
|
|
1307
|
-
- **
|
|
1384
|
+
- **Typed `/api` routes are CSRF-protected by default.** Mutating routes (`POST`/`PUT`/`PATCH`/`DELETE`) require a same-origin proof just like server actions; cross-site requests get `403`. Opt out with `route(..., { csrf: false })` **only** for endpoints that don't trust ambient credentials (webhooks, token-authenticated/public APIs). As with actions, the CSRF gate is not authentication — authorize inside the handler.
|
|
1385
|
+
- **Global middleware covers every endpoint.** Anything attached to `pipeline.use(...)` — `cors()`, `csp()`, `authGuard()`, a rate limiter, custom logging — runs for typed `/api` routes, `/_action`, `/_stream`, `/_image`, static assets, and SSR documents alike. (This was previously SSR-only; a cross-cutting guard you register globally now actually applies to your API surface.)
|
|
1386
|
+
- **CORS + credentials.** Listing an origin in `cors({ origin: [...], credentials: true })` fully trusts that origin for credentialed cross-origin reads. Only list origins you control. `credentials:true` with `origin:"*"` is refused at setup. **Never add `X-BractJS-Action` to `Access-Control-Allow-Headers`** — it is part of the CSRF gate; the built-in `cors()` deliberately omits it. If you write your own CORS layer and expose that header cross-origin, you defeat CSRF on both actions and `/api`; add a cryptographic double-submit token if you must. The header-based gate also assumes browsers send `Sec-Fetch-Site` — behind a proxy that strips it, rely on same-origin `Origin` (which `cors()` does not weaken).
|
|
1387
|
+
- **Error messages.** In production, loader/action/api errors are surfaced to the client as a generic message; the real message + stack appear only in dev (`NODE_ENV=development`). For user-facing structured errors throw an `HttpError` (its message *is* shown) — never put secrets in a raw `Error.message`.
|
|
1308
1388
|
- **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
|
-
- **
|
|
1389
|
+
- **Request body size.** Beyond the per-handler caps (1 MiB JSON for actions/api, 10 MiB route forms), the Bun adapter enforces a hard `maxRequestBodySize` ceiling (default 16 MiB) so no path can stream an unbounded body into memory. Raise it via the `maxRequestBodySize` config for a dedicated large-upload endpoint.
|
|
1390
|
+
- **Already handled for you:** path traversal + symlink escape on `/public` and `/_image`, open-redirect neutralization (`redirect()` requires `{ allowExternal: true }` to go off-origin), XSS-safe SSR data island (`safeStringify`), prototype-pollution rejection on action **and** `/api` JSON bodies plus null-prototype objects for form/search inputs, request body-size caps + global backstop, signed/constant-time-verified cookie sessions, CSP defaults (`script-src` nonce + `strict-dynamic`, `form-action`/`base-uri`/`frame-ancestors` `'self'`, `object-src 'none'`), and CSRF via layered `Sec-Fetch-Site` + custom-header + `Origin` checks across actions, `/_stream`, route mutations, and `/api`.
|
|
1310
1391
|
|
|
1311
1392
|
---
|
|
1312
1393
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bractjs/bractjs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -42,11 +42,6 @@
|
|
|
42
42
|
"default": "./src/index.ts"
|
|
43
43
|
}
|
|
44
44
|
},
|
|
45
|
-
"scripts": {
|
|
46
|
-
"dev": "bun run src/dev/server.ts",
|
|
47
|
-
"build": "bun run src/build/bundler.ts",
|
|
48
|
-
"test": "bun test"
|
|
49
|
-
},
|
|
50
45
|
"peerDependencies": {
|
|
51
46
|
"react": "^19",
|
|
52
47
|
"react-dom": "^19"
|
|
@@ -57,5 +52,11 @@
|
|
|
57
52
|
"@types/react-dom": "^19",
|
|
58
53
|
"react": "^19",
|
|
59
54
|
"react-dom": "^19"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"dev": "bun run src/dev/server.ts",
|
|
58
|
+
"build": "bun run src/build/bundler.ts",
|
|
59
|
+
"test": "bun test",
|
|
60
|
+
"typecheck": "bunx tsc --noEmit"
|
|
60
61
|
}
|
|
61
|
-
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Fixture exercising the Phase 1/2/4 route exports end-to-end:
|
|
2
|
+
// - `headers` → Cache-Control on the document + /_data response
|
|
3
|
+
// - `handle` → surfaced via useMatches() (asserted from the payload)
|
|
4
|
+
// - `middleware` → sets context (read by the loader) and stamps a header
|
|
5
|
+
import type { RouteMiddlewareFunction, HeadersArgs } from "../../../../shared/route-types.ts";
|
|
6
|
+
|
|
7
|
+
const setUser: RouteMiddlewareFunction = async (ctx, next) => {
|
|
8
|
+
ctx.context.demoUser = "alice";
|
|
9
|
+
const res = await next();
|
|
10
|
+
res.headers.set("X-Demo-Mw", "1");
|
|
11
|
+
return res;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const middleware = [setUser];
|
|
15
|
+
|
|
16
|
+
export const handle = { breadcrumb: "Features" };
|
|
17
|
+
|
|
18
|
+
export function loader({ context }: { context: Record<string, unknown> }) {
|
|
19
|
+
return { user: context.demoUser ?? null };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function headers(_args: HeadersArgs) {
|
|
23
|
+
return { "Cache-Control": "public, max-age=120" };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function FeaturesDemo() {
|
|
27
|
+
return <p>features</p>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { resolveHeaders, applyRouteHeaders } from "../server/headers.ts";
|
|
3
|
+
import type { LayoutChain } from "../server/layout.ts";
|
|
4
|
+
import type { LoaderResults } from "../server/loader.ts";
|
|
5
|
+
import type { HeadersFunction } from "../shared/route-types.ts";
|
|
6
|
+
|
|
7
|
+
const params = {};
|
|
8
|
+
const req = new Request("http://localhost/");
|
|
9
|
+
|
|
10
|
+
function chain(parts: {
|
|
11
|
+
root?: HeadersFunction;
|
|
12
|
+
layouts?: (HeadersFunction | undefined)[];
|
|
13
|
+
route?: HeadersFunction;
|
|
14
|
+
}): LayoutChain {
|
|
15
|
+
return {
|
|
16
|
+
root: parts.root ? { headers: parts.root } : {},
|
|
17
|
+
layouts: (parts.layouts ?? []).map((h) => (h ? { headers: h } : {})),
|
|
18
|
+
route: parts.route ? { headers: parts.route } : {},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function results(over: Partial<LoaderResults> = {}): LoaderResults {
|
|
23
|
+
return { root: null, layouts: [], route: null, ...over };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("resolveHeaders", () => {
|
|
27
|
+
test("returns null when no module exports headers", () => {
|
|
28
|
+
expect(resolveHeaders(chain({}), results(), params, req)).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("collects a single route's headers", () => {
|
|
32
|
+
const merged = resolveHeaders(
|
|
33
|
+
chain({ route: () => ({ "Cache-Control": "max-age=60" }) }),
|
|
34
|
+
results(),
|
|
35
|
+
params,
|
|
36
|
+
req,
|
|
37
|
+
);
|
|
38
|
+
expect(merged?.get("Cache-Control")).toBe("max-age=60");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("innermost route wins per key (route over root)", () => {
|
|
42
|
+
const merged = resolveHeaders(
|
|
43
|
+
chain({
|
|
44
|
+
root: () => ({ "Cache-Control": "max-age=0", Vary: "Cookie" }),
|
|
45
|
+
route: () => ({ "Cache-Control": "max-age=300" }),
|
|
46
|
+
}),
|
|
47
|
+
results(),
|
|
48
|
+
params,
|
|
49
|
+
req,
|
|
50
|
+
);
|
|
51
|
+
expect(merged?.get("Cache-Control")).toBe("max-age=300");
|
|
52
|
+
// Root-only key survives.
|
|
53
|
+
expect(merged?.get("Vary")).toBe("Cookie");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("parentHeaders carries accumulated values to inner links", () => {
|
|
57
|
+
let seen: string | null = "unset";
|
|
58
|
+
const merged = resolveHeaders(
|
|
59
|
+
chain({
|
|
60
|
+
root: () => ({ "X-From-Root": "1" }),
|
|
61
|
+
route: ({ parentHeaders }) => {
|
|
62
|
+
seen = parentHeaders.get("X-From-Root");
|
|
63
|
+
return {};
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
results(),
|
|
67
|
+
params,
|
|
68
|
+
req,
|
|
69
|
+
);
|
|
70
|
+
expect(seen).toBe("1");
|
|
71
|
+
expect(merged?.get("X-From-Root")).toBe("1");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("passes the matching loaderData slice to each link", () => {
|
|
75
|
+
const merged = resolveHeaders(
|
|
76
|
+
chain({ route: ({ loaderData }) => ({ ETag: String((loaderData as { etag: string }).etag) }) }),
|
|
77
|
+
results({ route: { etag: "abc" } }),
|
|
78
|
+
params,
|
|
79
|
+
req,
|
|
80
|
+
);
|
|
81
|
+
expect(merged?.get("ETag")).toBe("abc");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("layout headers merge between root and route", () => {
|
|
85
|
+
const merged = resolveHeaders(
|
|
86
|
+
chain({
|
|
87
|
+
root: () => ({ "Cache-Control": "max-age=0" }),
|
|
88
|
+
layouts: [() => ({ "Cache-Control": "max-age=10", Vary: "Accept" })],
|
|
89
|
+
route: () => ({ "Cache-Control": "max-age=60" }),
|
|
90
|
+
}),
|
|
91
|
+
results({ layouts: [null] }),
|
|
92
|
+
params,
|
|
93
|
+
req,
|
|
94
|
+
);
|
|
95
|
+
expect(merged?.get("Cache-Control")).toBe("max-age=60");
|
|
96
|
+
expect(merged?.get("Vary")).toBe("Accept");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("applyRouteHeaders", () => {
|
|
101
|
+
test("overrides same-key defaults and is a no-op for null", () => {
|
|
102
|
+
const base = new Headers({ "Cache-Control": "no-store", "X-Base": "1" });
|
|
103
|
+
applyRouteHeaders(base, new Headers({ "Cache-Control": "max-age=60" }));
|
|
104
|
+
expect(base.get("Cache-Control")).toBe("max-age=60");
|
|
105
|
+
expect(base.get("X-Base")).toBe("1");
|
|
106
|
+
|
|
107
|
+
const base2 = new Headers({ "X-Base": "1" });
|
|
108
|
+
applyRouteHeaders(base2, null);
|
|
109
|
+
expect(base2.get("X-Base")).toBe("1");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -182,3 +182,37 @@ test("/_data of a beforeLoad-gated route is blocked and never leaks loader data"
|
|
|
182
182
|
const body = await res.text();
|
|
183
183
|
expect(body).not.toContain("TOP-SECRET-LOADER-DATA");
|
|
184
184
|
});
|
|
185
|
+
|
|
186
|
+
// ── Route headers / useMatches / nested middleware (Phases 1, 2, 4) ──────────
|
|
187
|
+
|
|
188
|
+
test("route `headers` export sets Cache-Control on the document response", async () => {
|
|
189
|
+
const res = await fetch(`${BASE}/features-demo`);
|
|
190
|
+
expect(res.status).toBe(200);
|
|
191
|
+
expect(res.headers.get("Cache-Control")).toBe("public, max-age=120");
|
|
192
|
+
// Baseline hardening headers still present (not clobbered).
|
|
193
|
+
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("route `headers` export also applies to the /_data response", async () => {
|
|
197
|
+
const res = await fetch(`${BASE}/_data?path=/features-demo`);
|
|
198
|
+
expect(res.status).toBe(200);
|
|
199
|
+
expect(res.headers.get("Cache-Control")).toBe("public, max-age=120");
|
|
200
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("nested middleware runs (sets context read by the loader, stamps a header)", async () => {
|
|
204
|
+
const res = await fetch(`${BASE}/_data?path=/features-demo`);
|
|
205
|
+
expect(res.headers.get("X-Demo-Mw")).toBe("1");
|
|
206
|
+
const data = (await res.json()) as { route?: { user?: string } };
|
|
207
|
+
// The loader saw the context value the middleware set.
|
|
208
|
+
expect(data.route?.user).toBe("alice");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("/_data payload carries the matched chain (useMatches) with handle", async () => {
|
|
212
|
+
const res = await fetch(`${BASE}/_data?path=/features-demo`);
|
|
213
|
+
const data = (await res.json()) as { matches?: Array<{ id: string; handle?: { breadcrumb?: string } }> };
|
|
214
|
+
expect(Array.isArray(data.matches)).toBe(true);
|
|
215
|
+
// Leaf route carries its handle export.
|
|
216
|
+
const leaf = data.matches!.at(-1)!;
|
|
217
|
+
expect(leaf.handle?.breadcrumb).toBe("Features");
|
|
218
|
+
});
|
|
@@ -58,13 +58,17 @@ describe("resolveLayoutChainFromRegistry", () => {
|
|
|
58
58
|
expect(r.layoutFiles).toEqual([]);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
test("
|
|
62
|
-
//
|
|
61
|
+
test("wraps a folder index in that folder's layout", () => {
|
|
62
|
+
// routes/blog/_index.tsx (URL /blog) lives inside routes/blog/, so the
|
|
63
|
+
// sibling routes/blog/layout.tsx wraps it — matching Remix/RR/Next, where
|
|
64
|
+
// an index is nested under its directory's layout. Layout dirs are derived
|
|
65
|
+
// from the FILE path, so the `_index` → `blog` urlPattern collapse no
|
|
66
|
+
// longer hides the ancestor directory.
|
|
63
67
|
const r = resolveLayoutChainFromRegistry(
|
|
64
68
|
{ filePath: "routes/blog/_index.tsx", urlPattern: "blog", segments: ["blog"] },
|
|
65
69
|
registry,
|
|
66
70
|
);
|
|
67
|
-
expect(r.layoutFiles).toEqual(["root.tsx"]);
|
|
71
|
+
expect(r.layoutFiles).toEqual(["root.tsx", "routes/blog/layout.tsx"]);
|
|
68
72
|
});
|
|
69
73
|
});
|
|
70
74
|
|
|
@@ -67,3 +67,32 @@ describe("matchRoute", () => {
|
|
|
67
67
|
expect(r2?.params.id).toBe("123");
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
describe("optional segments [[id]]", () => {
|
|
72
|
+
test("matches with the segment present (binds the param)", () => {
|
|
73
|
+
const trie = buildTrie([makeRoute("users/[[id]]")]);
|
|
74
|
+
const r = matchRoute("/users/42", trie);
|
|
75
|
+
expect(r).not.toBeNull();
|
|
76
|
+
expect(r?.params).toEqual({ id: "42" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("matches with the segment absent (param unset)", () => {
|
|
80
|
+
const trie = buildTrie([makeRoute("users/[[id]]")]);
|
|
81
|
+
const r = matchRoute("/users", trie);
|
|
82
|
+
expect(r).not.toBeNull();
|
|
83
|
+
expect(r?.params).toEqual({});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("static sibling still wins over the optional param", () => {
|
|
87
|
+
const trie = buildTrie([makeRoute("users/[[id]]"), makeRoute("users/me")]);
|
|
88
|
+
const r = matchRoute("/users/me", trie);
|
|
89
|
+
expect(r?.routeFile.urlPattern).toBe("users/me");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("does not over-consume — extra segment falls through to catch-all", () => {
|
|
93
|
+
const trie = buildTrie([makeRoute("users/[[id]]"), makeRoute("users/[...rest]")]);
|
|
94
|
+
const r = matchRoute("/users/1/2", trie);
|
|
95
|
+
expect(r?.routeFile.urlPattern).toBe("users/[...rest]");
|
|
96
|
+
expect(r?.params.rest).toBe("1/2");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -18,9 +18,8 @@ beforeAll(async () => {
|
|
|
18
18
|
await writeFile(join(TMP, "root.tsx"), `export default function Root() { return null; }\n`);
|
|
19
19
|
await writeFile(join(TMP, "routes", "_index.tsx"), `export default function Home() { return null; }\n`);
|
|
20
20
|
await writeFile(join(TMP, "routes", "blog", "layout.tsx"), `export default function L({ children }: any) { return children; }\n`);
|
|
21
|
-
// Nested route — `routes/blog/layout.tsx`
|
|
22
|
-
//
|
|
23
|
-
// same path level (matches `layout.ts`'s `layoutDirs` resolution).
|
|
21
|
+
// Nested route under /blog/ — `routes/blog/layout.tsx` wraps everything in
|
|
22
|
+
// its directory (including a `blog/_index`), per `layoutDirsFromFilePath`.
|
|
24
23
|
await writeFile(join(TMP, "routes", "blog", "[slug].tsx"), `export default function P() { return null; }\n`);
|
|
25
24
|
|
|
26
25
|
await writeFile(
|
|
@@ -58,6 +58,11 @@ describe("lintRouteModuleSource — miscased exports", () => {
|
|
|
58
58
|
`export default () => null;\n` +
|
|
59
59
|
`export function loader() { return {}; }\n` +
|
|
60
60
|
`export function action() { return {}; }\n` +
|
|
61
|
+
`export const clientLoader = () => ({});\n` +
|
|
62
|
+
`export const clientAction = () => ({});\n` +
|
|
63
|
+
`export function headers() { return {}; }\n` +
|
|
64
|
+
`export const middleware = [];\n` +
|
|
65
|
+
`export const handle = {};\n` +
|
|
61
66
|
`export const searchSchema = {};\n` +
|
|
62
67
|
`export function Fallback() { return null; }\n` +
|
|
63
68
|
`export const ssr = false;\n`;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
runRouteMiddleware,
|
|
4
|
+
collectRouteMiddleware,
|
|
5
|
+
type RouteMiddleware,
|
|
6
|
+
} from "../server/middleware.ts";
|
|
7
|
+
import type { MiddlewareContext } from "../server/middleware.ts";
|
|
8
|
+
|
|
9
|
+
function makeCtx(): MiddlewareContext {
|
|
10
|
+
return { request: new Request("http://localhost/"), params: {}, context: {} };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ok = async () => new Response("ok", { status: 200 });
|
|
14
|
+
|
|
15
|
+
describe("collectRouteMiddleware", () => {
|
|
16
|
+
test("orders root → layouts → route and flattens arrays", () => {
|
|
17
|
+
const order: string[] = [];
|
|
18
|
+
const mk = (label: string): RouteMiddleware => async (_c, n) => { order.push(label); return n(); };
|
|
19
|
+
const chain = {
|
|
20
|
+
root: { middleware: mk("root") },
|
|
21
|
+
layouts: [{ middleware: [mk("l0a"), mk("l0b")] }, { middleware: mk("l1") }],
|
|
22
|
+
route: { middleware: mk("route") },
|
|
23
|
+
};
|
|
24
|
+
const fns = collectRouteMiddleware(chain);
|
|
25
|
+
expect(fns).toHaveLength(5);
|
|
26
|
+
return runRouteMiddleware(fns, makeCtx(), ok).then(() => {
|
|
27
|
+
expect(order).toEqual(["root", "l0a", "l0b", "l1", "route"]);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("ignores modules without middleware and non-function entries", () => {
|
|
32
|
+
const chain = {
|
|
33
|
+
root: {},
|
|
34
|
+
layouts: [{ middleware: undefined }, { middleware: ["nope" as unknown] }],
|
|
35
|
+
route: { middleware: (async (_c: MiddlewareContext, n: () => Promise<Response>) => n()) },
|
|
36
|
+
};
|
|
37
|
+
expect(collectRouteMiddleware(chain)).toHaveLength(1);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("runRouteMiddleware", () => {
|
|
42
|
+
test("empty list calls handler directly", async () => {
|
|
43
|
+
const res = await runRouteMiddleware([], makeCtx(), ok);
|
|
44
|
+
expect(res.status).toBe(200);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a middleware can short-circuit by not calling next()", async () => {
|
|
48
|
+
let handlerRan = false;
|
|
49
|
+
const gate: RouteMiddleware = async () => new Response("denied", { status: 403 });
|
|
50
|
+
const res = await runRouteMiddleware([gate], makeCtx(), async () => {
|
|
51
|
+
handlerRan = true;
|
|
52
|
+
return ok();
|
|
53
|
+
});
|
|
54
|
+
expect(res.status).toBe(403);
|
|
55
|
+
expect(handlerRan).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("shares a mutable context across the chain and into the handler", async () => {
|
|
59
|
+
const ctx = makeCtx();
|
|
60
|
+
const setUser: RouteMiddleware = async (c, n) => { c.context.user = "alice"; return n(); };
|
|
61
|
+
let seenInHandler: unknown;
|
|
62
|
+
await runRouteMiddleware([setUser], ctx, async () => {
|
|
63
|
+
seenInHandler = ctx.context.user;
|
|
64
|
+
return ok();
|
|
65
|
+
});
|
|
66
|
+
expect(seenInHandler).toBe("alice");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("rejects if a middleware calls next() twice", async () => {
|
|
70
|
+
const bad: RouteMiddleware = async (_c, n) => { await n(); return n(); };
|
|
71
|
+
await expect(runRouteMiddleware([bad], makeCtx(), ok)).rejects.toThrow(/more than once/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("outer middleware can post-process the inner Response", async () => {
|
|
75
|
+
const stamp: RouteMiddleware = async (_c, n) => {
|
|
76
|
+
const res = await n();
|
|
77
|
+
const out = new Response(res.body, res);
|
|
78
|
+
out.headers.set("X-Stamped", "1");
|
|
79
|
+
return out;
|
|
80
|
+
};
|
|
81
|
+
const res = await runRouteMiddleware([stamp], makeCtx(), ok);
|
|
82
|
+
expect(res.headers.get("X-Stamped")).toBe("1");
|
|
83
|
+
});
|
|
84
|
+
});
|