@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
@@ -2,7 +2,11 @@ import { basename } from "node:path";
2
2
 
3
3
  // ── Types ──────────────────────────────────────────────────────────────────
4
4
 
5
- export type Segment = string | { param: string } | { catchAll: string };
5
+ export type Segment =
6
+ | string
7
+ | { param: string }
8
+ | { optional: string }
9
+ | { catchAll: string };
6
10
 
7
11
  export interface RouteFile {
8
12
  filePath: string;
@@ -12,12 +16,22 @@ export interface RouteFile {
12
16
 
13
17
  // ── Helpers ────────────────────────────────────────────────────────────────
14
18
 
19
+ /** A path segment that is a route group: `(marketing)`. Contributes a layout
20
+ * folder but no URL segment. */
21
+ export function isRouteGroupSegment(seg: string): boolean {
22
+ return seg.startsWith("(") && seg.endsWith(")") && seg.length > 2;
23
+ }
24
+
15
25
  export function pathToSegments(pattern: string): Segment[] {
16
26
  if (pattern === "") return [];
17
27
  return pattern.split("/").map((seg) => {
18
28
  if (seg.startsWith("[...") && seg.endsWith("]")) {
19
29
  return { catchAll: seg.slice(4, -1) };
20
30
  }
31
+ // Optional param: [[id]] → matches with or without the segment present.
32
+ if (seg.startsWith("[[") && seg.endsWith("]]")) {
33
+ return { optional: seg.slice(2, -2) };
34
+ }
21
35
  if (seg.startsWith("[") && seg.endsWith("]")) {
22
36
  return { param: seg.slice(1, -1) };
23
37
  }
@@ -29,20 +43,48 @@ export function filePathToPattern(filePath: string): string {
29
43
  // Strip "routes/" prefix and file extension
30
44
  let path = filePath.replace(/^routes\//, "").replace(/\.(tsx|ts)$/, "");
31
45
 
46
+ // Drop route-group segments — `(marketing)/about` → `about`. They group
47
+ // files (and their layout.tsx) without adding a URL segment.
48
+ path = path
49
+ .split("/")
50
+ .filter((seg) => !isRouteGroupSegment(seg))
51
+ .join("/");
52
+
32
53
  // Handle nested _index (e.g. blog/_index → blog)
33
54
  path = path.replace(/\/_index$/, "");
34
55
 
35
56
  // Handle root _index
36
57
  if (path === "_index" || path === "") return "";
37
58
 
38
- // Convert [param] and [...catchAll] segments — keep as-is for pattern string
59
+ // Convert [param], [[optional]], and [...catchAll] segments — keep as-is for
60
+ // the pattern string.
39
61
  return path;
40
62
  }
41
63
 
64
+ /**
65
+ * Ancestor directory chain (relative to `routes/`) for a route file, outermost
66
+ * → innermost, used to locate nesting `layout.tsx` files. Derived from the FILE
67
+ * path (not the URL pattern) so route-group folders like `(marketing)` are
68
+ * included — their layout wraps children even though they add no URL segment.
69
+ *
70
+ * `routes/(marketing)/blog/[id].tsx` → `["(marketing)", "(marketing)/blog"]`.
71
+ */
72
+ export function layoutDirsFromFilePath(filePath: string): string[] {
73
+ const rel = filePath.replace(/^routes\//, "").replace(/\.(tsx|ts)$/, "");
74
+ const parts = rel.split("/");
75
+ parts.pop(); // drop the file's own basename — only ancestor dirs hold layouts
76
+ const dirs: string[] = [];
77
+ for (let i = 1; i <= parts.length; i++) {
78
+ dirs.push(parts.slice(0, i).join("/"));
79
+ }
80
+ return dirs;
81
+ }
82
+
42
83
  function segmentScore(seg: Segment): number {
43
84
  if (typeof seg === "string") return 0; // static
44
85
  if ("param" in seg) return 1; // dynamic
45
- return 2; // catch-all
86
+ if ("optional" in seg) return 2; // optional dynamic
87
+ return 3; // catch-all
46
88
  }
47
89
 
48
90
  function routeScore(route: RouteFile): number {
@@ -0,0 +1,47 @@
1
+ import { runSchema, type Schema } from "./validate.ts";
2
+
3
+ // ── Raw extraction ─────────────────────────────────────────────────────────
4
+
5
+ /**
6
+ * URLSearchParams → plain object. Repeated keys collapse into arrays
7
+ * (`?tag=a&tag=b` → `{ tag: ["a", "b"] }`), mirroring how `validate()`
8
+ * flattens FormData.
9
+ */
10
+ export function searchParamsToObject(sp: URLSearchParams): Record<string, string | string[]> {
11
+ // Null-prototype so a query param named "__proto__" (?__proto__=x) can't
12
+ // pollute Object.prototype when the result is later spread/merged. Using a
13
+ // plain {} here would make `out["__proto__"] = …` a no-op AND, for nested
14
+ // merges downstream, a pollution vector. SECURITY: see proto-guard.ts.
15
+ const out = Object.create(null) as Record<string, string | string[]>;
16
+ for (const [key, value] of sp.entries()) {
17
+ const existing = out[key];
18
+ if (existing === undefined) out[key] = value;
19
+ else if (Array.isArray(existing)) existing.push(value);
20
+ else out[key] = [existing, value];
21
+ }
22
+ return out;
23
+ }
24
+
25
+ // ── Validation ─────────────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Validate a URL's search params against a route's optional `searchSchema`
29
+ * export (Zod/Valibot/standard-schema compatible — same duck typing as
30
+ * `validate()`).
31
+ *
32
+ * - No schema → the raw string record (back-compat: routes that never opted in
33
+ * see exactly what `request.url` would give them).
34
+ * - Schema failure → throws the 400 `Response` from the validate machinery.
35
+ * Loaders must never run on unvalidated input; leniency belongs in the
36
+ * schema itself (`z.coerce.number().catch(1)` is the documented idiom for
37
+ * URLs that must tolerate junk).
38
+ * - Success → the parsed, coerced object (numbers/booleans/arrays/defaults).
39
+ */
40
+ export async function validateSearch(
41
+ schema: unknown,
42
+ url: URL,
43
+ ): Promise<Record<string, unknown>> {
44
+ const raw = searchParamsToObject(url.searchParams);
45
+ if (!schema) return raw;
46
+ return await runSchema(schema as Schema<Record<string, unknown>>, raw);
47
+ }
@@ -1,6 +1,8 @@
1
1
  import { scanRoutes, type RouteFile } from "./scanner.ts";
2
- import { buildTrie } from "./matcher.ts";
2
+ import { buildTrie, matchRoute } from "./matcher.ts";
3
3
  import { handleRequest, type HandlerConfig } from "./request-handler.ts";
4
+ import { pipeline, type MiddlewareContext } from "./middleware.ts";
5
+ import { renderSpaShell } from "./spa.ts";
4
6
  import { type ServerManifest } from "./render.ts";
5
7
  import { isDevRuntime, isExplicitDev } from "./env.ts";
6
8
  import { loadManifest } from "../build/manifest.ts";
@@ -24,10 +26,24 @@ export interface BractJSConfig {
24
26
  appDir: string;
25
27
  publicDir: string;
26
28
  manifest: ServerManifest;
29
+ /** WebSocket port for dev HMR (used by `bractjs dev` only). Default 3001. */
30
+ hmrPort?: number;
27
31
  /** Optional custom adapter (Cloudflare Workers, Deno, Node, etc.). Defaults to Bun.serve(). */
28
32
  adapter?: BractAdapter;
29
33
  /** i18n locale prefix routing (E2). */
30
34
  i18n?: I18nConfig;
35
+ /**
36
+ * SPA mode: `false` serves one static shell for every document GET instead
37
+ * of SSR. The server keeps running — /_data, actions, /_image, API routes
38
+ * and static assets behave exactly as in SSR mode ("no document SSR", not
39
+ * "no server"). Default `true`.
40
+ */
41
+ ssr?: boolean;
42
+ /**
43
+ * Paths to prerender at build time (SSG). Served from disk before dynamic
44
+ * SSR in production; requests with a query string stay dynamic.
45
+ */
46
+ prerender?: string[] | (() => string[] | Promise<string[]>);
31
47
  // Build options (used by src/build/bundler.ts)
32
48
  sourcemap?: "none" | "linked" | "inline" | "external";
33
49
  minify?: boolean;
@@ -37,6 +53,14 @@ export interface BractJSConfig {
37
53
  buildDir?: string;
38
54
  /** Directory for transformed image cache. Defaults to .bract-image-cache */
39
55
  imageCacheDir?: string;
56
+ /**
57
+ * Hard ceiling (bytes) on the size of any incoming request body, enforced by
58
+ * the Bun adapter regardless of the advertised Content-Length. Defaults to
59
+ * 16 MiB — above the 10 MiB route-form cap so normal requests pass while a
60
+ * single client can't stream an unbounded body into memory. Raise it for a
61
+ * dedicated large-upload endpoint. Only applies to the default Bun adapter.
62
+ */
63
+ maxRequestBodySize?: number;
40
64
  /** Called once after the server starts listening. Use to open DB connections, warm caches, etc. */
41
65
  onStart?: () => Promise<void> | void;
42
66
  /** Called before the process exits (any signal or uncaught error). Use to close DB connections, flush queues, etc. */
@@ -129,8 +153,38 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
129
153
  : loadServerActions(appDir);
130
154
  const moduleRegistry = config.moduleRegistry;
131
155
  const onError = config.onError;
156
+ const ssrEnabled = config.ssr !== false;
157
+
158
+ // SPA shell: production prefers the file `bractjs build` wrote; dev (or a
159
+ // missing file) renders it on demand so root.tsx edits show up. Cached per
160
+ // manifest in prod-without-file; never cached in dev.
161
+ let spaShellCache: { key: string; html: string } | null = null;
162
+ async function getSpaShell(manifest: ServerManifest): Promise<string> {
163
+ if (!isDevRuntime()) {
164
+ const file = Bun.file(join(buildDir, "client", "__spa.html"));
165
+ if (await file.exists()) return file.text();
166
+ const key = manifest.clientEntry;
167
+ if (spaShellCache?.key === key) return spaShellCache.html;
168
+ const html = await renderSpaShell(appDir, manifest, moduleRegistry);
169
+ spaShellCache = { key, html };
170
+ return html;
171
+ }
172
+ return renderSpaShell(appDir, manifest, moduleRegistry);
173
+ }
132
174
 
133
- return async function fetch(request: Request): Promise<Response> {
175
+ /** Prerendered file for a clean (query-free, dot-free) document path, or null. */
176
+ function prerenderFile(relHtmlOrJson: string): ReturnType<typeof Bun.file> | null {
177
+ if (relHtmlOrJson.split("/").some((s) => s === ".." || s === ".")) return null;
178
+ return Bun.file(join(buildDir, "client", "_prerender", relHtmlOrJson));
179
+ }
180
+
181
+ // The full per-request dispatch: special endpoints (API, actions, stream,
182
+ // image, static, prerender) first, then the SSR route handler. Runs INSIDE
183
+ // the global middleware pipeline (see the returned `fetch` below), so
184
+ // `pipeline.use(cors()/csp()/auth/…)` governs every response — not just SSR
185
+ // documents. `context` is the shared mutable object threaded through the
186
+ // pipeline; route-level middleware and getCspNonce() read the same object.
187
+ async function dispatch(request: Request, context: Record<string, unknown>): Promise<Response> {
134
188
  const url = new URL(request.url);
135
189
  const { pathname } = url;
136
190
 
@@ -196,9 +250,67 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
196
250
  if (staticRes) return staticRes;
197
251
 
198
252
  const trie = await trieReady;
253
+ const isDocGet = request.method === "GET" || request.method === "HEAD";
254
+
255
+ // SPA mode: every document GET that matches a route gets the static
256
+ // shell. /_data (no trie match) and mutations fall through to the normal
257
+ // handler, so loaders/actions/CSRF behave exactly as in SSR mode.
258
+ if (!ssrEnabled && isDocGet && matchRoute(pathname, trie)) {
259
+ const manifest = isDevRuntime() ? await readDevManifest(buildDir) : await manifestReady;
260
+ return new Response(await getSpaShell(manifest), {
261
+ headers: {
262
+ "Content-Type": "text/html; charset=utf-8",
263
+ "Cache-Control": "no-cache",
264
+ },
265
+ });
266
+ }
267
+
268
+ // Prerendered output (production): serve the build-time HTML / _data
269
+ // payload for clean URLs. A query string opts the request back into
270
+ // dynamic SSR — the static file was rendered without one.
271
+ if (!isDevRuntime() && isDocGet) {
272
+ if (pathname === "/_data") {
273
+ const target = url.searchParams.get("path") ?? "/";
274
+ const [targetPathname, targetSearch] = target.split("?");
275
+ if (!targetSearch) {
276
+ const rel = targetPathname === "/" ? "_data.json" : targetPathname.slice(1) + "/_data.json";
277
+ const f = prerenderFile(rel);
278
+ if (f && (await f.exists())) {
279
+ return new Response(f, {
280
+ headers: {
281
+ "Content-Type": "application/json",
282
+ "Cache-Control": "public, max-age=0, must-revalidate",
283
+ },
284
+ });
285
+ }
286
+ }
287
+ } else if (!url.search) {
288
+ const rel = pathname === "/" ? "index.html" : pathname.slice(1) + "/index.html";
289
+ const f = prerenderFile(rel);
290
+ if (f && (await f.exists())) {
291
+ return new Response(f, {
292
+ headers: {
293
+ "Content-Type": "text/html; charset=utf-8",
294
+ "Cache-Control": "public, max-age=0, must-revalidate",
295
+ },
296
+ });
297
+ }
298
+ }
299
+ }
300
+
199
301
  const manifest = isDevRuntime() ? await readDevManifest(buildDir) : await manifestReady;
200
302
  const handlerConfig: HandlerConfig = { appDir, publicDir, manifest, onError, moduleRegistry };
201
- return handleRequest(request, trie, handlerConfig);
303
+ return handleRequest(request, trie, handlerConfig, context);
304
+ }
305
+
306
+ return async function fetch(request: Request): Promise<Response> {
307
+ // Run the global middleware pipeline around the ENTIRE dispatch so
308
+ // cors()/csp()/logging/auth attached via `pipeline.use(...)` apply to
309
+ // API routes, server actions, /_stream, /_image and static assets — not
310
+ // only SSR documents. The per-route (nested) middleware chain still runs
311
+ // inside handleRequest for SSR/_data, sharing this same `context` object.
312
+ const ctx: MiddlewareContext = { request, params: {}, context: {} };
313
+ return pipeline.run(ctx, () => dispatch(request, ctx.context));
202
314
  };
203
315
  }
204
316
 
@@ -243,7 +355,7 @@ export function createServer(config?: Partial<BractJSConfig>): {
243
355
  const fetchHandler = buildFetchHandler(config ?? {});
244
356
 
245
357
  // Use provided adapter or fall back to the default Bun adapter.
246
- const adapter = config?.adapter ?? new BunAdapter();
358
+ const adapter = config?.adapter ?? new BunAdapter(config?.maxRequestBodySize);
247
359
 
248
360
  if (adapter instanceof BunAdapter) {
249
361
  adapter.setHandler(fetchHandler);
@@ -1,3 +1,5 @@
1
+ import { hasForbiddenKey } from "./proto-guard.ts";
2
+
1
3
  export type SessionData = Record<string, unknown>;
2
4
 
3
5
  export interface Session {
@@ -37,7 +39,16 @@ function encode(data: SessionData): string {
37
39
 
38
40
  function decode(encoded: string): SessionData {
39
41
  const pad = "=".repeat((4 - (encoded.length % 4)) % 4);
40
- return JSON.parse(atob(encoded.replace(/-/g, "+").replace(/_/g, "/") + pad)) as SessionData;
42
+ const parsed = JSON.parse(
43
+ atob(encoded.replace(/-/g, "+").replace(/_/g, "/") + pad),
44
+ ) as SessionData;
45
+ // Defense-in-depth: the payload is HMAC-verified before we get here, so this
46
+ // only matters if a signing secret leaks — but a session blob carrying a
47
+ // "__proto__" key must never pollute Object.prototype when read/spread.
48
+ if (hasForbiddenKey(parsed)) {
49
+ throw new Error("session: forbidden key in payload");
50
+ }
51
+ return parsed;
41
52
  }
42
53
 
43
54
  async function sign(data: string, secret: string): Promise<string> {
@@ -0,0 +1,62 @@
1
+ import { createElement, type ComponentType } from "react";
2
+ import { join, resolve } from "node:path";
3
+ import { renderRoute, type ServerManifest } from "./render.ts";
4
+ import { BractJSProvider, type RouteManifest } from "../shared/context.ts";
5
+ import type { ModuleRegistry } from "./layout.ts";
6
+
7
+ /**
8
+ * Render the SPA-mode document shell: the app's root component around an
9
+ * empty outlet, with `ssrMode: "spa"` in the bootstrap payload. Served for
10
+ * every document GET when the config sets `ssr: false`; the client router
11
+ * resolves the actual route (module + /_data) after hydration.
12
+ *
13
+ * The root renders with NO loader data (its loader does not run for the
14
+ * shell) and a "/" location — roots that render loader- or location-dependent
15
+ * markup are not compatible with SPA mode. Loaders/actions stay fully
16
+ * functional at runtime: SPA mode means "no document SSR", not "no server".
17
+ */
18
+ export async function renderSpaShell(
19
+ appDir: string,
20
+ manifest: ServerManifest,
21
+ registry?: ModuleRegistry,
22
+ ): Promise<string> {
23
+ let RootComponent: ComponentType = () => null;
24
+ if (registry) {
25
+ const rootMod = (registry["root.tsx"] ?? registry["root.ts"]) as { default?: ComponentType } | undefined;
26
+ if (rootMod?.default) RootComponent = rootMod.default;
27
+ } else {
28
+ const rootPath = resolve(join(appDir, "root.tsx"));
29
+ if (await Bun.file(rootPath).exists()) {
30
+ const mod = (await import(rootPath)) as { default?: ComponentType };
31
+ if (mod.default) RootComponent = mod.default;
32
+ }
33
+ }
34
+
35
+ const loaderData = { root: null, layouts: [], route: null };
36
+ const shell = createElement(BractJSProvider, {
37
+ value: {
38
+ loaderData: loaderData as unknown as Record<string, unknown>,
39
+ actionData: null,
40
+ params: {},
41
+ pathname: "/",
42
+ manifest: manifest as unknown as RouteManifest,
43
+ RouteComponent: undefined,
44
+ location: { pathname: "/", search: "", hash: "", state: null, key: "default" },
45
+ search: {},
46
+ },
47
+ children: createElement(RootComponent),
48
+ });
49
+
50
+ const res = await renderRoute({
51
+ shell,
52
+ loaderData: loaderData as unknown as Record<string, unknown>,
53
+ actionData: null,
54
+ params: {},
55
+ pathname: "/",
56
+ search: {},
57
+ manifest,
58
+ meta: [],
59
+ ssrMode: "spa",
60
+ });
61
+ return await res.text();
62
+ }
@@ -1,5 +1,6 @@
1
1
  import { resolveAction } from "./action-registry.ts";
2
2
  import { isExplicitDev } from "./env.ts";
3
+ import { csrfHint } from "./csrf.ts";
3
4
 
4
5
  // ── SSE helpers ────────────────────────────────────────────────────────────
5
6
 
@@ -31,7 +32,7 @@ export async function handleStreamRequest(request: Request): Promise<Response |
31
32
  // it cross-origin without a CORS preflight, and the real client (useFetcher)
32
33
  // always sends it. This is strictly tighter than the /_action gate.
33
34
  if (!request.headers.get("X-BractJS-Action")) {
34
- return new Response(sseChunk("error", { message: "Forbidden" }), {
35
+ return new Response(sseChunk("error", { message: isExplicitDev() ? csrfHint() : "Forbidden" }), {
35
36
  status: 403,
36
37
  headers: {
37
38
  "Content-Type": "text/event-stream",
@@ -70,6 +71,14 @@ export async function handleStreamRequest(request: Request): Promise<Response |
70
71
  async start(controller) {
71
72
  const encoder = new TextEncoder();
72
73
  try {
74
+ // SECURITY(medium): /_stream invokes the resolved action with NO
75
+ // caller-supplied arguments (GET carries no body, and we deliberately
76
+ // pass none). Any function reachable here therefore runs purely on
77
+ // server-side state. The X-BractJS-Action gate above blocks browser
78
+ // cross-origin abuse; the action-registry's RESERVED_ROUTE_EXPORTS
79
+ // filter keeps route lifecycle exports (loader/action/…) from ever
80
+ // being resolvable. Authors must still ensure stream actions are safe
81
+ // to call with no input and perform their own authorization.
73
82
  const result = await action();
74
83
  // If the action is an async generator, stream each value.
75
84
  if (result && typeof (result as AsyncIterable<unknown>)[Symbol.asyncIterator] === "function") {
@@ -14,7 +14,7 @@ interface SchemaWithSafeParse<T> {
14
14
  safeParse(input: unknown): SafeParseResult<T> | Promise<SafeParseResult<T>>;
15
15
  }
16
16
 
17
- type Schema<T> = SchemaWithParse<T> | SchemaWithSafeParse<T>;
17
+ export type Schema<T> = SchemaWithParse<T> | SchemaWithSafeParse<T>;
18
18
 
19
19
  // ── Field error shape ─────────────────────────────────────────────────────
20
20
 
@@ -33,7 +33,10 @@ export class ValidationError extends Error {
33
33
 
34
34
  function toPlainObject(input: FormData | Record<string, unknown>): Record<string, unknown> {
35
35
  if (input instanceof FormData) {
36
- const out: Record<string, unknown> = {};
36
+ // Null-prototype: a form field literally named "__proto__" becomes a plain
37
+ // own key here instead of mutating Object.prototype when the result is
38
+ // later spread/merged. SECURITY: see src/server/proto-guard.ts.
39
+ const out = Object.create(null) as Record<string, unknown>;
37
40
  for (const [key, value] of input.entries()) {
38
41
  if (key in out) {
39
42
  const existing = out[key];
@@ -48,21 +51,15 @@ function toPlainObject(input: FormData | Record<string, unknown>): Record<string
48
51
  }
49
52
 
50
53
  /**
51
- * Validate `input` against a Zod-compatible or Valibot-compatible schema.
52
- *
53
- * - If the schema has `.safeParse()`: uses it to collect field errors and throws
54
- * a typed `ValidationError` on failure (which the framework converts to a 400).
55
- * - If the schema only has `.parse()`: wraps it and re-throws the error as a
56
- * `ValidationError` with a single `_` field containing the error message.
57
- *
58
- * Returns the parsed (coerced) data on success.
54
+ * Run a plain object through a Zod/Valibot-compatible schema. The shared core
55
+ * of `validate()` (action/form bodies) and `validateSearch()` (URL search
56
+ * params). Throws a 400 `Response` with `{ errors }` field errors on failure;
57
+ * returns the parsed (coerced) data on success.
59
58
  */
60
- export async function validate<T>(
59
+ export async function runSchema<T>(
61
60
  schema: Schema<T>,
62
- input: FormData | Record<string, unknown>,
61
+ plain: Record<string, unknown>,
63
62
  ): Promise<T> {
64
- const plain = toPlainObject(input);
65
-
66
63
  if ("safeParse" in schema && typeof schema.safeParse === "function") {
67
64
  const result = await schema.safeParse(plain);
68
65
  if ((result as SafeParseResult<T>).success) {
@@ -87,3 +84,81 @@ export async function validate<T>(
87
84
  throw Response.json({ errors: fieldErrors }, { status: 400, statusText: "Validation failed" });
88
85
  }
89
86
  }
87
+
88
+ /**
89
+ * Validate `input` against a Zod-compatible or Valibot-compatible schema.
90
+ *
91
+ * - If the schema has `.safeParse()`: uses it to collect field errors and throws
92
+ * a typed `ValidationError` on failure (which the framework converts to a 400).
93
+ * - If the schema only has `.parse()`: wraps it and re-throws the error as a
94
+ * `ValidationError` with a single `_` field containing the error message.
95
+ *
96
+ * Returns the parsed (coerced) data on success.
97
+ */
98
+ export async function validate<T>(
99
+ schema: Schema<T>,
100
+ input: FormData | Record<string, unknown>,
101
+ ): Promise<T> {
102
+ return runSchema(schema, toPlainObject(input));
103
+ }
104
+
105
+ // ── Non-throwing validation (ergonomic action idiom) ───────────────────────
106
+
107
+ export type SafeValidateResult<T> =
108
+ | { ok: true; data: T }
109
+ | { ok: false; fieldErrors: FieldErrors; firstError: string };
110
+
111
+ /**
112
+ * Like {@link validate}, but returns a result instead of throwing — the
113
+ * ergonomic shape for actions that want to render field errors:
114
+ *
115
+ * ```ts
116
+ * const r = await safeValidate(PostSchema, formData);
117
+ * if (!r.ok) return { error: r.firstError, fieldErrors: r.fieldErrors };
118
+ * usePost(r.data);
119
+ * ```
120
+ *
121
+ * `firstError` is the first message across all fields, or a generic fallback.
122
+ */
123
+ export async function safeValidate<T>(
124
+ schema: Schema<T>,
125
+ input: FormData | Record<string, unknown>,
126
+ ): Promise<SafeValidateResult<T>> {
127
+ try {
128
+ const data = await runSchema(schema, toPlainObject(input));
129
+ return { ok: true, data };
130
+ } catch (err) {
131
+ if (isValidationResponse(err)) {
132
+ const { fieldErrors, firstError } = await readValidationError(err);
133
+ return { ok: false, fieldErrors, firstError };
134
+ }
135
+ throw err; // not a validation failure — let it propagate
136
+ }
137
+ }
138
+
139
+ /**
140
+ * True for the 400 `Response` thrown by {@link validate} / `searchSchema`
141
+ * validation (identified by status 400 + `statusText "Validation failed"`).
142
+ * Use it in the try/catch idiom when you keep calling `validate()` directly.
143
+ */
144
+ export function isValidationResponse(value: unknown): value is Response {
145
+ return value instanceof Response && value.status === 400 && value.statusText === "Validation failed";
146
+ }
147
+
148
+ /**
149
+ * Parse the `{ errors }` body of a validation 400 `Response` into field errors
150
+ * plus the first message. Tolerant of a non-JSON / unexpected body.
151
+ */
152
+ export async function readValidationError(
153
+ res: Response,
154
+ ): Promise<{ fieldErrors: FieldErrors; firstError: string }> {
155
+ const fallback = "Please check your input.";
156
+ try {
157
+ const body = (await res.clone().json()) as { errors?: FieldErrors };
158
+ const fieldErrors = body.errors ?? {};
159
+ const firstError = Object.values(fieldErrors)[0]?.[0] ?? fallback;
160
+ return { fieldErrors, firstError };
161
+ } catch {
162
+ return { fieldErrors: {}, firstError: fallback };
163
+ }
164
+ }
@@ -1,4 +1,5 @@
1
1
  import { createContext, useContext, createElement, type ComponentType, type ReactNode } from "react";
2
+ import type { RouterLocation, RouteMatch } from "./route-types.ts";
2
3
 
3
4
  export interface RouteManifest {
4
5
  [routeId: string]: {
@@ -15,6 +16,12 @@ export interface BractJSContextValue {
15
16
  manifest: RouteManifest;
16
17
  /** SSR-only: the matched route's default export so <Outlet> can render it without ClientRouter */
17
18
  RouteComponent?: ComponentType;
19
+ /** The request's location, so `useLocation()` works during SSR (hash is always ""). */
20
+ location?: RouterLocation;
21
+ /** Validated search params (route `searchSchema` output), so `useSearch()` works during SSR. */
22
+ search?: Record<string, unknown>;
23
+ /** The matched route chain (root → layouts → route) for `useMatches()`. */
24
+ matches?: RouteMatch[];
18
25
  }
19
26
 
20
27
  export const BractJSContext = createContext<BractJSContextValue>(null!);
@@ -0,0 +1,39 @@
1
+ import type { ActionArgs } from "./route-types.ts";
2
+ import { isExplicitDev } from "../server/env.ts";
3
+
4
+ type IntentHandler = (args: ActionArgs) => unknown;
5
+
6
+ /**
7
+ * Compose a single route `action` from per-intent handlers, dispatching on the
8
+ * form's `intent` field. Pairs with `<Form intent="...">` / `<fetcher.Form
9
+ * intent="...">`, which render the matching hidden input:
10
+ *
11
+ * ```ts
12
+ * export const action = defineActions({
13
+ * add: ({ formData }) => addTodo(formText(formData, "title")),
14
+ * delete: ({ formData }) => deleteTodo(formText(formData, "id")),
15
+ * });
16
+ * ```
17
+ *
18
+ * A missing or unknown intent returns a 400 `Response` (dev lists the known
19
+ * intents; prod is terse). Each handler receives the full {@link ActionArgs}.
20
+ */
21
+ export function defineActions<M extends Record<string, IntentHandler>>(
22
+ handlers: M,
23
+ ): (args: ActionArgs) => Promise<Awaited<ReturnType<M[keyof M]>> | Response> {
24
+ type Out = Awaited<ReturnType<M[keyof M]>> | Response;
25
+ const dispatch = async (args: ActionArgs): Promise<Out> => {
26
+ const raw = args.formData.get("intent");
27
+ const intent = typeof raw === "string" ? raw : "";
28
+ const handler = handlers[intent];
29
+ if (!handler) {
30
+ const known = Object.keys(handlers);
31
+ const message = isExplicitDev()
32
+ ? `Unknown action intent ${JSON.stringify(intent)}. Known intents: ${known.join(", ") || "(none)"}.`
33
+ : "Unknown action intent.";
34
+ return Response.json({ error: message }, { status: 400 });
35
+ }
36
+ return (await handler(args)) as Out;
37
+ };
38
+ return dispatch;
39
+ }
@@ -0,0 +1,34 @@
1
+ // Small ergonomics for reading FormData in actions, where `.get()` returns
2
+ // `string | File | null` and almost every call site coerces to a string.
3
+
4
+ /**
5
+ * Read a string field from FormData. Returns `""` when the field is missing or
6
+ * is a File (upload) — never `null`/`File`, so it drops straight into code that
7
+ * expects a string. Replaces the `String(formData.get("x") ?? "")` dance.
8
+ */
9
+ export function formText(formData: FormData, key: string): string {
10
+ const value = formData.get(key);
11
+ return typeof value === "string" ? value : "";
12
+ }
13
+
14
+ /**
15
+ * Collect string fields from FormData into a plain object. With no `keys`, every
16
+ * string entry is included (Files are skipped, first occurrence wins per key);
17
+ * with `keys`, only those fields (each defaulting to `""`). Handy for passing a
18
+ * typed subset of a form to a model function.
19
+ */
20
+ export function formValues(
21
+ formData: FormData,
22
+ keys?: string[],
23
+ ): Record<string, string> {
24
+ const out: Record<string, string> = {};
25
+ if (keys) {
26
+ for (const key of keys) out[key] = formText(formData, key);
27
+ return out;
28
+ }
29
+ for (const [key, value] of formData.entries()) {
30
+ if (key in out) continue; // first occurrence wins (mirrors FormData.get)
31
+ if (typeof value === "string") out[key] = value;
32
+ }
33
+ return out;
34
+ }