@bractjs/bractjs 0.1.28 → 0.1.29

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.
Files changed (46) hide show
  1. package/package.json +3 -2
  2. package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
  3. package/src/__tests__/headers.test.ts +111 -0
  4. package/src/__tests__/integration.test.ts +34 -0
  5. package/src/__tests__/layout-registry.test.ts +7 -3
  6. package/src/__tests__/matcher.test.ts +29 -0
  7. package/src/__tests__/module-registry.test.ts +2 -3
  8. package/src/__tests__/route-lint.test.ts +5 -0
  9. package/src/__tests__/route-middleware.test.ts +84 -0
  10. package/src/__tests__/scanner.test.ts +46 -1
  11. package/src/__tests__/security-fixes.test.ts +201 -0
  12. package/src/__tests__/use-matches.test.ts +54 -0
  13. package/src/build/route-lint.ts +3 -3
  14. package/src/client/ClientRouter.tsx +118 -18
  15. package/src/client/hooks/useMatches.ts +32 -0
  16. package/src/client/router.tsx +7 -1
  17. package/src/client/rpc.ts +11 -1
  18. package/src/codegen/module-registry.ts +13 -21
  19. package/src/codegen/route-codegen.ts +8 -3
  20. package/src/config/load.ts +1 -0
  21. package/src/index.ts +11 -3
  22. package/src/server/action-handler.ts +1 -20
  23. package/src/server/adapter.ts +16 -0
  24. package/src/server/api-route.ts +47 -0
  25. package/src/server/csp.ts +9 -3
  26. package/src/server/csrf.ts +10 -3
  27. package/src/server/headers.ts +49 -0
  28. package/src/server/layout.ts +12 -19
  29. package/src/server/matcher.ts +29 -2
  30. package/src/server/matches.ts +50 -0
  31. package/src/server/middleware.ts +66 -0
  32. package/src/server/proto-guard.ts +56 -0
  33. package/src/server/render.ts +34 -16
  34. package/src/server/request-handler.ts +67 -27
  35. package/src/server/scanner.ts +45 -3
  36. package/src/server/search.ts +5 -1
  37. package/src/server/serve.ts +28 -3
  38. package/src/server/session.ts +12 -1
  39. package/src/server/validate.ts +4 -1
  40. package/src/shared/context.ts +3 -1
  41. package/src/shared/route-types.ts +108 -0
  42. package/types/config.d.ts +3 -0
  43. package/types/index.d.ts +17 -0
  44. package/types/route.d.ts +76 -1
  45. package/LICENSE +0 -21
  46. package/README.md +0 -1331
@@ -6,10 +6,12 @@ import { runLoaders, runAction, buildLoaderArgs, runRouteContext, runBeforeLoad
6
6
  import { validateSearch } from "./search.ts";
7
7
  import { renderRoute, type ServerManifest } from "./render.ts";
8
8
  import { resolveMeta, mergeMeta } from "./meta.ts";
9
+ import { resolveHeaders } from "./headers.ts";
10
+ import { buildMatches } from "./matches.ts";
9
11
  import { json, error, sanitizeRedirect } from "./response.ts";
10
12
  import { isRedirect, isHttpError } from "../shared/errors.ts";
11
13
  import { isExplicitDev } from "./env.ts";
12
- import { pipeline, type MiddlewareContext } from "./middleware.ts";
14
+ import { runRouteMiddleware, collectRouteMiddleware, type MiddlewareContext } from "./middleware.ts";
13
15
  import { BractJSProvider } from "../shared/context.ts";
14
16
  import { isAllowedMutation, csrfForbiddenResponse } from "./csrf.ts";
15
17
  import { getCspNonce } from "./csp.ts";
@@ -38,15 +40,15 @@ const MAX_FORM_BYTES = 10 * 1_048_576; // 10 MiB
38
40
  export async function handleRequest(
39
41
  request: Request,
40
42
  trie: TrieNode,
41
- config: HandlerConfig
43
+ config: HandlerConfig,
44
+ context: Record<string, unknown> = {},
42
45
  ): Promise<Response> {
43
- const ctx: MiddlewareContext = {
44
- request,
45
- params: {},
46
- context: {},
47
- };
48
-
49
- return pipeline.run(ctx, () => route(request, trie, config, ctx.context));
46
+ // The global pipeline is run once by buildFetchHandler around the whole
47
+ // dispatch (so it also covers /api, /_action, /_stream, /_image, static).
48
+ // We receive the shared, already-running `context` here and only run the
49
+ // per-route (nested) middleware chain — running the global pipeline again
50
+ // would double-invoke cors()/csp()/etc. for SSR documents.
51
+ return route(request, trie, config, context);
50
52
  }
51
53
 
52
54
  async function route(
@@ -92,24 +94,42 @@ async function route(
92
94
  // never see unvalidated input, and a 400 here is cheaper than a wasted
93
95
  // context-factory/loader run. The thrown 400 Response propagates below.
94
96
  const search = await validateSearch(chain.route.searchSchema, targetUrl);
97
+
95
98
  // SECURITY(high): /_data must run the same auth/redirect gates as a full
96
99
  // page request — otherwise a SPA-style soft navigation to a protected
97
- // route would bypass beforeLoad() / defineContext() and leak loader data.
98
- const routeContext = await runRouteContext(
99
- chain.route as Parameters<typeof runRouteContext>[0],
100
- loaderRequest,
101
- match.params,
102
- context,
103
- );
104
- const args = buildLoaderArgs(loaderRequest, match.params, routeContext, search);
105
- const beforeLoadResponse = await runBeforeLoad(chain.route, args);
106
- if (beforeLoadResponse) return beforeLoadResponse;
107
- const results = await runLoaders(chain, args, onError);
108
- // Merged meta must ride along: ClientRouter re-renders the document head
109
- // from this payload on soft navigation, and the initial __BRACTJS_DATA__
110
- // already carries the merged shape.
111
- const meta = mergeMeta(resolveMeta(chain, results, match.params));
112
- return json({ root: results.root, layouts: results.layouts, route: results.route, params: match.params, meta, search });
100
+ // route would bypass nested middleware / beforeLoad() / defineContext()
101
+ // and leak loader data. Run the route middleware chain around the work,
102
+ // sharing the same mutable `context` so a gate can set/clear fields.
103
+ const mwCtx: MiddlewareContext = { request: loaderRequest, params: match.params, context };
104
+ return runRouteMiddleware(collectRouteMiddleware(chain), mwCtx, async () => {
105
+ const routeContext = await runRouteContext(
106
+ chain.route as Parameters<typeof runRouteContext>[0],
107
+ loaderRequest,
108
+ match.params,
109
+ mwCtx.context,
110
+ );
111
+ const args = buildLoaderArgs(loaderRequest, match.params, routeContext, search);
112
+ const beforeLoadResponse = await runBeforeLoad(chain.route, args);
113
+ if (beforeLoadResponse) return beforeLoadResponse;
114
+ const results = await runLoaders(chain, args, onError);
115
+ // Merged meta must ride along: ClientRouter re-renders the document head
116
+ // from this payload on soft navigation, and the initial __BRACTJS_DATA__
117
+ // already carries the merged shape.
118
+ const meta = mergeMeta(resolveMeta(chain, results, match.params));
119
+ const matches = buildMatches(chain, results, match.params, targetPathname);
120
+ const dataRes = json({ root: results.root, layouts: results.layouts, route: results.route, params: match.params, meta, search, matches });
121
+ // Apply the route `headers()` chain so a soft navigation gets the same
122
+ // Cache-Control/ETag/Vary as the full document load (renderRoute applies
123
+ // them there). Content-Type stays application/json.
124
+ const dataHeaders = resolveHeaders(chain, results, match.params, loaderRequest);
125
+ if (dataHeaders) {
126
+ dataHeaders.forEach((value, key) => {
127
+ if (key.toLowerCase() === "content-type") return;
128
+ dataRes.headers.set(key, value);
129
+ });
130
+ }
131
+ return dataRes;
132
+ });
113
133
  } catch (err) {
114
134
  if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
115
135
  // A non-redirect Response (e.g. the 400 thrown by search validation)
@@ -138,12 +158,19 @@ async function route(
138
158
  throw err;
139
159
  }
140
160
 
161
+ // Nested route middleware (root → layout → route) wraps the action, loaders,
162
+ // and render. It shares the same mutable `context` object, runs *inside* the
163
+ // global pipeline, and can short-circuit (auth gate / redirect) by returning
164
+ // a Response. Empty chains call the work directly (no overhead).
165
+ const mwCtx: MiddlewareContext = { request, params: match.params, context };
166
+ return runRouteMiddleware(collectRouteMiddleware(chain), mwCtx, async () => {
167
+
141
168
  // Run per-route context factory (defineContext export) before loaders.
142
169
  const routeContext = await runRouteContext(
143
170
  chain.route as Parameters<typeof runRouteContext>[0],
144
171
  request,
145
172
  match.params,
146
- context,
173
+ mwCtx.context,
147
174
  );
148
175
  const args = buildLoaderArgs(request, match.params, routeContext, search);
149
176
 
@@ -227,6 +254,10 @@ async function route(
227
254
  const RouteComponent = routeSsr === true ? chain.route.default : chain.route.Fallback;
228
255
  const ssrMode = routeSsr === true ? undefined : routeSsr === false ? "client-only" as const : "data-only" as const;
229
256
 
257
+ // useMatches() payload — the chain's handle + data, for breadcrumbs etc.
258
+ // Built from loaderChain so the loader slices line up with what ran.
259
+ const matches = buildMatches(loaderChain, loaderResults, match.params, pathname);
260
+
230
261
  // Wrap root in BractJSProvider so <Outlet> can render the route component
231
262
  // server-side without needing a ClientRouter.
232
263
  const shell = createElement(
@@ -241,12 +272,17 @@ async function route(
241
272
  RouteComponent,
242
273
  location: { pathname, search: url.search, hash: "", state: null, key: "default" },
243
274
  search,
275
+ matches,
244
276
  },
245
277
  children: createElement(RootComponent),
246
278
  },
247
279
  );
248
280
 
249
281
  const meta = resolveMeta(chain, loaderResults, match.params);
282
+ // Route `headers()` chain (Cache-Control/ETag/Vary/…), applied on top of the
283
+ // baseline document headers in renderRoute. Uses the loaders that actually
284
+ // ran (loaderChain) so a selective-SSR route's headers() sees the same data.
285
+ const routeHeaders = resolveHeaders(loaderChain, loaderResults, match.params, request);
250
286
 
251
287
  return renderRoute({
252
288
  shell,
@@ -257,9 +293,13 @@ async function route(
257
293
  search,
258
294
  manifest,
259
295
  meta,
296
+ matches,
297
+ headers: routeHeaders,
260
298
  routeFile: match.routeFile.filePath,
261
299
  // Set by the opt-in csp() middleware; undefined otherwise.
262
- nonce: getCspNonce(context),
300
+ nonce: getCspNonce(mwCtx.context),
263
301
  ssrMode,
264
302
  });
303
+
304
+ });
265
305
  }
@@ -2,7 +2,11 @@ import { basename } from "node:path";
2
2
 
3
3
  // ── Types ──────────────────────────────────────────────────────────────────
4
4
 
5
- export type Segment = string | { param: string } | { catchAll: string };
5
+ export type Segment =
6
+ | string
7
+ | { param: string }
8
+ | { optional: string }
9
+ | { catchAll: string };
6
10
 
7
11
  export interface RouteFile {
8
12
  filePath: string;
@@ -12,12 +16,22 @@ export interface RouteFile {
12
16
 
13
17
  // ── Helpers ────────────────────────────────────────────────────────────────
14
18
 
19
+ /** A path segment that is a route group: `(marketing)`. Contributes a layout
20
+ * folder but no URL segment. */
21
+ export function isRouteGroupSegment(seg: string): boolean {
22
+ return seg.startsWith("(") && seg.endsWith(")") && seg.length > 2;
23
+ }
24
+
15
25
  export function pathToSegments(pattern: string): Segment[] {
16
26
  if (pattern === "") return [];
17
27
  return pattern.split("/").map((seg) => {
18
28
  if (seg.startsWith("[...") && seg.endsWith("]")) {
19
29
  return { catchAll: seg.slice(4, -1) };
20
30
  }
31
+ // Optional param: [[id]] → matches with or without the segment present.
32
+ if (seg.startsWith("[[") && seg.endsWith("]]")) {
33
+ return { optional: seg.slice(2, -2) };
34
+ }
21
35
  if (seg.startsWith("[") && seg.endsWith("]")) {
22
36
  return { param: seg.slice(1, -1) };
23
37
  }
@@ -29,20 +43,48 @@ export function filePathToPattern(filePath: string): string {
29
43
  // Strip "routes/" prefix and file extension
30
44
  let path = filePath.replace(/^routes\//, "").replace(/\.(tsx|ts)$/, "");
31
45
 
46
+ // Drop route-group segments — `(marketing)/about` → `about`. They group
47
+ // files (and their layout.tsx) without adding a URL segment.
48
+ path = path
49
+ .split("/")
50
+ .filter((seg) => !isRouteGroupSegment(seg))
51
+ .join("/");
52
+
32
53
  // Handle nested _index (e.g. blog/_index → blog)
33
54
  path = path.replace(/\/_index$/, "");
34
55
 
35
56
  // Handle root _index
36
57
  if (path === "_index" || path === "") return "";
37
58
 
38
- // Convert [param] and [...catchAll] segments — keep as-is for pattern string
59
+ // Convert [param], [[optional]], and [...catchAll] segments — keep as-is for
60
+ // the pattern string.
39
61
  return path;
40
62
  }
41
63
 
64
+ /**
65
+ * Ancestor directory chain (relative to `routes/`) for a route file, outermost
66
+ * → innermost, used to locate nesting `layout.tsx` files. Derived from the FILE
67
+ * path (not the URL pattern) so route-group folders like `(marketing)` are
68
+ * included — their layout wraps children even though they add no URL segment.
69
+ *
70
+ * `routes/(marketing)/blog/[id].tsx` → `["(marketing)", "(marketing)/blog"]`.
71
+ */
72
+ export function layoutDirsFromFilePath(filePath: string): string[] {
73
+ const rel = filePath.replace(/^routes\//, "").replace(/\.(tsx|ts)$/, "");
74
+ const parts = rel.split("/");
75
+ parts.pop(); // drop the file's own basename — only ancestor dirs hold layouts
76
+ const dirs: string[] = [];
77
+ for (let i = 1; i <= parts.length; i++) {
78
+ dirs.push(parts.slice(0, i).join("/"));
79
+ }
80
+ return dirs;
81
+ }
82
+
42
83
  function segmentScore(seg: Segment): number {
43
84
  if (typeof seg === "string") return 0; // static
44
85
  if ("param" in seg) return 1; // dynamic
45
- return 2; // catch-all
86
+ if ("optional" in seg) return 2; // optional dynamic
87
+ return 3; // catch-all
46
88
  }
47
89
 
48
90
  function routeScore(route: RouteFile): number {
@@ -8,7 +8,11 @@ import { runSchema, type Schema } from "./validate.ts";
8
8
  * flattens FormData.
9
9
  */
10
10
  export function searchParamsToObject(sp: URLSearchParams): Record<string, string | string[]> {
11
- const out: Record<string, string | string[]> = {};
11
+ // Null-prototype so a query param named "__proto__" (?__proto__=x) can't
12
+ // pollute Object.prototype when the result is later spread/merged. Using a
13
+ // plain {} here would make `out["__proto__"] = …` a no-op AND, for nested
14
+ // merges downstream, a pollution vector. SECURITY: see proto-guard.ts.
15
+ const out = Object.create(null) as Record<string, string | string[]>;
12
16
  for (const [key, value] of sp.entries()) {
13
17
  const existing = out[key];
14
18
  if (existing === undefined) out[key] = value;
@@ -1,6 +1,7 @@
1
1
  import { scanRoutes, type RouteFile } from "./scanner.ts";
2
2
  import { buildTrie, matchRoute } from "./matcher.ts";
3
3
  import { handleRequest, type HandlerConfig } from "./request-handler.ts";
4
+ import { pipeline, type MiddlewareContext } from "./middleware.ts";
4
5
  import { renderSpaShell } from "./spa.ts";
5
6
  import { type ServerManifest } from "./render.ts";
6
7
  import { isDevRuntime, isExplicitDev } from "./env.ts";
@@ -52,6 +53,14 @@ export interface BractJSConfig {
52
53
  buildDir?: string;
53
54
  /** Directory for transformed image cache. Defaults to .bract-image-cache */
54
55
  imageCacheDir?: string;
56
+ /**
57
+ * Hard ceiling (bytes) on the size of any incoming request body, enforced by
58
+ * the Bun adapter regardless of the advertised Content-Length. Defaults to
59
+ * 16 MiB — above the 10 MiB route-form cap so normal requests pass while a
60
+ * single client can't stream an unbounded body into memory. Raise it for a
61
+ * dedicated large-upload endpoint. Only applies to the default Bun adapter.
62
+ */
63
+ maxRequestBodySize?: number;
55
64
  /** Called once after the server starts listening. Use to open DB connections, warm caches, etc. */
56
65
  onStart?: () => Promise<void> | void;
57
66
  /** Called before the process exits (any signal or uncaught error). Use to close DB connections, flush queues, etc. */
@@ -169,7 +178,13 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
169
178
  return Bun.file(join(buildDir, "client", "_prerender", relHtmlOrJson));
170
179
  }
171
180
 
172
- return async function fetch(request: Request): Promise<Response> {
181
+ // The full per-request dispatch: special endpoints (API, actions, stream,
182
+ // image, static, prerender) first, then the SSR route handler. Runs INSIDE
183
+ // the global middleware pipeline (see the returned `fetch` below), so
184
+ // `pipeline.use(cors()/csp()/auth/…)` governs every response — not just SSR
185
+ // documents. `context` is the shared mutable object threaded through the
186
+ // pipeline; route-level middleware and getCspNonce() read the same object.
187
+ async function dispatch(request: Request, context: Record<string, unknown>): Promise<Response> {
173
188
  const url = new URL(request.url);
174
189
  const { pathname } = url;
175
190
 
@@ -285,7 +300,17 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
285
300
 
286
301
  const manifest = isDevRuntime() ? await readDevManifest(buildDir) : await manifestReady;
287
302
  const handlerConfig: HandlerConfig = { appDir, publicDir, manifest, onError, moduleRegistry };
288
- return handleRequest(request, trie, handlerConfig);
303
+ return handleRequest(request, trie, handlerConfig, context);
304
+ }
305
+
306
+ return async function fetch(request: Request): Promise<Response> {
307
+ // Run the global middleware pipeline around the ENTIRE dispatch so
308
+ // cors()/csp()/logging/auth attached via `pipeline.use(...)` apply to
309
+ // API routes, server actions, /_stream, /_image and static assets — not
310
+ // only SSR documents. The per-route (nested) middleware chain still runs
311
+ // inside handleRequest for SSR/_data, sharing this same `context` object.
312
+ const ctx: MiddlewareContext = { request, params: {}, context: {} };
313
+ return pipeline.run(ctx, () => dispatch(request, ctx.context));
289
314
  };
290
315
  }
291
316
 
@@ -330,7 +355,7 @@ export function createServer(config?: Partial<BractJSConfig>): {
330
355
  const fetchHandler = buildFetchHandler(config ?? {});
331
356
 
332
357
  // Use provided adapter or fall back to the default Bun adapter.
333
- const adapter = config?.adapter ?? new BunAdapter();
358
+ const adapter = config?.adapter ?? new BunAdapter(config?.maxRequestBodySize);
334
359
 
335
360
  if (adapter instanceof BunAdapter) {
336
361
  adapter.setHandler(fetchHandler);
@@ -1,3 +1,5 @@
1
+ import { hasForbiddenKey } from "./proto-guard.ts";
2
+
1
3
  export type SessionData = Record<string, unknown>;
2
4
 
3
5
  export interface Session {
@@ -37,7 +39,16 @@ function encode(data: SessionData): string {
37
39
 
38
40
  function decode(encoded: string): SessionData {
39
41
  const pad = "=".repeat((4 - (encoded.length % 4)) % 4);
40
- return JSON.parse(atob(encoded.replace(/-/g, "+").replace(/_/g, "/") + pad)) as SessionData;
42
+ const parsed = JSON.parse(
43
+ atob(encoded.replace(/-/g, "+").replace(/_/g, "/") + pad),
44
+ ) as SessionData;
45
+ // Defense-in-depth: the payload is HMAC-verified before we get here, so this
46
+ // only matters if a signing secret leaks — but a session blob carrying a
47
+ // "__proto__" key must never pollute Object.prototype when read/spread.
48
+ if (hasForbiddenKey(parsed)) {
49
+ throw new Error("session: forbidden key in payload");
50
+ }
51
+ return parsed;
41
52
  }
42
53
 
43
54
  async function sign(data: string, secret: string): Promise<string> {
@@ -33,7 +33,10 @@ export class ValidationError extends Error {
33
33
 
34
34
  function toPlainObject(input: FormData | Record<string, unknown>): Record<string, unknown> {
35
35
  if (input instanceof FormData) {
36
- const out: Record<string, unknown> = {};
36
+ // Null-prototype: a form field literally named "__proto__" becomes a plain
37
+ // own key here instead of mutating Object.prototype when the result is
38
+ // later spread/merged. SECURITY: see src/server/proto-guard.ts.
39
+ const out = Object.create(null) as Record<string, unknown>;
37
40
  for (const [key, value] of input.entries()) {
38
41
  if (key in out) {
39
42
  const existing = out[key];
@@ -1,5 +1,5 @@
1
1
  import { createContext, useContext, createElement, type ComponentType, type ReactNode } from "react";
2
- import type { RouterLocation } from "./route-types.ts";
2
+ import type { RouterLocation, RouteMatch } from "./route-types.ts";
3
3
 
4
4
  export interface RouteManifest {
5
5
  [routeId: string]: {
@@ -20,6 +20,8 @@ export interface BractJSContextValue {
20
20
  location?: RouterLocation;
21
21
  /** Validated search params (route `searchSchema` output), so `useSearch()` works during SSR. */
22
22
  search?: Record<string, unknown>;
23
+ /** The matched route chain (root → layouts → route) for `useMatches()`. */
24
+ matches?: RouteMatch[];
23
25
  }
24
26
 
25
27
  export const BractJSContext = createContext<BractJSContextValue>(null!);
@@ -75,6 +75,41 @@ export type MetaFunction<T = unknown> = (
75
75
  args: MetaArgs<T>
76
76
  ) => MetaDescriptor[];
77
77
 
78
+ export interface HeadersArgs<T = unknown> {
79
+ /** This route's loader data (the route slice, already awaited). */
80
+ loaderData: T;
81
+ params: Record<string, string>;
82
+ request: Request;
83
+ /**
84
+ * The merged headers contributed by ancestors in the chain (root → layout →
85
+ * this route). Spread these to inherit, or override individual keys. Each
86
+ * `headers()` in the chain runs in order and sees what came before it.
87
+ */
88
+ parentHeaders: Headers;
89
+ }
90
+
91
+ /**
92
+ * A route/layout/root module's optional `headers` export, used to set
93
+ * response headers (e.g. `Cache-Control`, `ETag`, `Vary`) on the document and
94
+ * `/_data` responses. Runs in chain order (root → layout → route); the
95
+ * innermost value wins per key. Returns a `HeadersInit` (object, array of
96
+ * tuples, or `Headers`).
97
+ */
98
+ export type HeadersFunction<T = unknown> = (
99
+ args: HeadersArgs<T>
100
+ ) => HeadersInit;
101
+
102
+ /**
103
+ * A nested route-middleware function. Runs on the server in chain order
104
+ * (root → layout → route) before `beforeLoad`, the action, and loaders. Call
105
+ * `next()` to continue, or return a `Response` to short-circuit. The `context`
106
+ * object is shared and mutable across the whole chain (and into loaders).
107
+ */
108
+ export type RouteMiddlewareFunction = (
109
+ ctx: { request: Request; params: Record<string, string>; context: Record<string, unknown> },
110
+ next: () => Promise<Response>,
111
+ ) => Promise<Response>;
112
+
78
113
  export interface BeforeLoadArgs {
79
114
  params: Record<string, string>;
80
115
  context: Record<string, unknown>;
@@ -105,10 +140,64 @@ export interface ShouldRevalidateArgs {
105
140
 
106
141
  export type ShouldRevalidateFunction = (args: ShouldRevalidateArgs) => boolean;
107
142
 
143
+ /**
144
+ * A route's optional client loader (RR7-style). Runs in the browser on
145
+ * navigation to the route instead of just fetching the server loader. Call
146
+ * `serverLoader()` to get this route's server loader data (the `/_data`
147
+ * payload's route slice). Set `clientLoader.hydrate = true` to also run it
148
+ * during the initial hydration of an SSR'd document.
149
+ *
150
+ * Whatever it resolves to becomes the route's `useLoaderData()` value.
151
+ */
152
+ export interface ClientLoaderFunction<T = unknown> {
153
+ (args: {
154
+ request: Request;
155
+ params: Record<string, string>;
156
+ search: Record<string, unknown>;
157
+ /** Fetch this route's server loader data (the `/_data` route slice). */
158
+ serverLoader: () => Promise<unknown>;
159
+ }): Promise<T> | T;
160
+ /** Run on initial hydration too (default: only on client navigation). */
161
+ hydrate?: boolean;
162
+ }
163
+
164
+ /**
165
+ * A route's optional client action (RR7-style). Runs in the browser on a
166
+ * `<Form>`/fetcher submission to the route instead of POSTing directly. Call
167
+ * `serverAction()` to invoke the server action and get its data. Whatever it
168
+ * resolves to becomes the route's `useActionData()` value.
169
+ */
170
+ export type ClientActionFunction<T = unknown> = (args: {
171
+ request: Request;
172
+ params: Record<string, string>;
173
+ formData: FormData;
174
+ /** Invoke this route's server action and get its returned data. */
175
+ serverAction: () => Promise<unknown>;
176
+ }) => Promise<T> | T;
177
+
108
178
  export interface RouteModule<TLoader = unknown, TAction = unknown> {
109
179
  loader?: LoaderFunction<TLoader>;
110
180
  action?: ActionFunction<TAction>;
181
+ /** Browser-side loader; see {@link ClientLoaderFunction}. */
182
+ clientLoader?: ClientLoaderFunction<TLoader>;
183
+ /** Browser-side action; see {@link ClientActionFunction}. */
184
+ clientAction?: ClientActionFunction<TAction>;
111
185
  meta?: MetaFunction<TLoader>;
186
+ /**
187
+ * Set response headers (`Cache-Control`, `ETag`, `Vary`, CDN hints, …) for
188
+ * this route's document and `/_data` responses. Runs in chain order
189
+ * (root → layout → route); innermost wins per key, and each call receives the
190
+ * `parentHeaders` accumulated so far. Skipped for mutations and error responses.
191
+ */
192
+ headers?: HeadersFunction<TLoader>;
193
+ /**
194
+ * Nested middleware for this route/layout/root. Runs on the server in chain
195
+ * order (root → layout → route) before `beforeLoad`/action/loaders, with a
196
+ * shared mutable `context`. A single function or an array. Return a
197
+ * `Response` to short-circuit; call `next()` to continue. Runs *inside* the
198
+ * global `pipeline` middleware.
199
+ */
200
+ middleware?: RouteMiddlewareFunction | RouteMiddlewareFunction[];
112
201
  beforeLoad?: BeforeLoadFunction;
113
202
  shouldRevalidate?: ShouldRevalidateFunction;
114
203
  /**
@@ -141,3 +230,22 @@ export interface RouteDefinition {
141
230
  parentId?: string;
142
231
  index?: boolean;
143
232
  }
233
+
234
+ /**
235
+ * One entry in the matched route chain, as returned by `useMatches()`. The
236
+ * array runs outermost → innermost: root, then each layout, then the leaf
237
+ * route. Use it for breadcrumbs and conditional chrome driven by each route's
238
+ * `handle` export.
239
+ */
240
+ export interface RouteMatch<TData = unknown, THandle = Record<string, unknown>> {
241
+ /** Stable id of the matched module — its appDir-relative file path (e.g. "routes/blog/[id].tsx", "root.tsx"). */
242
+ id: string;
243
+ /** The active URL pathname (same for every entry — they all share the matched location). */
244
+ pathname: string;
245
+ /** The matched route params (shared across the chain). */
246
+ params: Record<string, string>;
247
+ /** This module's loader data slice (root / the matching layout / the route). */
248
+ data: TData;
249
+ /** This module's static `handle` export, or `undefined` if none. */
250
+ handle: THandle | undefined;
251
+ }
package/types/config.d.ts CHANGED
@@ -23,6 +23,9 @@ export interface BractJSConfig {
23
23
  plugins?: BunPlugin[];
24
24
  /** Directory for the transformed-image cache. Default: ".bract-image-cache". */
25
25
  imageCacheDir?: string;
26
+ /** Hard ceiling (bytes) on any incoming request body, enforced by the Bun
27
+ * adapter regardless of advertised Content-Length. Default 16 MiB. */
28
+ maxRequestBodySize?: number;
26
29
  /** WebSocket port for dev HMR (used by `bractjs dev` only). Default 3001. */
27
30
  hmrPort?: number;
28
31
  /** Custom server adapter (Cloudflare Workers, Deno, Node, etc.). Defaults to Bun.serve(). */
package/types/index.d.ts CHANGED
@@ -93,6 +93,7 @@ import type { MiddlewareFn, MiddlewareContext } from "./middleware.d.ts";
93
93
 
94
94
  export declare class MiddlewarePipeline {
95
95
  use(fn: MiddlewareFn): this;
96
+ clear(): this;
96
97
  run(ctx: MiddlewareContext, handler: () => Promise<Response>): Promise<Response>;
97
98
  }
98
99
  export declare const pipeline: MiddlewarePipeline;
@@ -106,16 +107,24 @@ export declare function authGuard(options: AuthGuardOptions): MiddlewareFn;
106
107
 
107
108
  // ── API routes (C1) ───────────────────────────────────────────────────────
108
109
  export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
110
+ export interface ApiRouteOptions {
111
+ /** CSRF protection for this route. Default `true` for mutating methods
112
+ * (POST/PUT/PATCH/DELETE). Set `false` only for endpoints that don't trust
113
+ * ambient credentials (webhooks, token-authenticated/public APIs). */
114
+ csrf?: boolean;
115
+ }
109
116
  export interface ApiRouteDefinition<TMethod extends HttpMethod, TPath extends string, TInput, TOutput> {
110
117
  method: TMethod;
111
118
  path: TPath;
112
119
  handler: (input: TInput, request: Request) => TOutput | Promise<TOutput>;
120
+ csrf: boolean;
113
121
  }
114
122
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
115
123
  export declare function route<TMethod extends HttpMethod, TPath extends string, TInput, TOutput>(
116
124
  method: TMethod,
117
125
  path: TPath,
118
126
  handler: (input: TInput, request: Request) => TOutput | Promise<TOutput>,
127
+ options?: ApiRouteOptions,
119
128
  ): ApiRouteDefinition<TMethod, TPath, TInput, TOutput>;
120
129
  export type AppApiRoutes = never; // users extend this via codegen
121
130
 
@@ -163,6 +172,14 @@ export declare function formText(formData: FormData, key: string): string;
163
172
  /** Collect string fields from FormData (all, or a named subset). */
164
173
  export declare function formValues(formData: FormData, keys?: string[]): Record<string, string>;
165
174
 
175
+ // ── Prototype-pollution guards ────────────────────────────────────────────
176
+ /** Deep-scan a parsed JSON value for `__proto__`/`constructor`/`prototype`
177
+ * keys. Fails closed past an internal depth cap. Returns true if found. */
178
+ export declare function hasForbiddenKey(value: unknown, depth?: number): boolean;
179
+ /** Build a null-prototype object from entries, so a key named `__proto__`
180
+ * lands as a plain own property instead of mutating the prototype. */
181
+ export declare function nullProtoFromEntries<V>(entries: Iterable<readonly [string, V]>): Record<string, V>;
182
+
166
183
  // ── Search-param validation ───────────────────────────────────────────────
167
184
  /** URLSearchParams → plain object; repeated keys collapse into arrays. */
168
185
  export declare function searchParamsToObject(sp: URLSearchParams): Record<string, string | string[]>;