@bractjs/bractjs 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +283 -58
  2. package/bin/cli.ts +18 -1
  3. package/package.json +1 -1
  4. package/src/__tests__/build-path.test.ts +29 -0
  5. package/src/__tests__/codegen-write.test.ts +67 -0
  6. package/src/__tests__/codegen.test.ts +64 -1
  7. package/src/__tests__/compile-safety.test.ts +4 -0
  8. package/src/__tests__/csp.test.ts +10 -0
  9. package/src/__tests__/define-actions.test.ts +69 -0
  10. package/src/__tests__/env.test.ts +18 -0
  11. package/src/__tests__/fetcher-store.test.ts +67 -0
  12. package/src/__tests__/fixtures/app/root.tsx +7 -2
  13. package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
  14. package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
  15. package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
  16. package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
  17. package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
  18. package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
  19. package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
  20. package/src/__tests__/form-data-helpers.test.ts +43 -0
  21. package/src/__tests__/integration.test.ts +56 -0
  22. package/src/__tests__/loader.test.ts +32 -1
  23. package/src/__tests__/nav-utils.test.ts +46 -0
  24. package/src/__tests__/prerender.test.ts +102 -0
  25. package/src/__tests__/programmatic-api.test.ts +20 -1
  26. package/src/__tests__/revalidation.test.ts +65 -0
  27. package/src/__tests__/route-lint.test.ts +74 -0
  28. package/src/__tests__/route-table.test.ts +33 -0
  29. package/src/__tests__/safe-validate.test.ts +96 -0
  30. package/src/__tests__/scroll-restoration.test.ts +66 -0
  31. package/src/__tests__/search-serializer.test.ts +42 -0
  32. package/src/__tests__/search-validation.test.ts +125 -0
  33. package/src/__tests__/security.test.ts +110 -1
  34. package/src/__tests__/selective-ssr.test.ts +85 -0
  35. package/src/__tests__/spa-mode.test.ts +77 -0
  36. package/src/__tests__/typed-routing.test.ts +239 -0
  37. package/src/build/bundler.ts +33 -0
  38. package/src/build/prerender.ts +88 -0
  39. package/src/build/route-lint.ts +49 -0
  40. package/src/client/ClientRouter.tsx +239 -47
  41. package/src/client/build-path.ts +24 -0
  42. package/src/client/cache.ts +8 -0
  43. package/src/client/components/Await.tsx +9 -2
  44. package/src/client/components/Form.tsx +23 -34
  45. package/src/client/components/Link.tsx +105 -11
  46. package/src/client/components/Outlet.tsx +8 -2
  47. package/src/client/components/ScrollRestoration.tsx +125 -0
  48. package/src/client/entry.tsx +39 -2
  49. package/src/client/fetcher-store.ts +61 -0
  50. package/src/client/form-utils.ts +3 -0
  51. package/src/client/hooks/useActionData.ts +7 -3
  52. package/src/client/hooks/useFetcher.ts +116 -33
  53. package/src/client/hooks/useFetchers.ts +23 -0
  54. package/src/client/hooks/useLoaderData.ts +8 -4
  55. package/src/client/hooks/useLocation.ts +27 -0
  56. package/src/client/hooks/useNavigate.ts +51 -0
  57. package/src/client/hooks/useParams.ts +15 -4
  58. package/src/client/hooks/useRevalidator.ts +26 -0
  59. package/src/client/hooks/useSearch.ts +73 -0
  60. package/src/client/hooks/useSearchParams.ts +21 -6
  61. package/src/client/nav-utils.ts +26 -0
  62. package/src/client/prefetch.ts +110 -15
  63. package/src/client/registry.ts +131 -0
  64. package/src/client/revalidation.ts +25 -0
  65. package/src/client/router.tsx +28 -1
  66. package/src/client/scroll-restoration.ts +48 -0
  67. package/src/client/search-serializer.ts +40 -0
  68. package/src/client/types.ts +6 -0
  69. package/src/codegen/route-codegen.ts +201 -29
  70. package/src/config/load.ts +21 -0
  71. package/src/dev/hmr-client.ts +3 -1
  72. package/src/dev/route-table.ts +27 -0
  73. package/src/dev/server.ts +106 -8
  74. package/src/dev/watcher.ts +25 -3
  75. package/src/index.ts +44 -3
  76. package/src/server/action-handler.ts +12 -3
  77. package/src/server/action-registry.ts +35 -0
  78. package/src/server/csp.ts +10 -1
  79. package/src/server/csrf.ts +26 -0
  80. package/src/server/env.ts +26 -5
  81. package/src/server/layout.ts +31 -1
  82. package/src/server/loader.ts +14 -8
  83. package/src/server/render.ts +18 -3
  84. package/src/server/request-handler.ts +50 -8
  85. package/src/server/search.ts +43 -0
  86. package/src/server/serve.ts +88 -1
  87. package/src/server/spa.ts +62 -0
  88. package/src/server/stream-handler.ts +10 -1
  89. package/src/server/validate.ts +85 -13
  90. package/src/shared/context.ts +5 -0
  91. package/src/shared/define-actions.ts +39 -0
  92. package/src/shared/form-data.ts +34 -0
  93. package/src/shared/route-types.ts +83 -2
  94. package/templates/new-app/app/root.tsx +2 -1
  95. package/templates/new-app/bractjs.config.ts +7 -12
  96. package/types/config.d.ts +21 -0
  97. package/types/index.d.ts +210 -10
  98. package/types/route.d.ts +62 -2
package/src/index.ts CHANGED
@@ -5,8 +5,11 @@ 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
7
  export type { ApiRouteDefinition, AppApiRoutes } from "./server/api-route.ts";
8
- export { validate } from "./server/validate.ts";
9
- export type { FieldErrors, ValidationError } from "./server/validate.ts";
8
+ export { validate, safeValidate, isValidationResponse, readValidationError } from "./server/validate.ts";
9
+ export type { FieldErrors, ValidationError, SafeValidateResult } from "./server/validate.ts";
10
+ export { formText, formValues } from "./shared/form-data.ts";
11
+ export { defineActions } from "./shared/define-actions.ts";
12
+ export { validateSearch, searchParamsToObject } from "./server/search.ts";
10
13
  export type { BractAdapter } from "./server/adapter.ts";
11
14
  export { BunAdapter } from "./server/adapter.ts";
12
15
 
@@ -70,6 +73,11 @@ export type {
70
73
  MetaFunction,
71
74
  RouteModule,
72
75
  RouteDefinition,
76
+ RouterLocation,
77
+ ShouldRevalidateArgs,
78
+ ShouldRevalidateFunction,
79
+ LoaderData,
80
+ ActionData,
73
81
  } from "./shared/route-types.ts";
74
82
  export type { RouteFile, Segment } from "./server/scanner.ts";
75
83
 
@@ -102,19 +110,49 @@ export { Form } from "./client/components/Form.tsx";
102
110
  export { Await } from "./client/components/Await.tsx";
103
111
  export { Image } from "./client/components/Image.tsx";
104
112
  export type { ImageProps, ImageFormat, ImageFit } from "./client/components/Image.tsx";
113
+ export { ScrollRestoration } from "./client/components/ScrollRestoration.tsx";
114
+ export type { ScrollRestorationProps } from "./client/components/ScrollRestoration.tsx";
105
115
 
106
116
  // Client hooks
107
117
  export { useLoaderData } from "./client/hooks/useLoaderData.ts";
108
118
  export { useActionData } from "./client/hooks/useActionData.ts";
119
+ export { useLocation } from "./client/hooks/useLocation.ts";
109
120
  export { useParams } from "./client/hooks/useParams.ts";
110
121
  export { useNavigation } from "./client/hooks/useNavigation.ts";
122
+ export { useNavigate } from "./client/hooks/useNavigate.ts";
123
+ export type { NavigateFn, NavigateOptions } from "./client/hooks/useNavigate.ts";
111
124
  export { useFetcher } from "./client/hooks/useFetcher.ts";
125
+ export type { FetcherResult, FetcherFormProps, UseFetcherOptions } from "./client/hooks/useFetcher.ts";
126
+ export { useFetchers } from "./client/hooks/useFetchers.ts";
127
+ export type { FetcherEntry, FetcherState } from "./client/fetcher-store.ts";
128
+ export { useRevalidator } from "./client/hooks/useRevalidator.ts";
129
+ export type { Revalidator } from "./client/hooks/useRevalidator.ts";
112
130
  export { useSearchParams } from "./client/hooks/useSearchParams.ts";
113
131
  export type { SearchParamsResult } from "./client/hooks/useSearchParams.ts";
132
+ export { useSearch, useSetSearch } from "./client/hooks/useSearch.ts";
133
+ export type { SetSearchFn, SetSearchOptions } from "./client/hooks/useSearch.ts";
134
+ export { serializeSearch } from "./client/search-serializer.ts";
114
135
  export { useBlocker } from "./client/hooks/useBlocker.ts";
115
136
  export { useLocale } from "./client/hooks/useLocale.ts";
116
137
  export { useLocalizedLink } from "./client/hooks/useLocalizedLink.ts";
117
138
 
139
+ // Typed-routing registration seam. Augment `Register` (done by `bractjs codegen`
140
+ // in app/route-types.gen.ts) to make <Link>, useNavigate, useParams, and
141
+ // useSearchParams type-safe. Augment RouteSearchParamsMap / RouteContextMap to
142
+ // type a route's search params / context.
143
+ export type {
144
+ Register,
145
+ RouteRegistry,
146
+ RegisteredRoutes,
147
+ ParamsFor,
148
+ SearchFor,
149
+ SearchOutputFor,
150
+ InferSchemaOutput,
151
+ RouteSearchParamsMap,
152
+ RouteContextMap,
153
+ } from "./client/registry.ts";
154
+ export { buildPath } from "./client/build-path.ts";
155
+
118
156
  // i18n utilities (server-side)
119
157
  export { wrapRoutesWithLocale, stripLocale, localizedDataPath } from "./server/i18n.ts";
120
158
  export type { I18nConfig } from "./server/serve.ts";
@@ -124,4 +162,7 @@ export { createDevServer } from "./dev/server.ts";
124
162
  export type { DevServerOptions, DevServer } from "./dev/server.ts";
125
163
  export { runBuild } from "./build/bundler.ts";
126
164
  export type { BuildConfig } from "./build/bundler.ts";
127
- export { loadUserConfig } from "./config/load.ts";
165
+ export { loadUserConfig, defineConfig } from "./config/load.ts";
166
+ export { runPrerender } from "./build/prerender.ts";
167
+ export type { PrerenderOptions, PrerenderResult } from "./build/prerender.ts";
168
+ export { renderSpaShell } from "./server/spa.ts";
@@ -1,15 +1,24 @@
1
1
  import { resolveAction } from "./action-registry.ts";
2
2
  import { json } from "./response.ts";
3
- import { isAllowedMutation } from "./csrf.ts";
3
+ import { isAllowedMutation, csrfForbiddenResponse } from "./csrf.ts";
4
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
+
10
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.
11
19
  function hasForbiddenKey(value: unknown, depth = 0): boolean {
12
- if (depth > 20 || !value || typeof value !== "object") return false;
20
+ if (!value || typeof value !== "object") return false;
21
+ if (depth > MAX_SCAN_DEPTH) return true;
13
22
  for (const key of Object.keys(value as Record<string, unknown>)) {
14
23
  if (FORBIDDEN_KEYS.has(key)) return true;
15
24
  if (hasForbiddenKey((value as Record<string, unknown>)[key], depth + 1)) return true;
@@ -23,7 +32,7 @@ export async function handleActionRequest(request: Request): Promise<Response |
23
32
  // would otherwise also reach this handler).
24
33
  if (url.pathname !== "/_action") return null;
25
34
  if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
26
- if (!isAllowedMutation(request)) return new Response("Forbidden", { status: 403 });
35
+ if (!isAllowedMutation(request)) return csrfForbiddenResponse();
27
36
 
28
37
  const id = url.searchParams.get("id");
29
38
  if (!id) return new Response("Bad Request: missing action id", { status: 400 });
@@ -6,6 +6,37 @@ import { join, relative, resolve, isAbsolute } from "node:path";
6
6
  const SERVER_RE = /^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*["']use server["']/;
7
7
  const registry = new Map<string, (...args: unknown[]) => Promise<unknown>>();
8
8
 
9
+ // SECURITY(high): exporting a function from a `"use server"` module publishes
10
+ // it as an unauthenticated RPC endpoint reachable via POST /_action and
11
+ // GET /_stream. For files under `routes/`, these reserved exports are framework
12
+ // lifecycle hooks / components — NOT intended as callable actions — so we never
13
+ // register them. Without this filter a route's `loader`/`action`/`default`
14
+ // could be invoked directly over the wire, bypassing search/param validation
15
+ // and (for /_stream) with zero arguments. Authors who genuinely want an action
16
+ // must export it under a different name.
17
+ const RESERVED_ROUTE_EXPORTS = new Set([
18
+ "default",
19
+ "loader",
20
+ "action",
21
+ "meta",
22
+ "beforeLoad",
23
+ "context",
24
+ "ErrorBoundary",
25
+ "Fallback",
26
+ "config",
27
+ "searchSchema",
28
+ "ssr",
29
+ ]);
30
+
31
+ function isRouteFile(rel: string): boolean {
32
+ return rel.startsWith("routes/") || rel.startsWith("routes\\");
33
+ }
34
+
35
+ function shouldRegisterExport(name: string, fromRouteFile: boolean): boolean {
36
+ if (fromRouteFile && RESERVED_ROUTE_EXPORTS.has(name)) return false;
37
+ return true;
38
+ }
39
+
9
40
  /**
10
41
  * Hash key for an action — must use the same string the client-side proxy
11
42
  * plugin hashes (`pathKey + "#" + name`). Mismatch → `/_action?id=...` 404.
@@ -61,8 +92,10 @@ export async function loadServerActions(appDir: string): Promise<void> {
61
92
  continue;
62
93
  }
63
94
 
95
+ const fromRouteFile = isRouteFile(rel);
64
96
  for (const [name, val] of Object.entries(mod)) {
65
97
  if (typeof val !== "function") continue;
98
+ if (!shouldRegisterExport(name, fromRouteFile)) continue;
66
99
  const id = await computeId(pathKeyForAction(filePath, appDir), name);
67
100
  registry.set(id, val as (...args: unknown[]) => Promise<unknown>);
68
101
  }
@@ -82,8 +115,10 @@ export async function loadServerActionsFromRegistry(
82
115
  entries: Array<{ relPath: string; mod: Record<string, unknown> }>,
83
116
  ): Promise<void> {
84
117
  for (const { relPath, mod } of entries) {
118
+ const fromRouteFile = isRouteFile(relPath);
85
119
  for (const [name, val] of Object.entries(mod)) {
86
120
  if (typeof val !== "function") continue;
121
+ if (!shouldRegisterExport(name, fromRouteFile)) continue;
87
122
  const id = await computeId(relPath, name);
88
123
  registry.set(id, val as (...args: unknown[]) => Promise<unknown>);
89
124
  }
package/src/server/csp.ts CHANGED
@@ -21,6 +21,15 @@ export interface CspOptions {
21
21
  * Useful for staging a policy before turning it on. Default: false.
22
22
  */
23
23
  reportOnly?: boolean;
24
+ /**
25
+ * Drop `'unsafe-inline'` from the default `style-src`. The baseline policy
26
+ * allows inline styles for ergonomics (React inline styles, CSS-in-JS), which
27
+ * leaves inline-style injection (CSS exfiltration / UI redress) possible.
28
+ * Set `strict: true` for `style-src 'self'` only — you must then serve all
29
+ * styles from same-origin stylesheets (or override `style-src` yourself with
30
+ * a nonce/hash via `directives`). Default: false.
31
+ */
32
+ strict?: boolean;
24
33
  }
25
34
 
26
35
  /**
@@ -70,7 +79,7 @@ export function csp(options: CspOptions = {}): MiddlewareFn {
70
79
  // imports without each chunk needing its own nonce. Falls back to 'self'
71
80
  // in browsers that don't support it.
72
81
  "script-src": `'self' 'nonce-${nonce}' 'strict-dynamic'`,
73
- "style-src": "'self' 'unsafe-inline'",
82
+ "style-src": options.strict ? "'self'" : "'self' 'unsafe-inline'",
74
83
  "img-src": "'self' data: blob:",
75
84
  "connect-src": "'self'",
76
85
  "base-uri": "'self'",
@@ -29,6 +29,32 @@
29
29
  // header. If CORS policy is ever loosened, Sec-Fetch-Site (1) remains as the
30
30
  // browser-enforced backstop, and apps that loosen CORS should add a
31
31
  // cryptographic double-submit token.
32
+ import { isExplicitDev } from "./env.ts";
33
+
34
+ /**
35
+ * The developer-facing explanation of a CSRF rejection. In dev it spells out
36
+ * the accepted signals and the usual fix; in prod it stays terse so the 403
37
+ * leaks nothing. Used for the plain route/action 403 bodies — the stream
38
+ * handler embeds {@link csrfHint} in its SSE error event instead.
39
+ */
40
+ export function csrfHint(): string {
41
+ return (
42
+ "Blocked a cross-site or unattributed mutation (CSRF protection). " +
43
+ "Same-origin browser requests are allowed automatically; a manual fetch() " +
44
+ 'must send the header `X-BractJS-Action: 1` (BractJS\'s <Form> and ' +
45
+ "useFetcher do this for you)."
46
+ );
47
+ }
48
+
49
+ /** A 403 Response for a rejected mutation: explanatory in dev, terse in prod. */
50
+ export function csrfForbiddenResponse(): Response {
51
+ if (isExplicitDev()) {
52
+ console.warn("[bractjs] 403 (CSRF): " + csrfHint());
53
+ return new Response("Forbidden — " + csrfHint(), { status: 403 });
54
+ }
55
+ return new Response("Forbidden", { status: 403 });
56
+ }
57
+
32
58
  export function isAllowedMutation(request: Request): boolean {
33
59
  // (1) Browser-enforced signal. If present, it vetoes cross-origin requests
34
60
  // regardless of what the Origin/custom headers claim.
package/src/server/env.ts CHANGED
@@ -20,6 +20,19 @@ export function isDevRuntime(): boolean {
20
20
  return _runtimeMode === "dev";
21
21
  }
22
22
 
23
+ /**
24
+ * The dev HMR WebSocket port, set by createDevServer and read when rendering
25
+ * the dev bootstrap so the injected HMR client connects to the right port
26
+ * (the config's `hmrPort`, not a hardcoded 3001). 0 = default.
27
+ */
28
+ let _devHmrPort = 0;
29
+ export function setDevHmrPort(port: number): void {
30
+ _devHmrPort = port;
31
+ }
32
+ export function getDevHmrPort(): number {
33
+ return _devHmrPort;
34
+ }
35
+
23
36
  /**
24
37
  * Strict "is development?" check used to gate sensitive output (error
25
38
  * messages, stack traces) that would otherwise leak in production.
@@ -50,12 +63,20 @@ const LS = String.fromCharCode(0x2028);
50
63
  const PS = String.fromCharCode(0x2029);
51
64
 
52
65
  export function safeStringify(data: unknown): string {
53
- const seen = new WeakSet();
54
- const json = JSON.stringify(data, (_key, value) => {
55
- if (typeof value === "object" && value !== null) {
56
- if (seen.has(value)) return "[Circular]";
57
- seen.add(value);
66
+ // Cycle detection must track ANCESTORS, not every visited node — a WeakSet
67
+ // of all seen objects flags legitimate shared references as "[Circular]"
68
+ // (e.g. a loader echoing `args.search` while the payload also carries
69
+ // `search` at the top level). MDN's replacer pattern: `this` is the holder
70
+ // object, so popping the stack until the top is the holder leaves exactly
71
+ // the current ancestor chain.
72
+ const ancestors: object[] = [];
73
+ const json = JSON.stringify(data, function (_key, value: unknown) {
74
+ if (typeof value !== "object" || value === null) return value;
75
+ while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {
76
+ ancestors.pop();
58
77
  }
78
+ if (ancestors.includes(value)) return "[Circular]";
79
+ ancestors.push(value);
59
80
  return value;
60
81
  });
61
82
  // Escape HTML-sensitive chars + JS LineTerminators (U+2028 / U+2029) so this
@@ -8,6 +8,12 @@ export interface LayoutChain {
8
8
  root: RouteModule;
9
9
  layouts: RouteModule[];
10
10
  route: RouteModule;
11
+ /**
12
+ * appDir-relative source paths for each module in the chain, for error
13
+ * messages ("loader error in routes/blog/[id].tsx"). Optional so hand-built
14
+ * chains in tests don't have to supply it.
15
+ */
16
+ files?: { root?: string; layouts: string[]; route?: string };
11
17
  }
12
18
 
13
19
  export interface ResolvedRoute extends RouteFile {
@@ -102,6 +108,14 @@ export async function importRouteModule(filePath: string): Promise<RouteModule>
102
108
  // full-page GET, POST actions, and the /_data soft-nav endpoint alike.
103
109
  beforeLoad: mod.beforeLoad,
104
110
  context: mod.context,
111
+ // searchSchema gates loader input — dropping it silently skips search
112
+ // validation, so loaders would see raw strings where they expect coerced data.
113
+ searchSchema: mod.searchSchema,
114
+ // Selective-SSR surface: dropping `ssr` would silently restore full SSR
115
+ // (running loaders the route opted out of); dropping `Fallback` would
116
+ // SSR an empty outlet and guarantee a hydration mismatch.
117
+ ssr: mod.ssr,
118
+ Fallback: mod.Fallback,
105
119
  handle: mod.handle,
106
120
  ErrorBoundary: mod.ErrorBoundary,
107
121
  default: mod.default,
@@ -124,6 +138,11 @@ function pickRouteModule(mod: Record<string, unknown> | RouteModule | undefined)
124
138
  // note in importRouteModule. The compiled-binary path goes through here.
125
139
  beforeLoad: m.beforeLoad as RouteModule["beforeLoad"],
126
140
  context: m.context as unknown,
141
+ // Keep searchSchema too — see importRouteModule. Missing it here would
142
+ // skip search validation only in compiled binaries, the worst kind of skew.
143
+ searchSchema: m.searchSchema,
144
+ ssr: m.ssr as RouteModule["ssr"],
145
+ Fallback: m.Fallback as RouteModule["Fallback"],
127
146
  handle: m.handle as RouteModule["handle"],
128
147
  ErrorBoundary: m.ErrorBoundary as RouteModule["ErrorBoundary"],
129
148
  default: m.default as RouteModule["default"],
@@ -155,7 +174,12 @@ export async function resolveRouteChain(
155
174
  const layoutMods = layoutKeys.map((k) => pickRouteModule(registry[k]));
156
175
  const routeKey = routeFile.filePath.split("\\").join("/");
157
176
  const routeMod = pickRouteModule(registry[routeKey]);
158
- return { root: rootMod, layouts: layoutMods, route: routeMod };
177
+ return {
178
+ root: rootMod,
179
+ layouts: layoutMods,
180
+ route: routeMod,
181
+ files: { root: rootKey, layouts: layoutKeys, route: routeKey },
182
+ };
159
183
  }
160
184
 
161
185
  const resolved = await resolveLayoutChain(routeFile, appDir);
@@ -167,9 +191,15 @@ export async function resolveRouteChain(
167
191
  resolve(join(appDir, routeFile.filePath))
168
192
  );
169
193
 
194
+ // Relativize the absolute layout paths back to appDir-relative for messages.
195
+ const appRoot = resolve(appDir);
196
+ const rel = (abs: string) => abs.startsWith(appRoot + "/") ? abs.slice(appRoot.length + 1) : abs;
197
+ const [rootFile, ...layoutFiles] = resolved.layoutFiles.map(rel);
198
+
170
199
  return {
171
200
  root: rootMod ?? {},
172
201
  layouts: layoutMods,
173
202
  route: routeMod,
203
+ files: { root: rootFile, layouts: layoutFiles, route: routeFile.filePath },
174
204
  };
175
205
  }
@@ -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 ────────────────────────────────────────────────────────
@@ -1,7 +1,7 @@
1
1
  import { renderToReadableStream } from "react-dom/server";
2
2
  import { createElement, Fragment, type ReactNode } from "react";
3
3
  import type { MetaDescriptor } from "../shared/route-types.ts";
4
- import { safeStringify, isDevRuntime } from "./env.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,6 +18,8 @@ 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[];
23
25
  status?: number;
@@ -25,6 +27,13 @@ export interface RenderOptions {
25
27
  routeFile?: string;
26
28
  /** Per-request CSP nonce (set by the opt-in `csp()` middleware). Applied to the inline bootstrap script + client entry module tags. */
27
29
  nonce?: string;
30
+ /**
31
+ * Set when the document did NOT SSR the route component: the client renders
32
+ * the Fallback during hydration, then swaps in the real component
33
+ * ("data-only": data already present; "client-only": after a /_data fetch;
34
+ * "spa": static shell, everything resolved client-side).
35
+ */
36
+ ssrMode?: "client-only" | "data-only" | "spa";
28
37
  }
29
38
 
30
39
  export async function renderRoute(options: RenderOptions): Promise<Response> {
@@ -38,13 +47,19 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
38
47
  status = 200,
39
48
  } = options;
40
49
 
41
- const devFlag = isDevRuntime() ? "window.__BRACT_DEV__=true;" : "";
50
+ // In dev, publish the HMR port so the injected client connects to the
51
+ // configured `hmrPort` rather than a hardcoded 3001. 0 → omit (client
52
+ // defaults to 3001).
53
+ const hmrPort = isDevRuntime() ? getDevHmrPort() : 0;
54
+ const devFlag = isDevRuntime()
55
+ ? "window.__BRACT_DEV__=true;" + (hmrPort ? `window.__BRACTJS_HMR_PORT__=${hmrPort};` : "")
56
+ : "";
42
57
  const devOverlay = isDevRuntime() ? devFlag + errorOverlayScript + "\n" : "";
43
58
  const mergedMeta = mergeMeta(options.meta ?? []);
44
59
  // The merged descriptor array is what the client reads to keep the document
45
60
  // head in sync on soft navigation — keep it shaped, not stringified HTML.
46
61
  const bootstrapScriptContent =
47
- devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, manifest, routeFile: options.routeFile, meta: mergedMeta })};`;
62
+ devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, search: options.search, manifest, routeFile: options.routeFile, meta: mergedMeta, ssrMode: options.ssrMode })};`;
48
63
 
49
64
  // Render <title>/<meta> elements alongside the app shell. React 19 hoists
50
65
  // document-metadata elements into <head> during streaming SSR, so crawlers
@@ -3,14 +3,15 @@ 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";
8
9
  import { json, error, sanitizeRedirect } from "./response.ts";
9
10
  import { isRedirect, isHttpError } from "../shared/errors.ts";
10
11
  import { isExplicitDev } from "./env.ts";
11
12
  import { pipeline, type MiddlewareContext } from "./middleware.ts";
12
13
  import { BractJSProvider } from "../shared/context.ts";
13
- import { isAllowedMutation } from "./csrf.ts";
14
+ import { isAllowedMutation, csrfForbiddenResponse } from "./csrf.ts";
14
15
  import { getCspNonce } from "./csp.ts";
15
16
  import { fireOnError, type OnErrorHook } from "./lifecycle.ts";
16
17
 
@@ -87,6 +88,10 @@ async function route(
87
88
  headers: request.headers,
88
89
  method: "GET",
89
90
  });
91
+ // Validate search params before any route work runs — loaders must
92
+ // never see unvalidated input, and a 400 here is cheaper than a wasted
93
+ // context-factory/loader run. The thrown 400 Response propagates below.
94
+ const search = await validateSearch(chain.route.searchSchema, targetUrl);
90
95
  // SECURITY(high): /_data must run the same auth/redirect gates as a full
91
96
  // page request — otherwise a SPA-style soft navigation to a protected
92
97
  // route would bypass beforeLoad() / defineContext() and leak loader data.
@@ -96,13 +101,20 @@ async function route(
96
101
  match.params,
97
102
  context,
98
103
  );
99
- const args = buildLoaderArgs(loaderRequest, match.params, routeContext);
104
+ const args = buildLoaderArgs(loaderRequest, match.params, routeContext, search);
100
105
  const beforeLoadResponse = await runBeforeLoad(chain.route, args);
101
106
  if (beforeLoadResponse) return beforeLoadResponse;
102
107
  const results = await runLoaders(chain, args, onError);
103
- return json({ root: results.root, layouts: results.layouts, route: results.route, params: match.params });
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 });
104
113
  } catch (err) {
105
114
  if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
115
+ // A non-redirect Response (e.g. the 400 thrown by search validation)
116
+ // is the intended reply — pass it through verbatim.
117
+ if (err instanceof Response) return err;
106
118
  if (isHttpError(err)) return json({ error: err.message }, { status: err.status });
107
119
  console.error("[bractjs] /_data error:", err);
108
120
  await fireOnError(onError, err, request);
@@ -115,6 +127,17 @@ async function route(
115
127
  if (!match) return error("Not Found", 404);
116
128
 
117
129
  const chain = await resolveRouteChain(match.routeFile, appDir, moduleRegistry);
130
+
131
+ // Validate search params before any route work (context factory, beforeLoad,
132
+ // action, loaders) — they all receive the validated object.
133
+ let search: Record<string, unknown>;
134
+ try {
135
+ search = await validateSearch(chain.route.searchSchema, url);
136
+ } catch (err) {
137
+ if (err instanceof Response) return err;
138
+ throw err;
139
+ }
140
+
118
141
  // Run per-route context factory (defineContext export) before loaders.
119
142
  const routeContext = await runRouteContext(
120
143
  chain.route as Parameters<typeof runRouteContext>[0],
@@ -122,7 +145,7 @@ async function route(
122
145
  match.params,
123
146
  context,
124
147
  );
125
- const args = buildLoaderArgs(request, match.params, routeContext);
148
+ const args = buildLoaderArgs(request, match.params, routeContext, search);
126
149
 
127
150
  // ── beforeLoad ────────────────────────────────────────────────────────
128
151
  const beforeLoadResponse = await runBeforeLoad(chain.route, args);
@@ -131,7 +154,7 @@ async function route(
131
154
  // ── Action (mutating methods) ─────────────────────────────────────────
132
155
  let actionData: unknown = null;
133
156
  if (MUTATING_METHODS.has(request.method)) {
134
- if (!isAllowedMutation(request)) return error("Forbidden", 403);
157
+ if (!isAllowedMutation(request)) return csrfForbiddenResponse();
135
158
  // Reject up front if the client advertises an oversized body.
136
159
  const clRaw = request.headers.get("Content-Length");
137
160
  if (clRaw) {
@@ -148,6 +171,8 @@ async function route(
148
171
  } catch (err) {
149
172
  if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
150
173
  if (isHttpError(err)) return error(err.message, err.status);
174
+ // Name the failing route so the log points at the right file.
175
+ console.error(`[bractjs] action error in ${chain.files?.route ?? match.routeFile.filePath}:`, err);
151
176
  await fireOnError(onError, err, request);
152
177
  if (isExplicitDev()) return error(err instanceof Error ? err.message : String(err), 500);
153
178
  return error("Internal Server Error", 500);
@@ -167,10 +192,20 @@ async function route(
167
192
  }
168
193
  }
169
194
 
195
+ // ── Selective SSR ─────────────────────────────────────────────────────
196
+ // `ssr: false` skips the ROUTE loader during document SSR (root/layout
197
+ // loaders still run — they render the shell). beforeLoad already ran above:
198
+ // it is the auth gate and must hold for every mode. The client completes
199
+ // the render via /_data after hydration, where the loader DOES run.
200
+ const routeSsr = chain.route.ssr ?? true;
201
+ const loaderChain = routeSsr === false
202
+ ? { ...chain, route: { ...chain.route, loader: undefined } }
203
+ : chain;
204
+
170
205
  // ── Loaders ───────────────────────────────────────────────────────────
171
206
  let loaderResults;
172
207
  try {
173
- loaderResults = await runLoaders(chain, args, onError);
208
+ loaderResults = await runLoaders(loaderChain, args, onError);
174
209
  } catch (err) {
175
210
  if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
176
211
  if (isHttpError(err)) return error(err.message, err.status);
@@ -187,7 +222,10 @@ async function route(
187
222
 
188
223
  // ── SSR render ────────────────────────────────────────────────────────
189
224
  const RootComponent = chain.root.default ?? (() => null);
190
- const RouteComponent = chain.route.default;
225
+ // Non-default SSR modes render the Fallback (or nothing) in the component's
226
+ // place; the client swaps in the real component after hydration.
227
+ const RouteComponent = routeSsr === true ? chain.route.default : chain.route.Fallback;
228
+ const ssrMode = routeSsr === true ? undefined : routeSsr === false ? "client-only" as const : "data-only" as const;
191
229
 
192
230
  // Wrap root in BractJSProvider so <Outlet> can render the route component
193
231
  // server-side without needing a ClientRouter.
@@ -201,6 +239,8 @@ async function route(
201
239
  pathname,
202
240
  manifest: manifest as unknown as import("../shared/context.ts").RouteManifest,
203
241
  RouteComponent,
242
+ location: { pathname, search: url.search, hash: "", state: null, key: "default" },
243
+ search,
204
244
  },
205
245
  children: createElement(RootComponent),
206
246
  },
@@ -214,10 +254,12 @@ async function route(
214
254
  actionData,
215
255
  params: match.params,
216
256
  pathname,
257
+ search,
217
258
  manifest,
218
259
  meta,
219
260
  routeFile: match.routeFile.filePath,
220
261
  // Set by the opt-in csp() middleware; undefined otherwise.
221
262
  nonce: getCspNonce(context),
263
+ ssrMode,
222
264
  });
223
265
  }