@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
@@ -0,0 +1,54 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { buildMatches } from "../server/matches.ts";
3
+ import type { LayoutChain } from "../server/layout.ts";
4
+ import type { LoaderResults } from "../server/loader.ts";
5
+
6
+ describe("buildMatches", () => {
7
+ test("returns root → layouts → route in order with ids and data", () => {
8
+ const chain: LayoutChain = {
9
+ root: { handle: { breadcrumb: "Home" } },
10
+ layouts: [{ handle: { breadcrumb: "Blog" } }],
11
+ route: { handle: { breadcrumb: "Post" } },
12
+ files: { root: "root.tsx", layouts: ["routes/blog/layout.tsx"], route: "routes/blog/[id].tsx" },
13
+ };
14
+ const data: LoaderResults = { root: { user: "a" }, layouts: [{ posts: 2 }], route: { id: "7" } };
15
+
16
+ const matches = buildMatches(chain, data, { id: "7" }, "/blog/7");
17
+
18
+ expect(matches.map((m) => m.id)).toEqual([
19
+ "root.tsx",
20
+ "routes/blog/layout.tsx",
21
+ "routes/blog/[id].tsx",
22
+ ]);
23
+ expect(matches.map((m) => m.handle?.breadcrumb)).toEqual(["Home", "Blog", "Post"]);
24
+ expect(matches[2].data).toEqual({ id: "7" });
25
+ expect(matches[1].data).toEqual({ posts: 2 });
26
+ // params + pathname shared across the chain.
27
+ expect(matches.every((m) => m.pathname === "/blog/7")).toBe(true);
28
+ expect(matches.every((m) => m.params.id === "7")).toBe(true);
29
+ });
30
+
31
+ test("handle is undefined when a module does not export it", () => {
32
+ const chain: LayoutChain = {
33
+ root: {},
34
+ layouts: [],
35
+ route: { handle: { title: "x" } },
36
+ files: { root: "root.tsx", layouts: [], route: "routes/_index.tsx" },
37
+ };
38
+ const data: LoaderResults = { root: null, layouts: [], route: null };
39
+ const matches = buildMatches(chain, data, {}, "/");
40
+ expect(matches[0].handle).toBeUndefined();
41
+ expect(matches[1].handle).toEqual({ title: "x" });
42
+ });
43
+
44
+ test("falls back to synthetic ids when files metadata is absent", () => {
45
+ const chain: LayoutChain = {
46
+ root: {},
47
+ layouts: [{}],
48
+ route: {},
49
+ };
50
+ const data: LoaderResults = { root: null, layouts: [null], route: null };
51
+ const matches = buildMatches(chain, data, {}, "/x");
52
+ expect(matches.map((m) => m.id)).toEqual(["root", "layout:0", "route"]);
53
+ });
54
+ });
@@ -4,9 +4,9 @@ import { extractExports } from "./directives.ts";
4
4
  // (wrong case) of one of these is almost always a mistake — the framework's
5
5
  // projection is case-sensitive, so `Loader` is silently ignored.
6
6
  export const ROUTE_EXPORT_NAMES = [
7
- "default", "loader", "action", "meta", "beforeLoad", "shouldRevalidate",
8
- "searchSchema", "ssr", "Fallback", "handle", "ErrorBoundary", "config",
9
- "loaderDeps", "context",
7
+ "default", "loader", "action", "clientLoader", "clientAction", "meta", "headers",
8
+ "middleware", "beforeLoad", "shouldRevalidate", "searchSchema", "ssr", "Fallback",
9
+ "handle", "ErrorBoundary", "config", "loaderDeps", "context",
10
10
  ] as const;
11
11
 
12
12
  const CANONICAL_LOWER = new Map(ROUTE_EXPORT_NAMES.map((n) => [n.toLowerCase(), n]));
@@ -16,7 +16,7 @@ import { matchPatternForPath, toSamePath, parseTo, createLocationKey } from "./n
16
16
  import { loaderCache, cacheKey } from "./cache.ts";
17
17
  import { registerRevalidator, type RevalidationInfo } from "./revalidation.ts";
18
18
  import { MetaTags } from "../shared/meta-tags.tsx";
19
- import type { MetaDescriptor, RouterLocation, ShouldRevalidateFunction } from "../shared/route-types.ts";
19
+ import type { MetaDescriptor, RouterLocation, RouteMatch, ShouldRevalidateFunction } from "../shared/route-types.ts";
20
20
 
21
21
  // ── Types ──────────────────────────────────────────────────────────────────
22
22
 
@@ -47,6 +47,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
47
47
  const [params, setParams] = useState(initialData.params);
48
48
  const [location, setLocation] = useState<RouterLocation>(initialData.location);
49
49
  const [search, setSearch] = useState<Record<string, unknown>>(initialData.search ?? {});
50
+ const [matches, setMatches] = useState<RouteMatch[]>(initialData.matches ?? []);
50
51
  const [navState, setNavState] = useState<NavigationState>("idle");
51
52
  const [revalidationState, setRevalidationState] = useState<"idle" | "loading">("idle");
52
53
  const [currentModule, setCurrentModule] = useState<RouteModuleClient | null>(initialModule);
@@ -61,6 +62,8 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
61
62
  // Refs mirroring state that the stable revalidate/submit callbacks need.
62
63
  const locationRef = useRef(location);
63
64
  useEffect(() => { locationRef.current = location; }, [location]);
65
+ const paramsRef = useRef(params);
66
+ useEffect(() => { paramsRef.current = params; }, [params]);
64
67
  const currentModuleRef = useRef(currentModule);
65
68
  useEffect(() => { currentModuleRef.current = currentModule; }, [currentModule]);
66
69
 
@@ -69,6 +72,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
69
72
  if (state.actionData !== undefined) setActionData(state.actionData);
70
73
  if (state.params !== undefined) setParams(state.params);
71
74
  if (state.search !== undefined) setSearch(state.search);
75
+ if (state.matches !== undefined) setMatches(state.matches);
72
76
  if (state.location !== undefined) setLocation(state.location);
73
77
  else if (state.pathname !== undefined) {
74
78
  // Legacy callers pass a (possibly query-carrying) pathname string.
@@ -148,6 +152,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
148
152
  // React 19 hoists the <title>/<meta> elements rendered by <MetaTags>
149
153
  // into <head>, so description/OG tags update on soft navigation.
150
154
  setMeta((data.meta as MetaDescriptor[] | undefined) ?? []);
155
+ setMatches((data.matches as RouteMatch[] | undefined) ?? []);
151
156
  });
152
157
  };
153
158
 
@@ -199,6 +204,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
199
204
  setParams(((fresh as Record<string, unknown>).params as Record<string, string>) ?? {});
200
205
  setSearch(((fresh as Record<string, unknown>).search as Record<string, unknown>) ?? {});
201
206
  setMeta(((fresh as Record<string, unknown>).meta as MetaDescriptor[] | undefined) ?? []);
207
+ setMatches(((fresh as Record<string, unknown>).matches as RouteMatch[] | undefined) ?? []);
202
208
  });
203
209
  });
204
210
  return;
@@ -215,6 +221,27 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
215
221
  return;
216
222
  }
217
223
  const data = await res.json() as Record<string, unknown>;
224
+
225
+ // clientLoader (RR7-style): when the route exports one, it runs in the
226
+ // browser and its result replaces the route's loader slice. It receives a
227
+ // `serverLoader()` that resolves to the freshly-fetched server data, so a
228
+ // clientLoader can wrap/augment/cache it. Other slices (root/layouts) and
229
+ // the meta/matches payload are untouched.
230
+ const clientLoader = (routeModule as Record<string, unknown> | null)
231
+ ?.clientLoader as import("../shared/route-types.ts").ClientLoaderFunction | undefined;
232
+ if (typeof clientLoader === "function") {
233
+ try {
234
+ data.route = await clientLoader({
235
+ request: new Request(new URL(dataPath, window.location.origin)),
236
+ params: (data.params as Record<string, string>) ?? {},
237
+ search: (data.search as Record<string, unknown>) ?? {},
238
+ serverLoader: () => Promise.resolve(data.route),
239
+ });
240
+ } catch (err) {
241
+ console.error("[bractjs] clientLoader error:", err);
242
+ }
243
+ }
244
+
218
245
  if (staleTime > 0) loaderCache.set(key, data, staleTime, gcTime);
219
246
 
220
247
  // Update DevTools state (dev-only — no-op in prod since the import fails).
@@ -290,6 +317,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
290
317
  setParams((data.params as Record<string, string>) ?? {});
291
318
  setSearch((data.search as Record<string, unknown>) ?? {});
292
319
  setMeta((data.meta as MetaDescriptor[] | undefined) ?? []);
320
+ setMatches((data.matches as RouteMatch[] | undefined) ?? []);
293
321
  });
294
322
  } catch (err) {
295
323
  console.error("[bractjs] revalidate error:", err);
@@ -304,6 +332,39 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
304
332
  return () => registerRevalidator(null);
305
333
  }, [revalidate]);
306
334
 
335
+ // clientLoader.hydrate: for a fully-SSR'd route whose clientLoader opted into
336
+ // hydration, run it once after mount and replace the route's loader slice.
337
+ // Routes that didn't SSR (hydrationPending truthy) take the fetch path below,
338
+ // where clientLoader already applies via loadRoute on navigation; this effect
339
+ // is only for the first paint of an SSR document.
340
+ useEffect(() => {
341
+ if (hydrationPending) return;
342
+ const cl = (initialModule as Record<string, unknown> | null)
343
+ ?.clientLoader as import("../shared/route-types.ts").ClientLoaderFunction | undefined;
344
+ if (typeof cl !== "function" || cl.hydrate !== true) return;
345
+ let cancelled = false;
346
+ void (async () => {
347
+ const path = window.location.pathname + window.location.search;
348
+ const serverSlice = (initialData.loaderData as Record<string, unknown>)?.route;
349
+ try {
350
+ const next = await cl({
351
+ request: new Request(new URL(path, window.location.origin)),
352
+ params: initialData.params,
353
+ search: initialData.search ?? {},
354
+ serverLoader: async () => serverSlice,
355
+ });
356
+ if (cancelled) return;
357
+ startTransition(() => {
358
+ setLoaderData((prev) => ({ ...prev, route: next }));
359
+ });
360
+ } catch (err) {
361
+ console.error("[bractjs] clientLoader (hydrate) error:", err);
362
+ }
363
+ })();
364
+ return () => { cancelled = true; };
365
+ // eslint-disable-next-line react-hooks/exhaustive-deps
366
+ }, []);
367
+
307
368
  // Selective-SSR / SPA hydration completion. The first client render matched
308
369
  // the server (Fallback or empty shell); after mount, put loader data in
309
370
  // place and swap in the real component via a transition.
@@ -334,6 +395,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
334
395
  setParams((data.params as Record<string, string>) ?? {});
335
396
  setSearch((data.search as Record<string, unknown>) ?? {});
336
397
  setMeta((data.meta as MetaDescriptor[] | undefined) ?? []);
398
+ setMatches((data.matches as RouteMatch[] | undefined) ?? []);
337
399
  setHydrationPending(false);
338
400
  });
339
401
  return;
@@ -402,34 +464,72 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
402
464
  const body = opts.body instanceof FormData
403
465
  ? opts.body
404
466
  : new URLSearchParams(opts.body);
405
- const res = await fetch(to, {
406
- method: opts.method.toUpperCase(),
407
- body,
408
- headers: { "X-BractJS-Action": "1" },
409
- });
410
- if (res.redirected) {
411
- const safe = toSamePath(res.url);
412
- if (safe) {
413
- // navigate() loads fresh data for the target — no extra revalidation.
414
- await navigateRef.current(safe);
415
- return;
467
+
468
+ // The server submit — also the `serverAction()` a clientAction can call.
469
+ // A redirected response short-circuits to a real navigation (via
470
+ // toSamePath so an attacker Location can never soft-nav the SPA); it
471
+ // returns a sentinel so the caller stops.
472
+ const REDIRECTED = Symbol("redirected");
473
+ let lastStatus = 0;
474
+ const doServerPost = async (): Promise<unknown> => {
475
+ const res = await fetch(to, {
476
+ method: opts.method.toUpperCase(),
477
+ body,
478
+ headers: { "X-BractJS-Action": "1" },
479
+ });
480
+ lastStatus = res.status;
481
+ if (res.redirected) {
482
+ const safe = toSamePath(res.url);
483
+ if (safe) { await navigateRef.current(safe); return REDIRECTED; }
484
+ window.location.assign(res.url);
485
+ return REDIRECTED;
416
486
  }
417
- window.location.assign(res.url);
418
- return;
487
+ return res.json();
488
+ };
489
+
490
+ // clientAction (RR7-style): if the target route exports one, it runs in
491
+ // the browser and decides whether/how to hit the server via serverAction().
492
+ const [toPath] = to.split("?");
493
+ const pattern = matchPatternForPath(toPath, manifest);
494
+ const chunkUrl = pattern !== null ? manifest.routes[pattern]?.chunk : undefined;
495
+ let clientAction: import("../shared/route-types.ts").ClientActionFunction | undefined;
496
+ if (chunkUrl) {
497
+ try {
498
+ const mod = await import(/* @vite-ignore */ chunkUrl) as Record<string, unknown>;
499
+ if (typeof mod.clientAction === "function") {
500
+ clientAction = mod.clientAction as import("../shared/route-types.ts").ClientActionFunction;
501
+ }
502
+ } catch { /* fall back to a plain server submit */ }
419
503
  }
420
- const data = (await res.json()) as unknown;
504
+
505
+ let data: unknown;
506
+ if (clientAction) {
507
+ let calledServer = false;
508
+ data = await clientAction({
509
+ request: new Request(new URL(to, window.location.origin), { method: opts.method.toUpperCase() }),
510
+ params: paramsRef.current,
511
+ formData: body instanceof FormData ? body : new FormData(),
512
+ serverAction: () => { calledServer = true; return doServerPost(); },
513
+ });
514
+ // If the clientAction triggered a redirect via serverAction(), stop.
515
+ if (calledServer && data === REDIRECTED) return;
516
+ } else {
517
+ data = await doServerPost();
518
+ if (data === REDIRECTED) return;
519
+ }
520
+
421
521
  setActionData(data);
422
522
  setNavState("loading");
423
- await revalidate({ formMethod: opts.method, actionStatus: res.status });
523
+ await revalidate({ formMethod: opts.method, actionStatus: lastStatus });
424
524
  } finally {
425
525
  setNavState("idle");
426
526
  }
427
- }, [revalidate]);
527
+ }, [revalidate, manifest]);
428
528
 
429
529
  return (
430
530
  <RouterContext.Provider
431
531
  value={{
432
- loaderData, actionData, params, pathname: location.pathname, location, search,
532
+ loaderData, actionData, params, pathname: location.pathname, location, search, matches,
433
533
  manifest, currentModule, setRoute, revalidate, revalidationState, hydrationPending,
434
534
  }}
435
535
  >
@@ -0,0 +1,32 @@
1
+ import { useContext } from "react";
2
+ import { RouterContext } from "../router.tsx";
3
+ import { BractJSContext } from "../../shared/context.ts";
4
+ import type { RouteMatch } from "../../shared/route-types.ts";
5
+
6
+ /**
7
+ * Returns the matched route chain, outermost → innermost: the root, then each
8
+ * layout, then the leaf route. Each entry exposes `{ id, pathname, params,
9
+ * data, handle }`, where `handle` is that module's static `handle` export.
10
+ *
11
+ * Use it to build breadcrumbs or conditional chrome from `handle` without
12
+ * threading props through every layout. Works in both SSR and client contexts;
13
+ * the chain updates on soft navigation and revalidation.
14
+ *
15
+ * ```tsx
16
+ * // routes/blog/[id].tsx
17
+ * export const handle = { breadcrumb: "Post" };
18
+ *
19
+ * // some layout
20
+ * const crumbs = useMatches()
21
+ * .filter((m) => m.handle?.breadcrumb)
22
+ * .map((m) => m.handle!.breadcrumb as string);
23
+ * ```
24
+ *
25
+ * `handle` must be JSON-serializable — it travels in the SSR bootstrap and the
26
+ * `/_data` soft-nav payload, the same as loader data.
27
+ */
28
+ export function useMatches(): RouteMatch[] {
29
+ const router = useContext(RouterContext);
30
+ const bract = useContext(BractJSContext);
31
+ return router?.matches ?? bract?.matches ?? [];
32
+ }
@@ -1,6 +1,6 @@
1
1
  import { createContext, useContext, type ComponentType } from "react";
2
2
  import type { ServerManifest } from "../server/render.ts";
3
- import type { RouterLocation } from "../shared/route-types.ts";
3
+ import type { RouterLocation, RouteMatch } from "../shared/route-types.ts";
4
4
 
5
5
  // ── Route module shape visible on the client ───────────────────────────────
6
6
 
@@ -9,6 +9,10 @@ export interface RouteModuleClient {
9
9
  ErrorBoundary?: ComponentType<{ error: Error }>;
10
10
  /** SSR'd placeholder for selective-SSR routes (`ssr: false` / `"data-only"`). */
11
11
  Fallback?: ComponentType;
12
+ /** Browser-side loader (RR7-style). Runs on navigation instead of just fetching /_data. */
13
+ clientLoader?: import("../shared/route-types.ts").ClientLoaderFunction;
14
+ /** Browser-side action (RR7-style). Runs on submit instead of POSTing directly. */
15
+ clientAction?: import("../shared/route-types.ts").ClientActionFunction;
12
16
  }
13
17
 
14
18
  /**
@@ -30,6 +34,8 @@ export interface RouteState {
30
34
  location: RouterLocation;
31
35
  /** Validated search params (route `searchSchema` output; raw string record otherwise). */
32
36
  search: Record<string, unknown>;
37
+ /** The matched route chain (root → layouts → route) for `useMatches()`. */
38
+ matches: RouteMatch[];
33
39
  }
34
40
 
35
41
  export interface RouterContextValue extends RouteState {
package/src/client/rpc.ts CHANGED
@@ -49,9 +49,19 @@ export function createClient<
49
49
  const httpMethod = method.toUpperCase();
50
50
  const url = baseUrl + path;
51
51
  const hasBody = httpMethod !== "GET" && httpMethod !== "DELETE" && input !== undefined;
52
+ // Send the custom CSRF marker on every mutating call. The server's
53
+ // /api CSRF gate accepts Sec-Fetch-Site / Origin too, but this
54
+ // header keeps same-origin calls working even behind proxies that
55
+ // strip those — and it can't be set cross-origin without a CORS
56
+ // preflight the framework's CORS never grants. Mirrors the
57
+ // server-action proxy in src/build/directives.ts.
58
+ const isMutating = httpMethod !== "GET";
59
+ const headers: Record<string, string> = {};
60
+ if (hasBody) headers["Content-Type"] = "application/json";
61
+ if (isMutating) headers["X-BractJS-Action"] = "1";
52
62
  const res = await fetch(url, {
53
63
  method: httpMethod,
54
- headers: hasBody ? { "Content-Type": "application/json" } : undefined,
64
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
55
65
  body: hasBody ? JSON.stringify(input) : undefined,
56
66
  });
57
67
  if (!res.ok) {
@@ -1,5 +1,5 @@
1
1
  import { join, resolve } from "node:path";
2
- import { scanRoutes, type RouteFile } from "../server/scanner.ts";
2
+ import { scanRoutes, layoutDirsFromFilePath, type RouteFile } from "../server/scanner.ts";
3
3
 
4
4
  // Codegen entry-points: `bun build --compile` can't statically trace
5
5
  // `Bun.Glob` scans or `import(absPath)` calls, so we materialise the route /
@@ -9,13 +9,14 @@ import { scanRoutes, type RouteFile } from "../server/scanner.ts";
9
9
 
10
10
  // ── Path safety ────────────────────────────────────────────────────────────
11
11
 
12
- // Allow ASCII filename characters, `/` for nested directories, and `[`/`]`
13
- // for file-based dynamic route syntax (`[id]`, `[...slug]`). All emit sites
14
- // wrap the path in JSON.stringify, but we still allowlist the charset as
15
- // defense-in-depth against a hostile filename containing a backtick, $, quote,
16
- // backslash, or whitespace breaking out of the generated literal. `..` as a
17
- // whole segment is rejected separately below (path-traversal guard).
18
- const SAFE_FILEPATH_RE = /^[A-Za-z0-9._\/\-\[\]]+$/;
12
+ // Allow ASCII filename characters, `/` for nested directories, `[`/`]` for
13
+ // file-based dynamic route syntax (`[id]`, `[...slug]`, `[[id]]`), and `(`/`)`
14
+ // for route-group folders (`(marketing)`). All emit sites wrap the path in
15
+ // JSON.stringify, but we still allowlist the charset as defense-in-depth
16
+ // against a hostile filename containing a backtick, $, quote, backslash, or
17
+ // whitespace breaking out of the generated literal. `..` as a whole segment is
18
+ // rejected separately below (path-traversal guard).
19
+ const SAFE_FILEPATH_RE = /^[A-Za-z0-9._\/\-\[\]()]+$/;
19
20
 
20
21
  function assertSafeFilePath(filePath: string): void {
21
22
  if (!SAFE_FILEPATH_RE.test(filePath)) {
@@ -37,26 +38,17 @@ function pathToIdent(prefix: string, relPath: string): string {
37
38
 
38
39
  // ── Layout discovery ───────────────────────────────────────────────────────
39
40
 
40
- function layoutDirsForPattern(urlPattern: string): string[] {
41
- if (urlPattern === "") return [];
42
- const segments = urlPattern.split("/");
43
- segments.pop();
44
- const dirs: string[] = [];
45
- for (let i = 1; i <= segments.length; i++) {
46
- dirs.push(segments.slice(0, i).join("/"));
47
- }
48
- return dirs;
49
- }
50
-
51
41
  /**
52
42
  * Find every `routes/<dir>/layout.tsx` (or `.ts`) that exists on disk for the
53
43
  * given set of routes. Mirrors the runtime probe in `resolveLayoutChain` but
54
- * runs once at codegen time so the generated registry is exhaustive.
44
+ * runs once at codegen time so the generated registry is exhaustive. Layout
45
+ * dirs are derived from each route's FILE path (via `layoutDirsFromFilePath`)
46
+ * so route-group folders are covered identically to the runtime.
55
47
  */
56
48
  async function collectLayouts(appDir: string, routes: RouteFile[]): Promise<string[]> {
57
49
  const layoutPaths = new Set<string>();
58
50
  for (const route of routes) {
59
- for (const dir of layoutDirsForPattern(route.urlPattern)) {
51
+ for (const dir of layoutDirsFromFilePath(route.filePath)) {
60
52
  for (const ext of ["tsx", "ts"]) {
61
53
  const rel = `routes/${dir}/layout.${ext}`;
62
54
  const abs = resolve(join(appDir, rel));
@@ -3,11 +3,12 @@ import { scanRoutes } from "../server/scanner.ts";
3
3
  import type { Segment } from "../server/scanner.ts";
4
4
  import { hashString } from "../build/hash.ts";
5
5
 
6
- // Convert [param] / [...catchAll] notation to :param colon-style for URLs.
6
+ // Convert [param] / [[optional]] / [...catchAll] notation to :param colon-style.
7
7
  function patternToColon(urlPattern: string): string {
8
8
  if (urlPattern === "") return "/";
9
9
  return "/" + urlPattern.split("/").map((seg) => {
10
10
  if (seg.startsWith("[...") && seg.endsWith("]")) return ":" + seg.slice(4, -1);
11
+ if (seg.startsWith("[[") && seg.endsWith("]]")) return ":" + seg.slice(2, -2);
11
12
  if (seg.startsWith("[") && seg.endsWith("]")) return ":" + seg.slice(1, -1);
12
13
  return seg;
13
14
  }).join("/");
@@ -16,7 +17,9 @@ function patternToColon(urlPattern: string): string {
16
17
  function paramsFromSegments(segments: Segment[]): string[] {
17
18
  return segments.flatMap((seg) =>
18
19
  typeof seg === "string" ? [] :
19
- "param" in seg ? [seg.param] : [seg.catchAll],
20
+ "param" in seg ? [seg.param] :
21
+ "optional" in seg ? [seg.optional] :
22
+ [seg.catchAll],
20
23
  );
21
24
  }
22
25
 
@@ -36,7 +39,9 @@ function substituteParams(pattern: string, params: string[]): string {
36
39
  const SAFE_PATTERN_RE = /^\/(?:[A-Za-z0-9_\-]+|:[A-Za-z_][A-Za-z0-9_]*)(?:\/(?:[A-Za-z0-9_\-]+|:[A-Za-z_][A-Za-z0-9_]*))*$|^\/$/;
37
40
  const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
38
41
  // Same guard the module-registry codegen applies before emitting import paths.
39
- const SAFE_FILEPATH_RE = /^[A-Za-z0-9._\/\-\[\]]+$/;
42
+ // Parens are permitted for route-group folders like `(marketing)`; they are
43
+ // inert inside the double-quoted import string the codegen emits.
44
+ const SAFE_FILEPATH_RE = /^[A-Za-z0-9._\/\-\[\]()]+$/;
40
45
 
41
46
  function assertSafePattern(pattern: string): void {
42
47
  if (!SAFE_PATTERN_RE.test(pattern)) {
@@ -26,6 +26,7 @@ export function validateUserConfig(cfg: unknown): Partial<BractJSConfig> {
26
26
 
27
27
  check("port", typeof c.port === "number" && Number.isFinite(c.port), "a finite number");
28
28
  check("hmrPort", typeof c.hmrPort === "number" && Number.isFinite(c.hmrPort), "a finite number");
29
+ check("maxRequestBodySize", typeof c.maxRequestBodySize === "number" && Number.isFinite(c.maxRequestBodySize) && c.maxRequestBodySize > 0, "a positive finite number");
29
30
  check("appDir", typeof c.appDir === "string", "a string");
30
31
  check("publicDir", typeof c.publicDir === "string", "a string");
31
32
  check("buildDir", typeof c.buildDir === "string", "a string");
package/src/index.ts CHANGED
@@ -4,9 +4,10 @@ export { buildFetchHandler } from "./server/serve.ts";
4
4
  export { defineContext } from "./server/context.ts";
5
5
  export type { ContextFactory } from "./server/context.ts";
6
6
  export { route } from "./server/api-route.ts";
7
- export type { ApiRouteDefinition, AppApiRoutes } from "./server/api-route.ts";
7
+ export type { ApiRouteDefinition, ApiRouteOptions, AppApiRoutes } from "./server/api-route.ts";
8
8
  export { validate, safeValidate, isValidationResponse, readValidationError } from "./server/validate.ts";
9
9
  export type { FieldErrors, ValidationError, SafeValidateResult } from "./server/validate.ts";
10
+ export { hasForbiddenKey, nullProtoFromEntries } from "./server/proto-guard.ts";
10
11
  export { formText, formValues } from "./shared/form-data.ts";
11
12
  export { defineActions } from "./shared/define-actions.ts";
12
13
  export { validateSearch, searchParamsToObject } from "./server/search.ts";
@@ -71,6 +72,12 @@ export type {
71
72
  LoaderFunction,
72
73
  ActionFunction,
73
74
  MetaFunction,
75
+ HeadersFunction,
76
+ HeadersArgs,
77
+ RouteMiddlewareFunction,
78
+ ClientLoaderFunction,
79
+ ClientActionFunction,
80
+ RouteMatch,
74
81
  RouteModule,
75
82
  RouteDefinition,
76
83
  RouterLocation,
@@ -87,8 +94,8 @@ export { BractJSContext, BractJSProvider, useBractJSContext } from "./shared/con
87
94
  export type { BractJSContextValue, RouteManifest } from "./shared/context.ts";
88
95
 
89
96
  // Middleware
90
- export { pipeline, MiddlewarePipeline } from "./server/middleware.ts";
91
- export type { MiddlewareFn, MiddlewareContext } from "./server/middleware.ts";
97
+ export { pipeline, MiddlewarePipeline, runRouteMiddleware, collectRouteMiddleware } from "./server/middleware.ts";
98
+ export type { MiddlewareFn, MiddlewareContext, RouteMiddleware } from "./server/middleware.ts";
92
99
  export { requestLogger } from "./middleware/requestLogger.ts";
93
100
  export { cors } from "./middleware/cors.ts";
94
101
  export type { CorsOptions } from "./middleware/cors.ts";
@@ -118,6 +125,7 @@ export { useLoaderData } from "./client/hooks/useLoaderData.ts";
118
125
  export { useActionData } from "./client/hooks/useActionData.ts";
119
126
  export { useLocation } from "./client/hooks/useLocation.ts";
120
127
  export { useParams } from "./client/hooks/useParams.ts";
128
+ export { useMatches } from "./client/hooks/useMatches.ts";
121
129
  export { useNavigation } from "./client/hooks/useNavigation.ts";
122
130
  export { useNavigate } from "./client/hooks/useNavigate.ts";
123
131
  export type { NavigateFn, NavigateOptions } from "./client/hooks/useNavigate.ts";
@@ -1,31 +1,12 @@
1
1
  import { resolveAction } from "./action-registry.ts";
2
2
  import { json } from "./response.ts";
3
3
  import { isAllowedMutation, csrfForbiddenResponse } from "./csrf.ts";
4
+ import { hasForbiddenKey } from "./proto-guard.ts";
4
5
 
5
- const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
6
6
  // Cap action JSON bodies. Anything over this looks like an abuse attempt;
7
7
  // FormData uploads (large files) take the multipart branch and bypass this.
8
8
  const MAX_JSON_BODY_BYTES = 1_048_576; // 1 MiB
9
9
 
10
- // Max nesting we will fully scan for forbidden keys. Legitimate action payloads
11
- // are shallow; anything deeper is treated as hostile.
12
- const MAX_SCAN_DEPTH = 200;
13
-
14
- // Deep scan: nested objects can carry __proto__ pollution vectors too.
15
- // SECURITY(high): this is a security filter, so it must FAIL CLOSED. A payload
16
- // nested past MAX_SCAN_DEPTH is rejected (returns true) rather than silently
17
- // passed — otherwise an attacker could bury `__proto__` below the cap to evade
18
- // the check and reach a recursive-merge sink in action code.
19
- function hasForbiddenKey(value: unknown, depth = 0): boolean {
20
- if (!value || typeof value !== "object") return false;
21
- if (depth > MAX_SCAN_DEPTH) return true;
22
- for (const key of Object.keys(value as Record<string, unknown>)) {
23
- if (FORBIDDEN_KEYS.has(key)) return true;
24
- if (hasForbiddenKey((value as Record<string, unknown>)[key], depth + 1)) return true;
25
- }
26
- return false;
27
- }
28
-
29
10
  export async function handleActionRequest(request: Request): Promise<Response | null> {
30
11
  const url = new URL(request.url);
31
12
  // SECURITY(medium): exact-match prevents URL confusion (e.g. "/_actionfoo"
@@ -22,9 +22,24 @@ export interface BractAdapter {
22
22
  * Default adapter — wraps `Bun.serve()`.
23
23
  * Created internally by `createServer()` when no adapter is provided.
24
24
  */
25
+ // SECURITY(medium): hard ceiling on request body size at the server boundary,
26
+ // independent of any Content-Length the client advertises. The per-route and
27
+ // /_action handlers apply their own (smaller) caps and double-check the decoded
28
+ // size, but this is the single backstop every code path inherits — it bounds
29
+ // memory even for paths that don't pre-check (e.g. an app's own /api handler
30
+ // that reads request.formData() directly). Sits above the 10 MiB route-form
31
+ // cap so legitimate uploads still pass; raise it via the `maxRequestBodySize`
32
+ // config for apps with a dedicated large-upload endpoint.
33
+ const DEFAULT_MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024; // 16 MiB
34
+
25
35
  export class BunAdapter implements BractAdapter {
26
36
  private server: ReturnType<typeof Bun.serve> | null = null;
27
37
  private handler: ((request: Request) => Promise<Response>) | null = null;
38
+ private maxRequestBodySize: number;
39
+
40
+ constructor(maxRequestBodySize: number = DEFAULT_MAX_REQUEST_BODY_BYTES) {
41
+ this.maxRequestBodySize = maxRequestBodySize;
42
+ }
28
43
 
29
44
  setHandler(handler: (request: Request) => Promise<Response>): void {
30
45
  this.handler = handler;
@@ -40,6 +55,7 @@ export class BunAdapter implements BractAdapter {
40
55
  const handler = this.handler;
41
56
  this.server = Bun.serve({
42
57
  port,
58
+ maxRequestBodySize: this.maxRequestBodySize,
43
59
  fetch: handler,
44
60
  error(err: Error) {
45
61
  console.error("[bractjs] unhandled server error:", err);