@bractjs/bractjs 0.1.27 → 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 (95) hide show
  1. package/README.md +242 -36
  2. package/bin/cli.ts +18 -1
  3. package/package.json +1 -1
  4. package/src/__tests__/codegen-write.test.ts +67 -0
  5. package/src/__tests__/codegen.test.ts +29 -2
  6. package/src/__tests__/compile-safety.test.ts +4 -0
  7. package/src/__tests__/csp.test.ts +10 -0
  8. package/src/__tests__/define-actions.test.ts +69 -0
  9. package/src/__tests__/env.test.ts +18 -0
  10. package/src/__tests__/fetcher-store.test.ts +67 -0
  11. package/src/__tests__/fixtures/app/root.tsx +7 -2
  12. package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
  13. package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
  14. package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
  15. package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -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__/integration.test.ts +56 -0
  21. package/src/__tests__/loader.test.ts +32 -1
  22. package/src/__tests__/nav-utils.test.ts +46 -0
  23. package/src/__tests__/prerender.test.ts +102 -0
  24. package/src/__tests__/programmatic-api.test.ts +20 -1
  25. package/src/__tests__/revalidation.test.ts +65 -0
  26. package/src/__tests__/route-lint.test.ts +74 -0
  27. package/src/__tests__/route-table.test.ts +33 -0
  28. package/src/__tests__/safe-validate.test.ts +96 -0
  29. package/src/__tests__/scroll-restoration.test.ts +66 -0
  30. package/src/__tests__/search-serializer.test.ts +42 -0
  31. package/src/__tests__/search-validation.test.ts +125 -0
  32. package/src/__tests__/security.test.ts +110 -1
  33. package/src/__tests__/selective-ssr.test.ts +85 -0
  34. package/src/__tests__/spa-mode.test.ts +77 -0
  35. package/src/__tests__/typed-routing.test.ts +51 -1
  36. package/src/build/bundler.ts +33 -0
  37. package/src/build/prerender.ts +88 -0
  38. package/src/build/route-lint.ts +49 -0
  39. package/src/client/ClientRouter.tsx +239 -47
  40. package/src/client/cache.ts +8 -0
  41. package/src/client/components/Await.tsx +9 -2
  42. package/src/client/components/Form.tsx +23 -34
  43. package/src/client/components/Link.tsx +80 -9
  44. package/src/client/components/Outlet.tsx +8 -2
  45. package/src/client/components/ScrollRestoration.tsx +125 -0
  46. package/src/client/entry.tsx +39 -2
  47. package/src/client/fetcher-store.ts +61 -0
  48. package/src/client/form-utils.ts +3 -0
  49. package/src/client/hooks/useActionData.ts +7 -3
  50. package/src/client/hooks/useFetcher.ts +116 -33
  51. package/src/client/hooks/useFetchers.ts +23 -0
  52. package/src/client/hooks/useLoaderData.ts +8 -4
  53. package/src/client/hooks/useLocation.ts +27 -0
  54. package/src/client/hooks/useNavigate.ts +11 -6
  55. package/src/client/hooks/useRevalidator.ts +26 -0
  56. package/src/client/hooks/useSearch.ts +73 -0
  57. package/src/client/hooks/useSearchParams.ts +7 -2
  58. package/src/client/nav-utils.ts +26 -0
  59. package/src/client/prefetch.ts +110 -15
  60. package/src/client/registry.ts +24 -0
  61. package/src/client/revalidation.ts +25 -0
  62. package/src/client/router.tsx +28 -1
  63. package/src/client/scroll-restoration.ts +48 -0
  64. package/src/client/search-serializer.ts +40 -0
  65. package/src/client/types.ts +6 -0
  66. package/src/codegen/route-codegen.ts +141 -8
  67. package/src/config/load.ts +21 -0
  68. package/src/dev/hmr-client.ts +3 -1
  69. package/src/dev/route-table.ts +27 -0
  70. package/src/dev/server.ts +106 -8
  71. package/src/dev/watcher.ts +25 -3
  72. package/src/index.ts +27 -3
  73. package/src/server/action-handler.ts +12 -3
  74. package/src/server/action-registry.ts +35 -0
  75. package/src/server/csp.ts +10 -1
  76. package/src/server/csrf.ts +26 -0
  77. package/src/server/env.ts +26 -5
  78. package/src/server/layout.ts +31 -1
  79. package/src/server/loader.ts +14 -8
  80. package/src/server/render.ts +18 -3
  81. package/src/server/request-handler.ts +50 -8
  82. package/src/server/search.ts +43 -0
  83. package/src/server/serve.ts +88 -1
  84. package/src/server/spa.ts +62 -0
  85. package/src/server/stream-handler.ts +10 -1
  86. package/src/server/validate.ts +85 -13
  87. package/src/shared/context.ts +5 -0
  88. package/src/shared/define-actions.ts +39 -0
  89. package/src/shared/form-data.ts +34 -0
  90. package/src/shared/route-types.ts +83 -2
  91. package/templates/new-app/app/root.tsx +2 -1
  92. package/templates/new-app/bractjs.config.ts +7 -12
  93. package/types/config.d.ts +21 -0
  94. package/types/index.d.ts +165 -9
  95. package/types/route.d.ts +62 -2
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { scanRoutes } from "../server/scanner.ts";
3
3
  import type { Segment } from "../server/scanner.ts";
4
+ import { hashString } from "../build/hash.ts";
4
5
 
5
6
  // Convert [param] / [...catchAll] notation to :param colon-style for URLs.
6
7
  function patternToColon(urlPattern: string): string {
@@ -34,6 +35,8 @@ function substituteParams(pattern: string, params: string[]): string {
34
35
  // backtick, ${ }, or quote into the generated TS source.
35
36
  const SAFE_PATTERN_RE = /^\/(?:[A-Za-z0-9_\-]+|:[A-Za-z_][A-Za-z0-9_]*)(?:\/(?:[A-Za-z0-9_\-]+|:[A-Za-z_][A-Za-z0-9_]*))*$|^\/$/;
36
37
  const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
38
+ // Same guard the module-registry codegen applies before emitting import paths.
39
+ const SAFE_FILEPATH_RE = /^[A-Za-z0-9._\/\-\[\]]+$/;
37
40
 
38
41
  function assertSafePattern(pattern: string): void {
39
42
  if (!SAFE_PATTERN_RE.test(pattern)) {
@@ -45,6 +48,11 @@ function assertSafeParam(name: string): void {
45
48
  throw new Error(`[bractjs] codegen: refusing to emit unsafe param name: ${JSON.stringify(name)}`);
46
49
  }
47
50
  }
51
+ function assertSafeFilePath(filePath: string): void {
52
+ if (!SAFE_FILEPATH_RE.test(filePath) || filePath.split("/").includes("..")) {
53
+ throw new Error(`[bractjs] codegen: refusing to emit unsafe file path: ${JSON.stringify(filePath)}`);
54
+ }
55
+ }
48
56
 
49
57
  function builderEntry(pattern: string, params: string[]): string {
50
58
  assertSafePattern(pattern);
@@ -110,6 +118,38 @@ function searchParamsTypeLines(routes: Array<{ pattern: string }>): string {
110
118
  ].join("\n");
111
119
  }
112
120
 
121
+ // Per-route VALIDATED search shapes, inferred from each route's `searchSchema`
122
+ // export via type-only `typeof import(...)`. Routes without a schema fall back
123
+ // to the user's RouteSearchParamsMap augmentation, then a permissive record.
124
+ // This map feeds `Register.routes.searchOutput` → `useSearch`/`useSetSearch`/
125
+ // `<Link search>`; the legacy string-valued `search` member is untouched.
126
+ function searchOutputTypeLines(routes: Array<{ pattern: string; filePath: string }>): string {
127
+ if (routes.length === 0) {
128
+ return "export type GeneratedSearchOutput = Record<never, never>;";
129
+ }
130
+ const entries = routes
131
+ .map((r) => {
132
+ assertSafePattern(r.pattern);
133
+ assertSafeFilePath(r.filePath);
134
+ const key = JSON.stringify(r.pattern);
135
+ const spec = JSON.stringify("./" + r.filePath.split("\\").join("/"));
136
+ return " " + key + ": typeof import(" + spec + ") extends { searchSchema: infer S }\n" +
137
+ " ? InferSchemaOutput<S>\n" +
138
+ " : (RouteSearchParamsMap extends Record<" + key + ", infer V> ? V : Record<string, unknown>);";
139
+ })
140
+ .join("\n");
141
+ return [
142
+ "// Validated search shape per route, inferred from `searchSchema` exports.",
143
+ "export type GeneratedSearchOutput = {",
144
+ entries,
145
+ "};",
146
+ "",
147
+ "/** Validated search object for a route — what `useSearch<T>()` returns. */",
148
+ "export type SearchOutput<T extends AppRoutes> =",
149
+ " T extends keyof GeneratedSearchOutput ? GeneratedSearchOutput[T] : Record<string, unknown>;",
150
+ ].join("\n");
151
+ }
152
+
113
153
  function contextTypeLines(routes: Array<{ pattern: string }>): string {
114
154
  if (routes.length === 0) {
115
155
  return "export type Context<_T extends AppRoutes> = Record<string, unknown>;";
@@ -156,18 +196,87 @@ function registerAugmentationLines(routes: Array<{ pattern: string; params: stri
156
196
  paramEntries,
157
197
  " };",
158
198
  " search: RouteSearchParamsMap;",
199
+ " searchOutput: GeneratedSearchOutput;",
159
200
  " };",
160
201
  " }",
161
202
  "}",
162
203
  ].join("\n");
163
204
  }
164
205
 
206
+ // ── Freshness fingerprint ────────────────────────────────────────────────
207
+ // The generated file embeds a hash of its route patterns so the dev server can
208
+ // detect drift precisely (and skip rewrites when nothing changed, which would
209
+ // otherwise trigger an editor reload loop). Patterns are sorted everywhere so
210
+ // the output is identical across machines regardless of filesystem scan order.
211
+
212
+ const FINGERPRINT_RE = /^\/\/ bractjs:routes ([0-9a-f]+) \((\d+) routes?\)$/m;
213
+
214
+ /** Stable 8-hex fingerprint of a route-pattern set (order-independent). */
215
+ export function routesFingerprint(patterns: string[]): Promise<string> {
216
+ return hashString([...patterns].sort().join("\n"));
217
+ }
218
+
219
+ /** Extract the fingerprint hash previously written into a generated file, or null. */
220
+ export function readFingerprint(src: string | null): string | null {
221
+ if (!src) return null;
222
+ const m = src.match(FINGERPRINT_RE);
223
+ return m ? m[1] : null;
224
+ }
225
+
226
+ /**
227
+ * A precise human-readable reason the generated types are stale, or null when
228
+ * fresh. `patterns` must be colon-style (the form the generated file embeds);
229
+ * prefer {@link explainStalenessForApp} which derives them for you.
230
+ */
231
+ export async function explainStaleness(
232
+ oldSrc: string | null,
233
+ patterns: string[],
234
+ ): Promise<string | null> {
235
+ if (!oldSrc) return "route-types.gen.ts is missing — generating it";
236
+ const current = await routesFingerprint(patterns);
237
+ if (readFingerprint(oldSrc) === current) return null;
238
+ // Recover the old pattern set from the union members to report add/remove
239
+ // counts. The last member ends with `;`, so allow an optional trailing `;`.
240
+ const old = new Set(
241
+ [...oldSrc.matchAll(/^ {2}\| "([^"]+)";?$/gm)].map((m) => m[1]),
242
+ );
243
+ const now = new Set(patterns);
244
+ const added = patterns.filter((p) => !old.has(p)).length;
245
+ const removed = [...old].filter((p) => !now.has(p)).length;
246
+ const parts: string[] = [];
247
+ if (added) parts.push(`+${added} added`);
248
+ if (removed) parts.push(`-${removed} removed`);
249
+ const detail = parts.length ? ` (${parts.join(", ")})` : "";
250
+ return `routes changed since last codegen${detail} — regenerating`;
251
+ }
252
+
253
+ /** Colon-style route patterns for an app dir (the form the generated file uses). */
254
+ export async function routePatternsForApp(appDir: string): Promise<string[]> {
255
+ const routeFiles = await scanRoutes(appDir);
256
+ return routeFiles.map((r) => patternToColon(r.urlPattern));
257
+ }
258
+
259
+ /** {@link explainStaleness} against the current generated file + route set on disk. */
260
+ export async function explainStalenessForApp(
261
+ appDir: string,
262
+ outPath?: string,
263
+ ): Promise<string | null> {
264
+ const dest = outPath ?? join(appDir, "route-types.gen.ts");
265
+ const existing = await Bun.file(dest).text().catch(() => null);
266
+ return explainStaleness(existing, await routePatternsForApp(appDir));
267
+ }
268
+
165
269
  export async function generateRouteTypes(appDir: string): Promise<string> {
166
270
  const routeFiles = await scanRoutes(appDir);
167
- const routes = routeFiles.map((r) => ({
168
- pattern: patternToColon(r.urlPattern),
169
- params: paramsFromSegments(r.segments),
170
- }));
271
+ const routes = routeFiles
272
+ .map((r) => ({
273
+ pattern: patternToColon(r.urlPattern),
274
+ params: paramsFromSegments(r.segments),
275
+ filePath: r.filePath,
276
+ }))
277
+ // Deterministic order independent of filesystem scan order, so the output
278
+ // is byte-identical across machines (and the idempotent write works).
279
+ .sort((a, b) => a.pattern.localeCompare(b.pattern));
171
280
 
172
281
  const union = routes.length > 0
173
282
  ? routes.map((r) => {
@@ -180,11 +289,18 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
180
289
 
181
290
  // `RouteSearchParamsMap` / `RouteContextMap` are imported from the package so
182
291
  // the local `SearchParams<T>` / `Context<T>` reference the same interfaces the
183
- // user augments via `declare module "@bractjs/bractjs"`.
184
- const IMPORTS = 'import type { RouteSearchParamsMap, RouteContextMap } from "@bractjs/bractjs";';
292
+ // user augments via `declare module "@bractjs/bractjs"`. `InferSchemaOutput`
293
+ // derives each route's validated search shape from its `searchSchema` export.
294
+ const IMPORTS = 'import type { RouteSearchParamsMap, RouteContextMap, InferSchemaOutput } from "@bractjs/bractjs";';
295
+
296
+ // Freshness breadcrumb: lets the dev server detect drift without re-deriving
297
+ // the whole file, and lets writeRouteTypes skip identical writes.
298
+ const fingerprint = await routesFingerprint(routes.map((r) => r.pattern));
299
+ const FINGERPRINT = `// bractjs:routes ${fingerprint} (${routes.length} route${routes.length === 1 ? "" : "s"})`;
185
300
 
186
301
  return [
187
302
  HEADER,
303
+ FINGERPRINT,
188
304
  IMPORTS,
189
305
  "",
190
306
  "export type AppRoutes =",
@@ -194,16 +310,24 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
194
310
  "",
195
311
  searchParamsTypeLines(routes),
196
312
  "",
313
+ searchOutputTypeLines(routes),
314
+ "",
197
315
  contextTypeLines(routes),
198
316
  "",
199
317
  "export type TypedLoaderArgs<T extends AppRoutes> = {",
200
318
  " request: Request;",
201
319
  " params: RouteParams<T>;",
202
320
  " context: Context<T>;",
321
+ " search: T extends keyof GeneratedSearchOutput ? GeneratedSearchOutput[T] : Record<string, unknown>;",
203
322
  "};",
204
323
  "export type TypedActionArgs<T extends AppRoutes> =",
205
324
  " TypedLoaderArgs<T> & { formData: FormData };",
206
325
  "",
326
+ "/** Loader args fully typed for a route literal: `loader(args: LoaderArgsFor<\"/posts\">)`. */",
327
+ "export type LoaderArgsFor<T extends AppRoutes> = TypedLoaderArgs<T>;",
328
+ "/** Action args fully typed for a route literal: `action(args: ActionArgsFor<\"/posts\">)`. */",
329
+ "export type ActionArgsFor<T extends AppRoutes> = TypedActionArgs<T>;",
330
+ "",
207
331
  "/** A locale-prefixed variant of a route (E2 i18n routing). */",
208
332
  "export type LocalizedRoute<T extends AppRoutes> = `/${string}${T}`;",
209
333
  "",
@@ -217,8 +341,17 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
217
341
  ].join("\n");
218
342
  }
219
343
 
220
- export async function writeRouteTypes(appDir: string, outPath?: string): Promise<void> {
344
+ export async function writeRouteTypes(
345
+ appDir: string,
346
+ outPath?: string,
347
+ ): Promise<{ dest: string; written: boolean }> {
221
348
  const dest = outPath ?? join(appDir, "route-types.gen.ts");
222
- await Bun.write(dest, await generateRouteTypes(appDir));
349
+ const next = await generateRouteTypes(appDir);
350
+ // Skip the write (and the log, and the resulting file-watcher event) when the
351
+ // content is unchanged — otherwise auto-codegen in dev would loop the editor.
352
+ const existing = await Bun.file(dest).text().catch(() => null);
353
+ if (existing === next) return { dest, written: false };
354
+ await Bun.write(dest, next);
223
355
  console.log("[bract] codegen →", dest);
356
+ return { dest, written: true };
224
357
  }
@@ -25,6 +25,7 @@ export function validateUserConfig(cfg: unknown): Partial<BractJSConfig> {
25
25
  };
26
26
 
27
27
  check("port", typeof c.port === "number" && Number.isFinite(c.port), "a finite number");
28
+ check("hmrPort", typeof c.hmrPort === "number" && Number.isFinite(c.hmrPort), "a finite number");
28
29
  check("appDir", typeof c.appDir === "string", "a string");
29
30
  check("publicDir", typeof c.publicDir === "string", "a string");
30
31
  check("buildDir", typeof c.buildDir === "string", "a string");
@@ -45,10 +46,30 @@ export function validateUserConfig(cfg: unknown): Partial<BractJSConfig> {
45
46
  check("onStart", typeof c.onStart === "function", "a function");
46
47
  check("onShutdown", typeof c.onShutdown === "function", "a function");
47
48
  check("onError", typeof c.onError === "function", "a function");
49
+ check("ssr", typeof c.ssr === "boolean", "a boolean");
50
+ check(
51
+ "prerender",
52
+ typeof c.prerender === "function" ||
53
+ (Array.isArray(c.prerender) && c.prerender.every((p) => typeof p === "string")),
54
+ "an array of paths or a function returning one",
55
+ );
48
56
 
49
57
  return c as Partial<BractJSConfig>;
50
58
  }
51
59
 
60
+ /**
61
+ * Identity helper for `bractjs.config.ts` — wrap your default export to get
62
+ * autocomplete and type-checking on the config fields (no runtime effect):
63
+ *
64
+ * ```ts
65
+ * import { defineConfig } from "@bractjs/bractjs";
66
+ * export default defineConfig({ port: 3000, clientEnv: ["PUBLIC_API_URL"] });
67
+ * ```
68
+ */
69
+ export function defineConfig(config: Partial<BractJSConfig>): Partial<BractJSConfig> {
70
+ return config;
71
+ }
72
+
52
73
  /**
53
74
  * Load `bractjs.config.ts` (or `.js`) from the user's cwd if present.
54
75
  * Returns an empty object when no file exists — callers fall back to defaults.
@@ -23,7 +23,9 @@ export const hmrClientScript: string = `
23
23
  }
24
24
 
25
25
  function connect() {
26
- var ws = new WebSocket("ws://localhost:3001");
26
+ // Port published by the server's dev bootstrap (config hmrPort), else 3001.
27
+ var port = window.__BRACTJS_HMR_PORT__ || 3001;
28
+ var ws = new WebSocket("ws://localhost:" + port);
27
29
  ws.onmessage = function (event) {
28
30
  try {
29
31
  var msg = JSON.parse(event.data);
@@ -0,0 +1,27 @@
1
+ export interface RouteTableRow {
2
+ pattern: string;
3
+ file: string;
4
+ hasLoader: boolean;
5
+ hasAction: boolean;
6
+ }
7
+
8
+ /**
9
+ * Render a compact route inventory for the dev server boot log, so a developer
10
+ * can see at a glance what routes the app matched (and which have data/mutation
11
+ * handlers). Pure string formatting — no I/O.
12
+ */
13
+ export function formatRouteTable(rows: RouteTableRow[]): string {
14
+ if (rows.length === 0) return "[bractjs] no routes found under routes/";
15
+ const sorted = [...rows].sort((a, b) => a.pattern.localeCompare(b.pattern));
16
+ const patternWidth = Math.max(7, ...sorted.map((r) => r.pattern.length));
17
+ const lines = sorted.map((r) => {
18
+ const markers = [r.hasLoader ? "loader" : "", r.hasAction ? "action" : ""]
19
+ .filter(Boolean)
20
+ .join(" ");
21
+ return ` ${r.pattern.padEnd(patternWidth)} ${markers.padEnd(13)} ${r.file}`;
22
+ });
23
+ return [
24
+ `[bractjs] ${rows.length} route${rows.length === 1 ? "" : "s"}:`,
25
+ ...lines,
26
+ ].join("\n");
27
+ }
package/src/dev/server.ts CHANGED
@@ -1,13 +1,66 @@
1
1
  import { createServer } from "../server/serve.ts";
2
- import { setRuntimeMode } from "../server/env.ts";
2
+ import { setRuntimeMode, setDevHmrPort } from "../server/env.ts";
3
3
  import { createHmrServer } from "./hmr-server.ts";
4
4
  import { watchApp } from "./watcher.ts";
5
5
  import { rebuildClient } from "./rebuilder.ts";
6
- import { filePathToPattern } from "../server/scanner.ts";
7
- import { basename, extname } from "node:path";
6
+ import { filePathToPattern, scanRoutes } from "../server/scanner.ts";
7
+ import { basename, extname, join, resolve } from "node:path";
8
8
  import type { LifecycleHooks } from "../server/lifecycle.ts";
9
9
  import { loadUserConfig } from "../config/load.ts";
10
10
  import type { BractJSConfig } from "../server/serve.ts";
11
+ import { writeRouteTypes, explainStalenessForApp } from "../codegen/route-codegen.ts";
12
+ import { lintRouteModuleSource } from "../build/route-lint.ts";
13
+ import { formatRouteTable, type RouteTableRow } from "./route-table.ts";
14
+
15
+ // Warn-once across HMR rebuilds so the same lint message doesn't spam the log.
16
+ const warnedRouteIssues = new Set<string>();
17
+
18
+ /**
19
+ * Statically lint route modules and print the route table. Reads each route
20
+ * file's source once (no module execution, no per-request cost). Returns the
21
+ * table rows so the boot path can print them alongside the HMR port.
22
+ */
23
+ async function inspectRoutes(appDir: string): Promise<RouteTableRow[]> {
24
+ const routes = await scanRoutes(appDir);
25
+ const rows: RouteTableRow[] = [];
26
+ for (const r of routes) {
27
+ let src = "";
28
+ try {
29
+ src = await Bun.file(resolve(process.cwd(), appDir, r.filePath)).text();
30
+ } catch {
31
+ continue;
32
+ }
33
+ for (const warning of lintRouteModuleSource(src, r.filePath)) {
34
+ const key = r.filePath + "\0" + warning;
35
+ if (warnedRouteIssues.has(key)) continue;
36
+ warnedRouteIssues.add(key);
37
+ console.warn(`[bractjs] ${warning}`);
38
+ }
39
+ rows.push({
40
+ pattern: r.urlPattern === "" ? "/" : "/" + r.urlPattern,
41
+ file: r.filePath,
42
+ hasLoader: /^export\s+(?:async\s+)?function\s+loader\b|^export\s+const\s+loader\b/m.test(src),
43
+ hasAction: /^export\s+(?:async\s+)?function\s+action\b|^export\s+const\s+action\b/m.test(src),
44
+ });
45
+ }
46
+ return rows;
47
+ }
48
+
49
+ /**
50
+ * Regenerate typed routes if the route set drifted from the last codegen.
51
+ * Idempotent: writeRouteTypes skips the write when content is unchanged, so it
52
+ * never triggers an editor reload loop. Runs at boot and on add/remove/rename.
53
+ */
54
+ async function syncRouteTypes(appDir: string): Promise<void> {
55
+ try {
56
+ const reason = await explainStalenessForApp(appDir);
57
+ if (reason) console.log(`[bractjs] ${reason}`);
58
+ await writeRouteTypes(appDir);
59
+ } catch (err) {
60
+ // Codegen is a DX aid, never fatal to the dev loop.
61
+ console.warn("[bractjs] route codegen skipped:", err instanceof Error ? err.message : err);
62
+ }
63
+ }
11
64
 
12
65
  export interface DevServerOptions {
13
66
  /** HTTP port for the app server. Default: config.port ?? 3000. */
@@ -38,15 +91,42 @@ export async function createDevServer(options?: DevServerOptions): Promise<DevSe
38
91
  // for any source-import path, dev or `bractjs start`), so no separate dev hook
39
92
  // is needed here.
40
93
 
41
- const hmrPort = options?.hmrPort ?? 3001;
94
+ const hmrPort = options?.hmrPort ?? merged.hmrPort ?? 3001;
42
95
  const appPort = options?.port ?? merged.port ?? 3000;
96
+ // Publish the port so the SSR dev bootstrap tells the HMR client where to connect.
97
+ setDevHmrPort(hmrPort);
98
+
99
+ const appDir = merged.appDir ?? "./app";
43
100
 
44
- const hmr = createHmrServer(hmrPort);
101
+ // Keep typed routes fresh on boot (covers "added a route while the server was
102
+ // down"). Idempotent — no-op write when nothing changed.
103
+ await syncRouteTypes(appDir);
104
+
105
+ // Friendly port-conflict message instead of a raw Bun EADDRINUSE stack.
106
+ const onPortInUse = (which: "app server" | "HMR socket", port: number): never => {
107
+ console.error(
108
+ `[bractjs] Port ${port} is already in use (${which}). ` +
109
+ `Set \`port\` (and \`hmrPort\` for the HMR socket) in bractjs.config.ts, ` +
110
+ `or stop the process using it.`,
111
+ );
112
+ return process.exit(1);
113
+ };
114
+
115
+ let hmr: ReturnType<typeof createHmrServer>;
116
+ try {
117
+ hmr = createHmrServer(hmrPort);
118
+ } catch (err) {
119
+ if ((err as { code?: string }).code === "EADDRINUSE") onPortInUse("HMR socket", hmrPort);
120
+ throw err;
121
+ }
45
122
 
46
123
  // Build client bundle before the HTTP server starts accepting requests
47
124
  const { duration: initialMs } = await rebuildClient(merged);
48
125
  console.log(`[bractjs] initial client build in ${initialMs}ms`);
49
126
 
127
+ // Lint route modules + collect the route table (read sources once, no exec).
128
+ const routeRows = await inspectRoutes(appDir);
129
+
50
130
  // Load user lifecycle hooks if defined (e.g. app/lifecycle.ts)
51
131
  let lifecycle: LifecycleHooks = {};
52
132
  try {
@@ -57,9 +137,26 @@ export async function createDevServer(options?: DevServerOptions): Promise<DevSe
57
137
  // No lifecycle file — that's fine
58
138
  }
59
139
 
60
- const srv = createServer({ port: appPort, ...merged, ...lifecycle });
140
+ let srv: ReturnType<typeof createServer>;
141
+ try {
142
+ srv = createServer({ port: appPort, ...merged, ...lifecycle });
143
+ } catch (err) {
144
+ hmr.stop();
145
+ if ((err as { code?: string }).code === "EADDRINUSE") onPortInUse("app server", appPort);
146
+ throw err;
147
+ }
148
+
149
+ watchApp(appDir, async (file, info) => {
150
+ // Add/remove/rename of a route file changes the route set → regenerate
151
+ // typed routes. Saves (content changes) never alter the generated output
152
+ // (it uses type-only `typeof import(...)`), so skip codegen on those.
153
+ if (info.renameSeen && file.startsWith("routes/")) {
154
+ await syncRouteTypes(appDir);
155
+ }
156
+
157
+ // Re-lint changed route modules (warn-once dedupes repeats).
158
+ if (file.startsWith("routes/")) await inspectRoutes(appDir);
61
159
 
62
- watchApp(merged.appDir ?? "./app", async (file) => {
63
160
  const { duration } = await rebuildClient(merged);
64
161
 
65
162
  // Route files (not layout): do a fine-grained module swap without full reload.
@@ -81,7 +178,8 @@ export async function createDevServer(options?: DevServerOptions): Promise<DevSe
81
178
  }
82
179
  });
83
180
 
84
- console.log(`BractJS dev server on http://localhost:${appPort}`);
181
+ console.log(formatRouteTable(routeRows));
182
+ console.log(`BractJS dev server on http://localhost:${appPort} (HMR ws://localhost:${hmrPort})`);
85
183
 
86
184
  return {
87
185
  stop() {
@@ -3,21 +3,42 @@ import { watch } from "node:fs";
3
3
 
4
4
  const WATCHED_EXTENSIONS = new Set([".tsx", ".ts", ".css"]);
5
5
 
6
+ /** Extra info about a debounced change burst. */
7
+ export interface WatchChangeInfo {
8
+ /** The last file event type seen in the burst. */
9
+ event: "rename" | "change";
10
+ /**
11
+ * True when ANY event in the burst was a "rename" (add/remove/rename) — not
12
+ * just the last one. `fs.watch` collapses bursts, so this is OR-accumulated
13
+ * across the debounce window; the codegen trigger keys on it.
14
+ */
15
+ renameSeen: boolean;
16
+ }
17
+
6
18
  /**
7
19
  * Watches appDir for file changes and calls onChange with the changed file path.
8
20
  * Debounces rapid changes within 50ms to avoid duplicate rebuilds.
9
21
  */
10
- export function watchApp(appDir: string, onChange: (file: string) => void): void {
22
+ export function watchApp(
23
+ appDir: string,
24
+ onChange: (file: string, info: WatchChangeInfo) => void,
25
+ ): void {
11
26
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
12
27
  let pendingFile = "";
28
+ let lastEvent: "rename" | "change" = "change";
29
+ let renameSeen = false;
13
30
 
14
- watch(appDir, { recursive: true }, (_eventType, filename) => {
31
+ watch(appDir, { recursive: true }, (eventType, filename) => {
15
32
  if (!filename) return;
16
33
 
17
34
  const ext = path.extname(filename);
18
35
  if (!WATCHED_EXTENSIONS.has(ext)) return;
19
36
 
20
37
  pendingFile = filename;
38
+ lastEvent = eventType === "rename" ? "rename" : "change";
39
+ // OR-accumulate across the debounce window: a save (change) immediately
40
+ // followed by a create (rename) must not lose the rename signal.
41
+ if (lastEvent === "rename") renameSeen = true;
21
42
 
22
43
  if (debounceTimer !== null) {
23
44
  clearTimeout(debounceTimer);
@@ -26,7 +47,8 @@ export function watchApp(appDir: string, onChange: (file: string) => void): void
26
47
  debounceTimer = setTimeout(() => {
27
48
  debounceTimer = null;
28
49
  console.log(`✓ ${path.basename(pendingFile)} changed`);
29
- onChange(pendingFile);
50
+ onChange(pendingFile, { event: lastEvent, renameSeen });
51
+ renameSeen = false;
30
52
  }, 50);
31
53
  });
32
54
  }
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,17 +110,28 @@ 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";
111
122
  export { useNavigate } from "./client/hooks/useNavigate.ts";
112
123
  export type { NavigateFn, NavigateOptions } from "./client/hooks/useNavigate.ts";
113
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";
114
130
  export { useSearchParams } from "./client/hooks/useSearchParams.ts";
115
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";
116
135
  export { useBlocker } from "./client/hooks/useBlocker.ts";
117
136
  export { useLocale } from "./client/hooks/useLocale.ts";
118
137
  export { useLocalizedLink } from "./client/hooks/useLocalizedLink.ts";
@@ -127,6 +146,8 @@ export type {
127
146
  RegisteredRoutes,
128
147
  ParamsFor,
129
148
  SearchFor,
149
+ SearchOutputFor,
150
+ InferSchemaOutput,
130
151
  RouteSearchParamsMap,
131
152
  RouteContextMap,
132
153
  } from "./client/registry.ts";
@@ -141,4 +162,7 @@ export { createDevServer } from "./dev/server.ts";
141
162
  export type { DevServerOptions, DevServer } from "./dev/server.ts";
142
163
  export { runBuild } from "./build/bundler.ts";
143
164
  export type { BuildConfig } from "./build/bundler.ts";
144
- 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 });