@bractjs/bractjs 0.1.27 → 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 (117) hide show
  1. package/bin/cli.ts +18 -1
  2. package/package.json +3 -2
  3. package/src/__tests__/codegen-write.test.ts +67 -0
  4. package/src/__tests__/codegen.test.ts +29 -2
  5. package/src/__tests__/compile-safety.test.ts +4 -0
  6. package/src/__tests__/csp.test.ts +10 -0
  7. package/src/__tests__/define-actions.test.ts +69 -0
  8. package/src/__tests__/env.test.ts +18 -0
  9. package/src/__tests__/fetcher-store.test.ts +67 -0
  10. package/src/__tests__/fixtures/app/root.tsx +7 -2
  11. package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
  12. package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
  13. package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
  14. package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
  15. package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
  16. package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
  17. package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
  18. package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
  19. package/src/__tests__/form-data-helpers.test.ts +43 -0
  20. package/src/__tests__/headers.test.ts +111 -0
  21. package/src/__tests__/integration.test.ts +90 -0
  22. package/src/__tests__/layout-registry.test.ts +7 -3
  23. package/src/__tests__/loader.test.ts +32 -1
  24. package/src/__tests__/matcher.test.ts +29 -0
  25. package/src/__tests__/module-registry.test.ts +2 -3
  26. package/src/__tests__/nav-utils.test.ts +46 -0
  27. package/src/__tests__/prerender.test.ts +102 -0
  28. package/src/__tests__/programmatic-api.test.ts +20 -1
  29. package/src/__tests__/revalidation.test.ts +65 -0
  30. package/src/__tests__/route-lint.test.ts +79 -0
  31. package/src/__tests__/route-middleware.test.ts +84 -0
  32. package/src/__tests__/route-table.test.ts +33 -0
  33. package/src/__tests__/safe-validate.test.ts +96 -0
  34. package/src/__tests__/scanner.test.ts +46 -1
  35. package/src/__tests__/scroll-restoration.test.ts +66 -0
  36. package/src/__tests__/search-serializer.test.ts +42 -0
  37. package/src/__tests__/search-validation.test.ts +125 -0
  38. package/src/__tests__/security-fixes.test.ts +201 -0
  39. package/src/__tests__/security.test.ts +110 -1
  40. package/src/__tests__/selective-ssr.test.ts +85 -0
  41. package/src/__tests__/spa-mode.test.ts +77 -0
  42. package/src/__tests__/typed-routing.test.ts +51 -1
  43. package/src/__tests__/use-matches.test.ts +54 -0
  44. package/src/build/bundler.ts +33 -0
  45. package/src/build/prerender.ts +88 -0
  46. package/src/build/route-lint.ts +49 -0
  47. package/src/client/ClientRouter.tsx +339 -47
  48. package/src/client/cache.ts +8 -0
  49. package/src/client/components/Await.tsx +9 -2
  50. package/src/client/components/Form.tsx +23 -34
  51. package/src/client/components/Link.tsx +80 -9
  52. package/src/client/components/Outlet.tsx +8 -2
  53. package/src/client/components/ScrollRestoration.tsx +125 -0
  54. package/src/client/entry.tsx +39 -2
  55. package/src/client/fetcher-store.ts +61 -0
  56. package/src/client/form-utils.ts +3 -0
  57. package/src/client/hooks/useActionData.ts +7 -3
  58. package/src/client/hooks/useFetcher.ts +116 -33
  59. package/src/client/hooks/useFetchers.ts +23 -0
  60. package/src/client/hooks/useLoaderData.ts +8 -4
  61. package/src/client/hooks/useLocation.ts +27 -0
  62. package/src/client/hooks/useMatches.ts +32 -0
  63. package/src/client/hooks/useNavigate.ts +11 -6
  64. package/src/client/hooks/useRevalidator.ts +26 -0
  65. package/src/client/hooks/useSearch.ts +73 -0
  66. package/src/client/hooks/useSearchParams.ts +7 -2
  67. package/src/client/nav-utils.ts +26 -0
  68. package/src/client/prefetch.ts +110 -15
  69. package/src/client/registry.ts +24 -0
  70. package/src/client/revalidation.ts +25 -0
  71. package/src/client/router.tsx +34 -1
  72. package/src/client/rpc.ts +11 -1
  73. package/src/client/scroll-restoration.ts +48 -0
  74. package/src/client/search-serializer.ts +40 -0
  75. package/src/client/types.ts +6 -0
  76. package/src/codegen/module-registry.ts +13 -21
  77. package/src/codegen/route-codegen.ts +148 -10
  78. package/src/config/load.ts +22 -0
  79. package/src/dev/hmr-client.ts +3 -1
  80. package/src/dev/route-table.ts +27 -0
  81. package/src/dev/server.ts +106 -8
  82. package/src/dev/watcher.ts +25 -3
  83. package/src/index.ts +38 -6
  84. package/src/server/action-handler.ts +3 -13
  85. package/src/server/action-registry.ts +35 -0
  86. package/src/server/adapter.ts +16 -0
  87. package/src/server/api-route.ts +47 -0
  88. package/src/server/csp.ts +19 -4
  89. package/src/server/csrf.ts +36 -3
  90. package/src/server/env.ts +26 -5
  91. package/src/server/headers.ts +49 -0
  92. package/src/server/layout.ts +43 -20
  93. package/src/server/loader.ts +14 -8
  94. package/src/server/matcher.ts +29 -2
  95. package/src/server/matches.ts +50 -0
  96. package/src/server/middleware.ts +66 -0
  97. package/src/server/proto-guard.ts +56 -0
  98. package/src/server/render.ts +51 -18
  99. package/src/server/request-handler.ts +111 -29
  100. package/src/server/scanner.ts +45 -3
  101. package/src/server/search.ts +47 -0
  102. package/src/server/serve.ts +116 -4
  103. package/src/server/session.ts +12 -1
  104. package/src/server/spa.ts +62 -0
  105. package/src/server/stream-handler.ts +10 -1
  106. package/src/server/validate.ts +89 -14
  107. package/src/shared/context.ts +7 -0
  108. package/src/shared/define-actions.ts +39 -0
  109. package/src/shared/form-data.ts +34 -0
  110. package/src/shared/route-types.ts +191 -2
  111. package/templates/new-app/app/root.tsx +2 -1
  112. package/templates/new-app/bractjs.config.ts +7 -12
  113. package/types/config.d.ts +24 -0
  114. package/types/index.d.ts +182 -9
  115. package/types/route.d.ts +138 -3
  116. package/LICENSE +0 -21
  117. package/README.md +0 -1125
@@ -21,6 +21,7 @@ export async function safeRun<T>(
21
21
  fn: ((args: LoaderArgs) => Promise<T> | T) | undefined,
22
22
  args: LoaderArgs,
23
23
  onError?: OnErrorHook,
24
+ where?: string,
24
25
  ): Promise<T | { __error: unknown } | null> {
25
26
  if (!fn) return null;
26
27
 
@@ -36,12 +37,14 @@ export async function safeRun<T>(
36
37
  // dev we surface the real message + stack for DX. Routes wanting to
37
38
  // surface structured user-facing errors should throw an HttpError, not
38
39
  // a custom Error subclass.
39
- console.error("[bractjs] loader error:", err);
40
+ // Name the failing module so the log/overlay points at the right file.
41
+ console.error(`[bractjs] loader error${where ? ` in ${where}` : ""}:`, err);
40
42
  await fireOnError(onError, err, args.request);
41
43
  const safe = isExplicitDev()
42
44
  ? {
43
45
  message: err instanceof Error ? err.message : String(err),
44
46
  stack: err instanceof Error ? err.stack : undefined,
47
+ routeFile: where,
45
48
  }
46
49
  : { message: "Internal Server Error" };
47
50
  return { __error: safe };
@@ -60,7 +63,7 @@ export async function runBeforeLoad(
60
63
  args: LoaderArgs,
61
64
  ): Promise<Response | null> {
62
65
  const fn = routeModule.beforeLoad as
63
- | ((a: { params: Record<string,string>; context: Record<string,unknown>; location: { pathname: string; search: string } }) => unknown)
66
+ | ((a: { params: Record<string,string>; context: Record<string,unknown>; location: { pathname: string; search: string }; search?: Record<string, unknown> }) => unknown)
64
67
  | undefined;
65
68
  if (!fn) return null;
66
69
  const url = new URL(args.request.url);
@@ -68,6 +71,7 @@ export async function runBeforeLoad(
68
71
  params: args.params,
69
72
  context: args.context,
70
73
  location: { pathname: url.pathname, search: url.search },
74
+ search: args.search,
71
75
  });
72
76
  if (result instanceof Response) return result;
73
77
  return null;
@@ -83,13 +87,14 @@ export async function runLoaders(
83
87
  // Run every loader in the chain concurrently — root, all layouts, and the
84
88
  // route loader. The route loader is usually the slowest and most important
85
89
  // one, so it must not be serialized behind the layout wave.
86
- const layoutLoaders = chain.layouts.map((mod) =>
87
- safeRun(mod.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError)
90
+ const files = chain.files;
91
+ const layoutLoaders = chain.layouts.map((mod, i) =>
92
+ safeRun(mod.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError, files?.layouts[i])
88
93
  );
89
94
 
90
95
  const [root, route, ...layoutResults] = await Promise.all([
91
- safeRun(chain.root.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError),
92
- safeRun(chain.route.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError),
96
+ safeRun(chain.root.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError, files?.root),
97
+ safeRun(chain.route.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError, files?.route),
93
98
  ...layoutLoaders,
94
99
  ]);
95
100
 
@@ -118,9 +123,10 @@ export async function runAction(
118
123
  export function buildLoaderArgs(
119
124
  request: Request,
120
125
  params: Record<string, string>,
121
- context: Record<string, unknown>
126
+ context: Record<string, unknown>,
127
+ search: Record<string, unknown> = {},
122
128
  ): LoaderArgs {
123
- return { request, params, context };
129
+ return { request, params, context, search };
124
130
  }
125
131
 
126
132
  // ── runRouteContext ────────────────────────────────────────────────────────
@@ -5,6 +5,13 @@ import type { RouteFile, Segment } from "./scanner.ts";
5
5
  export interface TrieNode {
6
6
  children: Map<string, TrieNode>;
7
7
  paramChild?: { name: string; node: TrieNode };
8
+ /**
9
+ * An optional param segment (`[[id]]`). When present it behaves like a param
10
+ * child (binds `name` to the consumed part); the matcher additionally tries
11
+ * skipping it entirely, so the route at `node` matches with the segment
12
+ * absent too (the param is then simply not set).
13
+ */
14
+ optionalChild?: { name: string; node: TrieNode };
8
15
  catchAllChild?: { name: string; node: TrieNode };
9
16
  routeFile?: RouteFile;
10
17
  }
@@ -33,6 +40,9 @@ export function buildTrie(routes: RouteFile[]): TrieNode {
33
40
  } else if ("param" in seg) {
34
41
  if (!node.paramChild) node.paramChild = { name: seg.param, node: makeNode() };
35
42
  node = node.paramChild.node;
43
+ } else if ("optional" in seg) {
44
+ if (!node.optionalChild) node.optionalChild = { name: seg.optional, node: makeNode() };
45
+ node = node.optionalChild.node;
36
46
  } else {
37
47
  // catchAll — terminal, store and stop
38
48
  if (!node.catchAllChild) node.catchAllChild = { name: seg.catchAll, node: makeNode() };
@@ -62,7 +72,13 @@ function walk(
62
72
  ): MatchResult {
63
73
  // All parts consumed — check for route at this node
64
74
  if (idx === parts.length) {
65
- return node.routeFile ? { routeFile: node.routeFile, params } : null;
75
+ if (node.routeFile) return { routeFile: node.routeFile, params };
76
+ // An optional param's segment was omitted (e.g. /users for [[id]]). The
77
+ // route lives one node deeper; the param is simply left unset.
78
+ if (node.optionalChild?.node.routeFile) {
79
+ return { routeFile: node.optionalChild.node.routeFile, params };
80
+ }
81
+ return null;
66
82
  }
67
83
 
68
84
  const part = parts[idx];
@@ -83,7 +99,18 @@ function walk(
83
99
  if (result) return result;
84
100
  }
85
101
 
86
- // 3. Try catch-allconsumes remaining segments
102
+ // 3. Try optional param consume this part as the param (the "present"
103
+ // case). The "absent" case is handled at the all-parts-consumed branch
104
+ // above. Param-before-catch-all keeps optional more specific than splat.
105
+ if (node.optionalChild) {
106
+ const result = walk(node.optionalChild.node, parts, idx + 1, {
107
+ ...params,
108
+ [node.optionalChild.name]: part,
109
+ });
110
+ if (result) return result;
111
+ }
112
+
113
+ // 4. Try catch-all — consumes remaining segments
87
114
  if (node.catchAllChild) {
88
115
  const remaining = parts.slice(idx).join("/");
89
116
  const catchNode = node.catchAllChild.node;
@@ -0,0 +1,50 @@
1
+ import type { LayoutChain } from "./layout.ts";
2
+ import type { LoaderResults } from "./loader.ts";
3
+ import type { RouteMatch } from "../shared/route-types.ts";
4
+
5
+ /**
6
+ * Build the `useMatches()` payload: one entry per module in the chain
7
+ * (root → layouts → route), pairing each module's static `handle` export with
8
+ * its loader-data slice. Serialized into the SSR bootstrap and `/_data` so the
9
+ * client can read it without re-importing every module.
10
+ *
11
+ * `handle` must be JSON-serializable to survive the SSR/soft-nav transport —
12
+ * the same constraint loader data already has.
13
+ */
14
+ export function buildMatches(
15
+ chain: LayoutChain,
16
+ loaderData: LoaderResults,
17
+ params: Record<string, string>,
18
+ pathname: string,
19
+ ): RouteMatch[] {
20
+ const matches: RouteMatch[] = [];
21
+ const files = chain.files;
22
+
23
+ matches.push({
24
+ id: files?.root ?? "root",
25
+ pathname,
26
+ params,
27
+ data: loaderData.root,
28
+ handle: chain.root.handle,
29
+ });
30
+
31
+ chain.layouts.forEach((mod, i) => {
32
+ matches.push({
33
+ id: files?.layouts?.[i] ?? `layout:${i}`,
34
+ pathname,
35
+ params,
36
+ data: loaderData.layouts[i] ?? null,
37
+ handle: mod.handle,
38
+ });
39
+ });
40
+
41
+ matches.push({
42
+ id: files?.route ?? "route",
43
+ pathname,
44
+ params,
45
+ data: loaderData.route,
46
+ handle: chain.route.handle,
47
+ });
48
+
49
+ return matches;
50
+ }
@@ -22,6 +22,13 @@ export class MiddlewarePipeline {
22
22
  return this;
23
23
  }
24
24
 
25
+ /** Remove all registered middleware. Useful for tests and for embedders that
26
+ * rebuild the pipeline (e.g. on a hot reload). */
27
+ clear(): this {
28
+ this.fns = [];
29
+ return this;
30
+ }
31
+
25
32
  /**
26
33
  * Compose all registered middleware into a single chain and execute it.
27
34
  * Each fn calls `next()` to invoke the next fn; the last `next()` calls `handler`.
@@ -49,3 +56,62 @@ export class MiddlewarePipeline {
49
56
 
50
57
  /** Module-level default pipeline — attach middleware here via pipeline.use(). */
51
58
  export const pipeline = new MiddlewarePipeline();
59
+
60
+ // ── Per-route (nested) middleware ────────────────────────────────────────────
61
+
62
+ /**
63
+ * A route/layout/root module's `middleware` entry. Same shape as the global
64
+ * {@link MiddlewareFn}: call `next()` to continue the chain, or return a
65
+ * `Response` to short-circuit (auth gate, redirect). The `ctx.context` object
66
+ * is shared and mutable — set fields on it and downstream middleware, loaders,
67
+ * and actions see them.
68
+ */
69
+ export type RouteMiddleware = MiddlewareFn;
70
+
71
+ /**
72
+ * Compose a route's nested middleware chain (root → layouts → route, in that
73
+ * order) around `handler` and run it. Mirrors {@link MiddlewarePipeline.run}
74
+ * but for an ad-hoc, per-request list rather than the module-level pipeline.
75
+ * An empty list calls `handler` directly (zero overhead for routes that don't
76
+ * use middleware).
77
+ */
78
+ export function runRouteMiddleware(
79
+ fns: RouteMiddleware[],
80
+ ctx: MiddlewareContext,
81
+ handler: () => Promise<Response>,
82
+ ): Promise<Response> {
83
+ if (fns.length === 0) return handler();
84
+ let lastCalled = -1;
85
+ const dispatch = (i: number): Promise<Response> => {
86
+ if (i <= lastCalled) {
87
+ return Promise.reject(new Error("route middleware: next() called more than once"));
88
+ }
89
+ lastCalled = i;
90
+ if (i >= fns.length) return handler();
91
+ return fns[i](ctx, () => dispatch(i + 1));
92
+ };
93
+ return dispatch(0);
94
+ }
95
+
96
+ /**
97
+ * Flatten a route chain's `middleware` exports into a single ordered list:
98
+ * root first, then each layout outermost→innermost, then the leaf route. Each
99
+ * module may export `middleware` as a single fn or an array; both normalize
100
+ * here. Non-function entries are ignored defensively.
101
+ */
102
+ export function collectRouteMiddleware(chain: {
103
+ root: { middleware?: unknown };
104
+ layouts: Array<{ middleware?: unknown }>;
105
+ route: { middleware?: unknown };
106
+ }): RouteMiddleware[] {
107
+ const out: RouteMiddleware[] = [];
108
+ const add = (m: unknown) => {
109
+ if (!m) return;
110
+ const list = Array.isArray(m) ? m : [m];
111
+ for (const fn of list) if (typeof fn === "function") out.push(fn as RouteMiddleware);
112
+ };
113
+ add(chain.root.middleware);
114
+ for (const layout of chain.layouts) add(layout.middleware);
115
+ add(chain.route.middleware);
116
+ return out;
117
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Prototype-pollution guards, shared across every untrusted-object boundary
3
+ * (server actions, typed /api JSON, form/search → object conversions).
4
+ *
5
+ * Two strategies, used where each fits:
6
+ *
7
+ * 1. {@link hasForbiddenKey} — a deep scan that REJECTS a parsed JSON value
8
+ * carrying a dangerous key. Used on /_action and /api JSON bodies, where a
9
+ * 400 is the right UX and the payload shape is the app's contract.
10
+ *
11
+ * 2. {@link nullProtoFromEntries} — builds a null-prototype object from
12
+ * key/value pairs so a key literally named "__proto__" becomes an ordinary
13
+ * own property that can never reach Object.prototype. Used for the
14
+ * FormData / URLSearchParams → object conversions, which must accept
15
+ * arbitrary field names without erroring.
16
+ */
17
+
18
+ // `__proto__` is the actual pollution vector for own-keys produced by
19
+ // JSON.parse. `constructor`/`prototype` are included defensively: a recursive
20
+ // merge that walks `obj.constructor.prototype` can be steered by them too.
21
+ const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
22
+
23
+ // Max nesting we will fully scan. Legitimate payloads are shallow; anything
24
+ // deeper is treated as hostile and rejected (fail closed) — see hasForbiddenKey.
25
+ const MAX_SCAN_DEPTH = 200;
26
+
27
+ /**
28
+ * Deep scan for a forbidden key anywhere in a parsed JSON value.
29
+ *
30
+ * SECURITY(high): this is a security filter, so it FAILS CLOSED. A value nested
31
+ * past MAX_SCAN_DEPTH returns `true` (rejected) rather than being passed
32
+ * through — otherwise an attacker could bury `__proto__` below the cap to evade
33
+ * the check and reach a recursive-merge sink in handler code.
34
+ */
35
+ export function hasForbiddenKey(value: unknown, depth = 0): boolean {
36
+ if (!value || typeof value !== "object") return false;
37
+ if (depth > MAX_SCAN_DEPTH) return true;
38
+ for (const key of Object.keys(value as Record<string, unknown>)) {
39
+ if (FORBIDDEN_KEYS.has(key)) return true;
40
+ if (hasForbiddenKey((value as Record<string, unknown>)[key], depth + 1)) return true;
41
+ }
42
+ return false;
43
+ }
44
+
45
+ /**
46
+ * Build a null-prototype object from [key, value] entries. A key named
47
+ * "__proto__" lands as a plain own property instead of mutating the prototype,
48
+ * so downstream spreads/merges of the result are pollution-safe.
49
+ */
50
+ export function nullProtoFromEntries<V>(
51
+ entries: Iterable<readonly [string, V]>,
52
+ ): Record<string, V> {
53
+ const out = Object.create(null) as Record<string, V>;
54
+ for (const [k, v] of entries) out[k] = v;
55
+ return out;
56
+ }
@@ -1,7 +1,7 @@
1
1
  import { renderToReadableStream } from "react-dom/server";
2
2
  import { createElement, Fragment, type ReactNode } from "react";
3
- import type { MetaDescriptor } from "../shared/route-types.ts";
4
- import { safeStringify, isDevRuntime } from "./env.ts";
3
+ import type { MetaDescriptor, RouteMatch } from "../shared/route-types.ts";
4
+ import { safeStringify, isDevRuntime, getDevHmrPort } from "./env.ts";
5
5
  import { errorOverlayScript } from "../dev/error-overlay.ts";
6
6
  import { mergeMeta } from "./meta.ts";
7
7
  import { MetaTags } from "../shared/meta-tags.tsx";
@@ -18,13 +18,29 @@ export interface RenderOptions {
18
18
  actionData: unknown;
19
19
  params: Record<string, string>;
20
20
  pathname: string;
21
+ /** Validated search params — hydrates `useSearch()` so the client never re-validates. */
22
+ search?: Record<string, unknown>;
21
23
  manifest: ServerManifest;
22
24
  meta: MetaDescriptor[];
25
+ /** The matched route chain (root → layouts → route) for `useMatches()`. */
26
+ matches?: RouteMatch[];
23
27
  status?: number;
24
28
  /** Path of the matched route file (e.g. "routes/_index.tsx"), used by the client to pre-import the module before hydration. */
25
29
  routeFile?: string;
26
30
  /** Per-request CSP nonce (set by the opt-in `csp()` middleware). Applied to the inline bootstrap script + client entry module tags. */
27
31
  nonce?: string;
32
+ /**
33
+ * Set when the document did NOT SSR the route component: the client renders
34
+ * the Fallback during hydration, then swaps in the real component
35
+ * ("data-only": data already present; "client-only": after a /_data fetch;
36
+ * "spa": static shell, everything resolved client-side).
37
+ */
38
+ ssrMode?: "client-only" | "data-only" | "spa";
39
+ /**
40
+ * Resolved route `headers()` output (root → layout → route merged). Applied
41
+ * on top of the baseline document headers, overriding any same-key default.
42
+ */
43
+ headers?: Headers | null;
28
44
  }
29
45
 
30
46
  export async function renderRoute(options: RenderOptions): Promise<Response> {
@@ -38,13 +54,19 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
38
54
  status = 200,
39
55
  } = options;
40
56
 
41
- const devFlag = isDevRuntime() ? "window.__BRACT_DEV__=true;" : "";
57
+ // In dev, publish the HMR port so the injected client connects to the
58
+ // configured `hmrPort` rather than a hardcoded 3001. 0 → omit (client
59
+ // defaults to 3001).
60
+ const hmrPort = isDevRuntime() ? getDevHmrPort() : 0;
61
+ const devFlag = isDevRuntime()
62
+ ? "window.__BRACT_DEV__=true;" + (hmrPort ? `window.__BRACTJS_HMR_PORT__=${hmrPort};` : "")
63
+ : "";
42
64
  const devOverlay = isDevRuntime() ? devFlag + errorOverlayScript + "\n" : "";
43
65
  const mergedMeta = mergeMeta(options.meta ?? []);
44
66
  // The merged descriptor array is what the client reads to keep the document
45
67
  // head in sync on soft navigation — keep it shaped, not stringified HTML.
46
68
  const bootstrapScriptContent =
47
- devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, manifest, routeFile: options.routeFile, meta: mergedMeta })};`;
69
+ devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, search: options.search, manifest, routeFile: options.routeFile, meta: mergedMeta, matches: options.matches, ssrMode: options.ssrMode })};`;
48
70
 
49
71
  // Render <title>/<meta> elements alongside the app shell. React 19 hoists
50
72
  // document-metadata elements into <head> during streaming SSR, so crawlers
@@ -75,19 +97,30 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
75
97
 
76
98
  const responseStatus = renderError ? 500 : status;
77
99
 
78
- return new Response(stream, {
79
- status: responseStatus,
80
- headers: {
81
- "Content-Type": "text/html; charset=utf-8",
82
- "Transfer-Encoding": "chunked",
83
- // SECURITY(medium): baseline hardening headers. For a Content-Security-
84
- // Policy, opt into the nonce-based `csp()` middleware — it generates a
85
- // per-request nonce, applies it to the inline bootstrap script + client
86
- // entry module here (via renderToReadableStream's `nonce` option), and
87
- // sets the CSP response header.
88
- "X-Content-Type-Options": "nosniff",
89
- "X-Frame-Options": "SAMEORIGIN",
90
- "Referrer-Policy": "strict-origin-when-cross-origin",
91
- },
100
+ const headers = new Headers({
101
+ "Content-Type": "text/html; charset=utf-8",
102
+ "Transfer-Encoding": "chunked",
103
+ // SECURITY(medium): baseline hardening headers. For a Content-Security-
104
+ // Policy, opt into the nonce-based `csp()` middleware — it generates a
105
+ // per-request nonce, applies it to the inline bootstrap script + client
106
+ // entry module here (via renderToReadableStream's `nonce` option), and
107
+ // sets the CSP response header.
108
+ "X-Content-Type-Options": "nosniff",
109
+ "X-Frame-Options": "SAMEORIGIN",
110
+ "Referrer-Policy": "strict-origin-when-cross-origin",
92
111
  });
112
+
113
+ // Route `headers()` output (root → layout → route) overrides the baseline.
114
+ // Content-Type / Transfer-Encoding stay framework-owned: a route shouldn't
115
+ // be able to corrupt the streamed document envelope. Don't apply on render
116
+ // errors — that path serves a generic 500, not the route's cached document.
117
+ if (options.headers && !renderError) {
118
+ options.headers.forEach((value, key) => {
119
+ const k = key.toLowerCase();
120
+ if (k === "content-type" || k === "transfer-encoding") return;
121
+ headers.set(key, value);
122
+ });
123
+ }
124
+
125
+ return new Response(stream, { status: responseStatus, headers });
93
126
  }
@@ -3,14 +3,17 @@ import type { TrieNode } from "./matcher.ts";
3
3
  import { matchRoute } from "./matcher.ts";
4
4
  import { resolveRouteChain, type ModuleRegistry } from "./layout.ts";
5
5
  import { runLoaders, runAction, buildLoaderArgs, runRouteContext, runBeforeLoad } from "./loader.ts";
6
+ import { validateSearch } from "./search.ts";
6
7
  import { renderRoute, type ServerManifest } from "./render.ts";
7
- import { resolveMeta } from "./meta.ts";
8
+ import { resolveMeta, mergeMeta } from "./meta.ts";
9
+ import { resolveHeaders } from "./headers.ts";
10
+ import { buildMatches } from "./matches.ts";
8
11
  import { json, error, sanitizeRedirect } from "./response.ts";
9
12
  import { isRedirect, isHttpError } from "../shared/errors.ts";
10
13
  import { isExplicitDev } from "./env.ts";
11
- import { pipeline, type MiddlewareContext } from "./middleware.ts";
14
+ import { runRouteMiddleware, collectRouteMiddleware, type MiddlewareContext } from "./middleware.ts";
12
15
  import { BractJSProvider } from "../shared/context.ts";
13
- import { isAllowedMutation } from "./csrf.ts";
16
+ import { isAllowedMutation, csrfForbiddenResponse } from "./csrf.ts";
14
17
  import { getCspNonce } from "./csp.ts";
15
18
  import { fireOnError, type OnErrorHook } from "./lifecycle.ts";
16
19
 
@@ -37,15 +40,15 @@ const MAX_FORM_BYTES = 10 * 1_048_576; // 10 MiB
37
40
  export async function handleRequest(
38
41
  request: Request,
39
42
  trie: TrieNode,
40
- config: HandlerConfig
43
+ config: HandlerConfig,
44
+ context: Record<string, unknown> = {},
41
45
  ): Promise<Response> {
42
- const ctx: MiddlewareContext = {
43
- request,
44
- params: {},
45
- context: {},
46
- };
47
-
48
- 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);
49
52
  }
50
53
 
51
54
  async function route(
@@ -87,22 +90,51 @@ async function route(
87
90
  headers: request.headers,
88
91
  method: "GET",
89
92
  });
93
+ // Validate search params before any route work runs — loaders must
94
+ // never see unvalidated input, and a 400 here is cheaper than a wasted
95
+ // context-factory/loader run. The thrown 400 Response propagates below.
96
+ const search = await validateSearch(chain.route.searchSchema, targetUrl);
97
+
90
98
  // SECURITY(high): /_data must run the same auth/redirect gates as a full
91
99
  // page request — otherwise a SPA-style soft navigation to a protected
92
- // route would bypass beforeLoad() / defineContext() and leak loader data.
93
- const routeContext = await runRouteContext(
94
- chain.route as Parameters<typeof runRouteContext>[0],
95
- loaderRequest,
96
- match.params,
97
- context,
98
- );
99
- const args = buildLoaderArgs(loaderRequest, match.params, routeContext);
100
- const beforeLoadResponse = await runBeforeLoad(chain.route, args);
101
- if (beforeLoadResponse) return beforeLoadResponse;
102
- const results = await runLoaders(chain, args, onError);
103
- return json({ root: results.root, layouts: results.layouts, route: results.route, params: match.params });
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
+ });
104
133
  } catch (err) {
105
134
  if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
135
+ // A non-redirect Response (e.g. the 400 thrown by search validation)
136
+ // is the intended reply — pass it through verbatim.
137
+ if (err instanceof Response) return err;
106
138
  if (isHttpError(err)) return json({ error: err.message }, { status: err.status });
107
139
  console.error("[bractjs] /_data error:", err);
108
140
  await fireOnError(onError, err, request);
@@ -115,14 +147,32 @@ async function route(
115
147
  if (!match) return error("Not Found", 404);
116
148
 
117
149
  const chain = await resolveRouteChain(match.routeFile, appDir, moduleRegistry);
150
+
151
+ // Validate search params before any route work (context factory, beforeLoad,
152
+ // action, loaders) — they all receive the validated object.
153
+ let search: Record<string, unknown>;
154
+ try {
155
+ search = await validateSearch(chain.route.searchSchema, url);
156
+ } catch (err) {
157
+ if (err instanceof Response) return err;
158
+ throw err;
159
+ }
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
+
118
168
  // Run per-route context factory (defineContext export) before loaders.
119
169
  const routeContext = await runRouteContext(
120
170
  chain.route as Parameters<typeof runRouteContext>[0],
121
171
  request,
122
172
  match.params,
123
- context,
173
+ mwCtx.context,
124
174
  );
125
- const args = buildLoaderArgs(request, match.params, routeContext);
175
+ const args = buildLoaderArgs(request, match.params, routeContext, search);
126
176
 
127
177
  // ── beforeLoad ────────────────────────────────────────────────────────
128
178
  const beforeLoadResponse = await runBeforeLoad(chain.route, args);
@@ -131,7 +181,7 @@ async function route(
131
181
  // ── Action (mutating methods) ─────────────────────────────────────────
132
182
  let actionData: unknown = null;
133
183
  if (MUTATING_METHODS.has(request.method)) {
134
- if (!isAllowedMutation(request)) return error("Forbidden", 403);
184
+ if (!isAllowedMutation(request)) return csrfForbiddenResponse();
135
185
  // Reject up front if the client advertises an oversized body.
136
186
  const clRaw = request.headers.get("Content-Length");
137
187
  if (clRaw) {
@@ -148,6 +198,8 @@ async function route(
148
198
  } catch (err) {
149
199
  if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
150
200
  if (isHttpError(err)) return error(err.message, err.status);
201
+ // Name the failing route so the log points at the right file.
202
+ console.error(`[bractjs] action error in ${chain.files?.route ?? match.routeFile.filePath}:`, err);
151
203
  await fireOnError(onError, err, request);
152
204
  if (isExplicitDev()) return error(err instanceof Error ? err.message : String(err), 500);
153
205
  return error("Internal Server Error", 500);
@@ -167,10 +219,20 @@ async function route(
167
219
  }
168
220
  }
169
221
 
222
+ // ── Selective SSR ─────────────────────────────────────────────────────
223
+ // `ssr: false` skips the ROUTE loader during document SSR (root/layout
224
+ // loaders still run — they render the shell). beforeLoad already ran above:
225
+ // it is the auth gate and must hold for every mode. The client completes
226
+ // the render via /_data after hydration, where the loader DOES run.
227
+ const routeSsr = chain.route.ssr ?? true;
228
+ const loaderChain = routeSsr === false
229
+ ? { ...chain, route: { ...chain.route, loader: undefined } }
230
+ : chain;
231
+
170
232
  // ── Loaders ───────────────────────────────────────────────────────────
171
233
  let loaderResults;
172
234
  try {
173
- loaderResults = await runLoaders(chain, args, onError);
235
+ loaderResults = await runLoaders(loaderChain, args, onError);
174
236
  } catch (err) {
175
237
  if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
176
238
  if (isHttpError(err)) return error(err.message, err.status);
@@ -187,7 +249,14 @@ async function route(
187
249
 
188
250
  // ── SSR render ────────────────────────────────────────────────────────
189
251
  const RootComponent = chain.root.default ?? (() => null);
190
- const RouteComponent = chain.route.default;
252
+ // Non-default SSR modes render the Fallback (or nothing) in the component's
253
+ // place; the client swaps in the real component after hydration.
254
+ const RouteComponent = routeSsr === true ? chain.route.default : chain.route.Fallback;
255
+ const ssrMode = routeSsr === true ? undefined : routeSsr === false ? "client-only" as const : "data-only" as const;
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);
191
260
 
192
261
  // Wrap root in BractJSProvider so <Outlet> can render the route component
193
262
  // server-side without needing a ClientRouter.
@@ -201,12 +270,19 @@ async function route(
201
270
  pathname,
202
271
  manifest: manifest as unknown as import("../shared/context.ts").RouteManifest,
203
272
  RouteComponent,
273
+ location: { pathname, search: url.search, hash: "", state: null, key: "default" },
274
+ search,
275
+ matches,
204
276
  },
205
277
  children: createElement(RootComponent),
206
278
  },
207
279
  );
208
280
 
209
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);
210
286
 
211
287
  return renderRoute({
212
288
  shell,
@@ -214,10 +290,16 @@ async function route(
214
290
  actionData,
215
291
  params: match.params,
216
292
  pathname,
293
+ search,
217
294
  manifest,
218
295
  meta,
296
+ matches,
297
+ headers: routeHeaders,
219
298
  routeFile: match.routeFile.filePath,
220
299
  // Set by the opt-in csp() middleware; undefined otherwise.
221
- nonce: getCspNonce(context),
300
+ nonce: getCspNonce(mwCtx.context),
301
+ ssrMode,
302
+ });
303
+
222
304
  });
223
305
  }