@bractjs/bractjs 0.1.22 → 0.1.24

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.
@@ -0,0 +1,316 @@
1
+ import { join, resolve } from "node:path";
2
+ import { scanRoutes, type RouteFile } from "../server/scanner.ts";
3
+
4
+ // Codegen entry-points: `bun build --compile` can't statically trace
5
+ // `Bun.Glob` scans or `import(absPath)` calls, so we materialise the route /
6
+ // layout / action graph into static `import` statements at build time. The
7
+ // emitted files live at `<appDir>/_generated/{routes,actions}.ts` and are
8
+ // imported by the user-facing `app/server.ts` entrypoint.
9
+
10
+ // ── Path safety ────────────────────────────────────────────────────────────
11
+
12
+ // Allow ASCII filename characters, `/` for nested directories, and `[`/`]`
13
+ // for file-based dynamic route syntax (`[id]`, `[...slug]`). All emit sites
14
+ // wrap the path in JSON.stringify, but we still allowlist the charset as
15
+ // defense-in-depth against a hostile filename containing a backtick, $, quote,
16
+ // backslash, or whitespace breaking out of the generated literal. `..` as a
17
+ // whole segment is rejected separately below (path-traversal guard).
18
+ const SAFE_FILEPATH_RE = /^[A-Za-z0-9._\/\-\[\]]+$/;
19
+
20
+ function assertSafeFilePath(filePath: string): void {
21
+ if (!SAFE_FILEPATH_RE.test(filePath)) {
22
+ throw new Error(`[bractjs] codegen: refusing to emit unsafe file path: ${JSON.stringify(filePath)}`);
23
+ }
24
+ if (filePath.split("/").includes("..")) {
25
+ throw new Error(`[bractjs] codegen: refusing to emit path with .. segment: ${JSON.stringify(filePath)}`);
26
+ }
27
+ }
28
+
29
+ // Derive a JS identifier from an arbitrary relative path. Any character outside
30
+ // `[A-Za-z0-9_]` collapses to `_`. The leading digit (if any) gets an `_`
31
+ // prefix. This is purely for variable naming in the generated source; the
32
+ // authoritative key for the registry is the JSON-stringified relPath.
33
+ function pathToIdent(prefix: string, relPath: string): string {
34
+ const safe = relPath.replace(/[^A-Za-z0-9_]/g, "_");
35
+ return /^[0-9]/.test(safe) ? `${prefix}__${safe}` : `${prefix}_${safe}`;
36
+ }
37
+
38
+ // ── Layout discovery ───────────────────────────────────────────────────────
39
+
40
+ function layoutDirsForPattern(urlPattern: string): string[] {
41
+ if (urlPattern === "") return [];
42
+ const segments = urlPattern.split("/");
43
+ segments.pop();
44
+ const dirs: string[] = [];
45
+ for (let i = 1; i <= segments.length; i++) {
46
+ dirs.push(segments.slice(0, i).join("/"));
47
+ }
48
+ return dirs;
49
+ }
50
+
51
+ /**
52
+ * Find every `routes/<dir>/layout.tsx` (or `.ts`) that exists on disk for the
53
+ * given set of routes. Mirrors the runtime probe in `resolveLayoutChain` but
54
+ * runs once at codegen time so the generated registry is exhaustive.
55
+ */
56
+ async function collectLayouts(appDir: string, routes: RouteFile[]): Promise<string[]> {
57
+ const layoutPaths = new Set<string>();
58
+ for (const route of routes) {
59
+ for (const dir of layoutDirsForPattern(route.urlPattern)) {
60
+ for (const ext of ["tsx", "ts"]) {
61
+ const rel = `routes/${dir}/layout.${ext}`;
62
+ const abs = resolve(join(appDir, rel));
63
+ if (await Bun.file(abs).exists()) {
64
+ layoutPaths.add(rel);
65
+ }
66
+ }
67
+ }
68
+ }
69
+ return [...layoutPaths].sort();
70
+ }
71
+
72
+ // ── Action discovery ───────────────────────────────────────────────────────
73
+
74
+ const SERVER_DIRECTIVE_RE = /^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*["']use server["']/;
75
+
76
+ function isEligibleActionPath(rel: string): boolean {
77
+ return (
78
+ rel.endsWith(".server.ts") ||
79
+ rel.endsWith(".server.tsx") ||
80
+ rel.startsWith("routes/") ||
81
+ rel.startsWith("routes\\")
82
+ );
83
+ }
84
+
85
+ async function collectActionFiles(appDir: string): Promise<string[]> {
86
+ const glob = new Bun.Glob("**/*.{ts,tsx}");
87
+ const found: string[] = [];
88
+ for await (const rel of glob.scan(appDir)) {
89
+ if (!isEligibleActionPath(rel)) continue;
90
+ // Skip the codegen output directory itself.
91
+ if (rel.startsWith("_generated/")) continue;
92
+ const abs = resolve(join(appDir, rel));
93
+ let src: string;
94
+ try {
95
+ src = await Bun.file(abs).text();
96
+ } catch {
97
+ continue;
98
+ }
99
+ if (!SERVER_DIRECTIVE_RE.test(src)) continue;
100
+ // Normalise Windows separators to POSIX for the registry key.
101
+ found.push(rel.split("\\").join("/"));
102
+ }
103
+ return found.sort();
104
+ }
105
+
106
+ // ── Generators ─────────────────────────────────────────────────────────────
107
+
108
+ const HEADER =
109
+ "// Generated by `bractjs codegen` — do not edit manually.\n" +
110
+ "// Static imports here let `bun build --compile` trace every route, layout,\n" +
111
+ "// and server-action module instead of relying on runtime fs scans.\n" +
112
+ "\n" +
113
+ "/* eslint-disable */\n";
114
+
115
+ export interface RouteRegistryInput {
116
+ appDir: string;
117
+ routes: RouteFile[];
118
+ layoutRelPaths: string[]; // e.g. ["routes/blog/layout.tsx"]
119
+ hasRoot: boolean; // true if appDir/root.tsx exists
120
+ }
121
+
122
+ export function generateRouteRegistry(input: RouteRegistryInput): string {
123
+ const { routes, layoutRelPaths, hasRoot } = input;
124
+
125
+ // ── Build the import statements ──
126
+ const imports: string[] = [];
127
+ const entries: string[] = [];
128
+
129
+ if (hasRoot) {
130
+ assertSafeFilePath("root.tsx");
131
+ const ident = pathToIdent("mod", "root_tsx");
132
+ imports.push(`import * as ${ident} from "../root.tsx";`);
133
+ entries.push(` ${JSON.stringify("root.tsx")}: ${ident},`);
134
+ }
135
+
136
+ for (const rel of layoutRelPaths) {
137
+ assertSafeFilePath(rel);
138
+ const ident = pathToIdent("mod", rel);
139
+ imports.push(`import * as ${ident} from ${JSON.stringify("../" + rel)};`);
140
+ entries.push(` ${JSON.stringify(rel)}: ${ident},`);
141
+ }
142
+
143
+ for (const r of routes) {
144
+ const rel = r.filePath.split("\\").join("/");
145
+ assertSafeFilePath(rel);
146
+ const ident = pathToIdent("mod", rel);
147
+ imports.push(`import * as ${ident} from ${JSON.stringify("../" + rel)};`);
148
+ entries.push(` ${JSON.stringify(rel)}: ${ident},`);
149
+ }
150
+
151
+ // ── routeFiles array literal (mirrors scanRoutes output shape) ──
152
+ const routeFilesLines: string[] = [];
153
+ for (const r of routes) {
154
+ const rel = r.filePath.split("\\").join("/");
155
+ assertSafeFilePath(rel);
156
+ const segments = JSON.stringify(r.segments);
157
+ routeFilesLines.push(
158
+ ` { filePath: ${JSON.stringify(rel)}, urlPattern: ${JSON.stringify(r.urlPattern)}, segments: ${segments} },`,
159
+ );
160
+ }
161
+
162
+ return [
163
+ HEADER,
164
+ `import type { RouteFile, ModuleRegistry } from "@bractjs/bractjs";`,
165
+ "",
166
+ imports.join("\n"),
167
+ "",
168
+ "// Modules keyed by appDir-relative path. The framework looks up route,",
169
+ "// layout, and root modules here at request time instead of doing a",
170
+ "// dynamic `import(absPath)` call that would fail in a compiled binary.",
171
+ "//",
172
+ "// `ModuleRegistry` is intentionally `Record<string, RouteModule | Record<string, unknown>>`",
173
+ "// — strict `RouteModule` typing would reject route files whose exports",
174
+ "// (e.g. an ErrorBoundary typed for `{ error: Error }` instead of `{ error: unknown }`)",
175
+ "// diverge slightly from the framework signature. Runtime behaviour is unchanged.",
176
+ "export const moduleRegistry: ModuleRegistry = {",
177
+ entries.join("\n"),
178
+ "};",
179
+ "",
180
+ "export const routeFiles: RouteFile[] = [",
181
+ routeFilesLines.join("\n"),
182
+ "];",
183
+ "",
184
+ ].join("\n");
185
+ }
186
+
187
+ export interface ActionRegistryInput {
188
+ appDir: string;
189
+ actionRelPaths: string[];
190
+ }
191
+
192
+ export function generateActionRegistry(input: ActionRegistryInput): string {
193
+ const { actionRelPaths } = input;
194
+
195
+ const imports: string[] = [];
196
+ const entries: string[] = [];
197
+
198
+ for (const rel of actionRelPaths) {
199
+ assertSafeFilePath(rel);
200
+ const ident = pathToIdent("act", rel);
201
+ imports.push(`import * as ${ident} from ${JSON.stringify("../" + rel)};`);
202
+ entries.push(` { relPath: ${JSON.stringify(rel)}, mod: ${ident} as Record<string, unknown> },`);
203
+ }
204
+
205
+ return [
206
+ HEADER,
207
+ imports.join("\n"),
208
+ "",
209
+ "// Server-action modules, statically imported so `bun build --compile`",
210
+ "// traces them. The framework iterates this list and registers each",
211
+ "// exported function under SHA-256(relPath + \"#\" + name) — identical to",
212
+ "// the ID the client proxy plugin embeds in the bundled JS.",
213
+ "export const actionModules: Array<{ relPath: string; mod: Record<string, unknown> }> = [",
214
+ entries.join("\n"),
215
+ "];",
216
+ "",
217
+ ].join("\n");
218
+ }
219
+
220
+ // ── Manifest codegen ───────────────────────────────────────────────────────
221
+
222
+ /**
223
+ * Shape produced by `src/build/manifest.ts` (`RouteManifest`). Re-typed here
224
+ * so the codegen module stays decoupled from the build pipeline.
225
+ */
226
+ interface DiskManifest {
227
+ version?: number;
228
+ mode?: string;
229
+ clientEntry: string;
230
+ rootChunk?: string;
231
+ routes: Record<string, { chunk: string; pattern: string }>;
232
+ }
233
+
234
+ /**
235
+ * Convert the disk-format manifest (RouteManifest) into a TypeScript module
236
+ * exporting a `ServerManifest` constant. This decouples the compiled-binary
237
+ * server entry from the disk manifest — at startup it imports the constant
238
+ * instead of reading `build/route-manifest.json`.
239
+ */
240
+ export function generateManifestModule(disk: DiskManifest): string {
241
+ // Mirror the RouteManifest → ServerManifest projection in serve.ts so the
242
+ // compiled binary sees the same shape `buildFetchHandler` would have built.
243
+ const projectedRoutes: Record<string, { file: string; chunk: string }> = {};
244
+ for (const [pat, entry] of Object.entries(disk.routes ?? {})) {
245
+ projectedRoutes[pat] = { file: entry.chunk, chunk: entry.chunk };
246
+ }
247
+ const projected = {
248
+ clientEntry: disk.clientEntry,
249
+ rootChunk: disk.rootChunk,
250
+ routes: projectedRoutes,
251
+ };
252
+ // JSON.stringify is safe for embedding — all strings get properly escaped.
253
+ // No template literals or backtick interpolation can leak through.
254
+ return [
255
+ HEADER,
256
+ `import type { ServerManifest } from "@bractjs/bractjs";`,
257
+ "",
258
+ "// Snapshot of build/route-manifest.json, frozen into the binary so the",
259
+ "// server never needs to read it from disk at startup.",
260
+ `export const manifest: ServerManifest = ${JSON.stringify(projected, null, 2)};`,
261
+ "",
262
+ ].join("\n");
263
+ }
264
+
265
+ /**
266
+ * Read `<buildDir>/route-manifest.json` and write
267
+ * `<appDir>/_generated/manifest.ts`. Must run AFTER the client `Bun.build()`
268
+ * step — chunk filenames are content-hashed and not known until then.
269
+ */
270
+ export async function writeManifestModule(
271
+ appDir: string,
272
+ buildDir: string,
273
+ ): Promise<string> {
274
+ const absAppDir = resolve(appDir);
275
+ const absBuildDir = resolve(buildDir);
276
+ const src = join(absBuildDir, "route-manifest.json");
277
+ const file = Bun.file(src);
278
+ if (!(await file.exists())) {
279
+ throw new Error(
280
+ `[bractjs] manifest codegen: ${src} not found. Run the client build before manifest codegen.`,
281
+ );
282
+ }
283
+ const disk = (await file.json()) as DiskManifest;
284
+ const out = join(absAppDir, "_generated", "manifest.ts");
285
+ await Bun.write(out, generateManifestModule(disk));
286
+ return out;
287
+ }
288
+
289
+ // ── Driver ─────────────────────────────────────────────────────────────────
290
+
291
+ export interface CodegenResult {
292
+ routesPath: string;
293
+ actionsPath: string;
294
+ }
295
+
296
+ export async function writeModuleRegistries(appDir: string): Promise<CodegenResult> {
297
+ const absAppDir = resolve(appDir);
298
+ const routes = await scanRoutes(absAppDir);
299
+ const layoutRelPaths = await collectLayouts(absAppDir, routes);
300
+ const hasRoot =
301
+ (await Bun.file(resolve(join(absAppDir, "root.tsx"))).exists()) ||
302
+ (await Bun.file(resolve(join(absAppDir, "root.ts"))).exists());
303
+ const actionRelPaths = await collectActionFiles(absAppDir);
304
+
305
+ const routesSrc = generateRouteRegistry({ appDir: absAppDir, routes, layoutRelPaths, hasRoot });
306
+ const actionsSrc = generateActionRegistry({ appDir: absAppDir, actionRelPaths });
307
+
308
+ const outDir = resolve(join(absAppDir, "_generated"));
309
+ const routesPath = join(outDir, "routes.ts");
310
+ const actionsPath = join(outDir, "actions.ts");
311
+
312
+ await Bun.write(routesPath, routesSrc);
313
+ await Bun.write(actionsPath, actionsSrc);
314
+
315
+ return { routesPath, actionsPath };
316
+ }
@@ -1,7 +1,7 @@
1
1
  import { resolve, join, sep } from "node:path";
2
2
  import { realpath } from "node:fs/promises";
3
3
  import { serverOnlyPlugin } from "../build/env-plugin.ts";
4
- import { useServerProxyPlugin } from "../build/directives.ts";
4
+ import { createUseServerProxyPlugin } from "../build/directives.ts";
5
5
 
6
6
  /**
7
7
  * Dev-only HTTP handler for /_hmr/module?file=routes/about.tsx
@@ -55,7 +55,7 @@ export async function handleHmrModuleRequest(
55
55
  target: "browser",
56
56
  minify: false,
57
57
  sourcemap: "inline",
58
- plugins: [serverOnlyPlugin, useServerProxyPlugin],
58
+ plugins: [serverOnlyPlugin, createUseServerProxyPlugin(rootDir)],
59
59
  });
60
60
 
61
61
  if (!result.success || result.outputs.length === 0) {
@@ -1,5 +1,5 @@
1
1
  import type { BractJSConfig } from "../server/serve.ts";
2
- import { useServerProxyPlugin } from "../build/directives.ts";
2
+ import { createUseServerProxyPlugin } from "../build/directives.ts";
3
3
  import { scanRoutes } from "../server/scanner.ts";
4
4
  import { generateManifest, writeManifest } from "../build/manifest.ts";
5
5
  import { mkdir, rename, rm } from "node:fs/promises";
@@ -48,7 +48,7 @@ export async function rebuildClient(
48
48
  // structure. publicPath + ../ traversals produce wrong absolute URLs.
49
49
  minify: false,
50
50
  sourcemap: "inline",
51
- plugins: [useServerProxyPlugin, ...(config?.plugins ?? [])],
51
+ plugins: [createUseServerProxyPlugin(appDir), ...(config?.plugins ?? [])],
52
52
  });
53
53
  } finally {
54
54
  await rm(shimPath, { force: true });
package/src/index.ts CHANGED
@@ -14,7 +14,36 @@ export { BunAdapter } from "./server/adapter.ts";
14
14
  export { createCloudflareAdapter, makeCloudflareHandler } from "./adapters/cloudflare.ts";
15
15
 
16
16
  // Build plugins
17
+ //
18
+ // These plugins are REQUIRED when users compose their own `Bun.build` call
19
+ // (e.g. native `bun build --compile` or a custom client bundle step). Missing
20
+ // any of them breaks security or runtime behaviour:
21
+ //
22
+ // - `useClientStubPlugin` (server bundle): replaces "use client" modules with
23
+ // null stubs. Without it, the server binary crashes when React tries to
24
+ // call browser-only hooks/APIs.
25
+ // - `createUseServerProxyPlugin(appDir)` (client bundle): replaces
26
+ // "use server" exports with fetch proxies. Without it, server-action
27
+ // bodies — including DB queries and secrets — ship inside the browser JS.
28
+ // - `serverOnlyPlugin` (client bundle): hard-fails imports of `*.server.ts`
29
+ // files so server-only code can never be tree-walked into the client bundle.
30
+ // - `clientEnvPlugin(allowedKeys, env)` (client bundle): allowlists which
31
+ // `process.env.*` references survive into the browser bundle.
32
+ // - `cssModulesPlugin` (client bundle): handles `*.module.css` imports.
17
33
  export { cssModulesPlugin, transformCssModule } from "./build/plugins/css-modules.ts";
34
+ export { useClientStubPlugin, createUseServerProxyPlugin, useServerProxyPlugin } from "./build/directives.ts";
35
+ export { serverOnlyPlugin, clientEnvPlugin } from "./build/env-plugin.ts";
36
+
37
+ // Module-registry codegen (drives `bun build --compile` workflow)
38
+ export {
39
+ writeModuleRegistries,
40
+ writeManifestModule,
41
+ generateRouteRegistry,
42
+ generateActionRegistry,
43
+ generateManifestModule,
44
+ } from "./codegen/module-registry.ts";
45
+ export type { CodegenResult } from "./codegen/module-registry.ts";
46
+ export type { ModuleRegistry } from "./server/layout.ts";
18
47
 
19
48
  // Client RPC
20
49
  export { createClient } from "./client/rpc.ts";
@@ -34,6 +63,7 @@ export type {
34
63
  RouteModule,
35
64
  RouteDefinition,
36
65
  } from "./shared/route-types.ts";
66
+ export type { RouteFile, Segment } from "./server/scanner.ts";
37
67
 
38
68
  export { BractJSError, HttpError, isRedirect, isHttpError, isBractJSError } from "./shared/errors.ts";
39
69
  export { Deferred, defer, isDeferred } from "./shared/deferred.ts";
@@ -1,4 +1,4 @@
1
- import { join } from "node:path";
1
+ import { join, relative, resolve, isAbsolute } from "node:path";
2
2
 
3
3
  // Anchored at start-of-file. Allow whitespace and line/block comments before
4
4
  // the "use server" string literal. This prevents false matches from a "use
@@ -6,8 +6,12 @@ import { join } 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
- async function computeId(filePath: string, name: string): Promise<string> {
10
- const raw = new TextEncoder().encode(filePath + "#" + name);
9
+ /**
10
+ * Hash key for an action must use the same string the client-side proxy
11
+ * plugin hashes (`pathKey + "#" + name`). Mismatch → `/_action?id=...` 404.
12
+ */
13
+ async function computeId(pathKey: string, name: string): Promise<string> {
14
+ const raw = new TextEncoder().encode(pathKey + "#" + name);
11
15
  const buf = await crypto.subtle.digest("SHA-256", raw);
12
16
  return Array.from(new Uint8Array(buf))
13
17
  .map((b) => b.toString(16).padStart(2, "0"))
@@ -15,6 +19,18 @@ async function computeId(filePath: string, name: string): Promise<string> {
15
19
  .slice(0, 16);
16
20
  }
17
21
 
22
+ /**
23
+ * Convert an absolute file path to the appDir-relative key used for hashing.
24
+ * Matches `pathKeyForAction` in `src/build/directives.ts`. Files outside
25
+ * appDir keep their absolute path so external imports stay hashable but
26
+ * distinct from in-tree files.
27
+ */
28
+ function pathKeyForAction(absPath: string, appDir: string): string {
29
+ const absAppDir = isAbsolute(appDir) ? appDir : resolve(appDir);
30
+ const rel = relative(absAppDir, absPath);
31
+ return rel.startsWith("..") ? absPath : rel;
32
+ }
33
+
18
34
  export function resolveAction(id: string): ((...args: unknown[]) => Promise<unknown>) | null {
19
35
  return registry.get(id) ?? null;
20
36
  }
@@ -47,7 +63,28 @@ export async function loadServerActions(appDir: string): Promise<void> {
47
63
 
48
64
  for (const [name, val] of Object.entries(mod)) {
49
65
  if (typeof val !== "function") continue;
50
- const id = await computeId(filePath, name);
66
+ const id = await computeId(pathKeyForAction(filePath, appDir), name);
67
+ registry.set(id, val as (...args: unknown[]) => Promise<unknown>);
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Registry-driven counterpart to `loadServerActions`. Skips the filesystem
74
+ * scan and dynamic imports — every entry was already statically imported by
75
+ * `_generated/actions.ts`, so we just iterate and register.
76
+ *
77
+ * Each entry's `relPath` MUST be appDir-relative (matches what
78
+ * `createUseServerProxyPlugin(appDir)` hashed during the client build).
79
+ * Mismatched relPaths produce silent `/_action?id=...` 404s.
80
+ */
81
+ export async function loadServerActionsFromRegistry(
82
+ entries: Array<{ relPath: string; mod: Record<string, unknown> }>,
83
+ ): Promise<void> {
84
+ for (const { relPath, mod } of entries) {
85
+ for (const [name, val] of Object.entries(mod)) {
86
+ if (typeof val !== "function") continue;
87
+ const id = await computeId(relPath, name);
51
88
  registry.set(id, val as (...args: unknown[]) => Promise<unknown>);
52
89
  }
53
90
  }
@@ -14,6 +14,14 @@ export interface ResolvedRoute extends RouteFile {
14
14
  layoutFiles: string[];
15
15
  }
16
16
 
17
+ /**
18
+ * Pre-loaded module map keyed by appDir-relative path (e.g. "root.tsx",
19
+ * "routes/blog/layout.tsx"). When `resolveRouteChain` is called with a
20
+ * registry, all module lookups go through the registry instead of dynamic
21
+ * `import(absPath)` — this is what makes `bun build --compile` viable.
22
+ */
23
+ export type ModuleRegistry = Record<string, RouteModule | Record<string, unknown>>;
24
+
17
25
  // ── Helpers ────────────────────────────────────────────────────────────────
18
26
 
19
27
  /** Derive the ancestor directory segments from a route's urlPattern. */
@@ -55,6 +63,31 @@ export async function resolveLayoutChain(
55
63
  return { ...routeFile, layoutFiles };
56
64
  }
57
65
 
66
+ /**
67
+ * Registry-driven equivalent of `resolveLayoutChain`. Skips all filesystem
68
+ * checks — returns the appDir-relative keys that exist in the registry, in
69
+ * the same root-first, outermost-to-innermost order. Required for compiled
70
+ * binaries where `Bun.file().exists()` against the original app paths is
71
+ * unreliable.
72
+ */
73
+ export function resolveLayoutChainFromRegistry(
74
+ routeFile: RouteFile,
75
+ registry: ModuleRegistry,
76
+ ): ResolvedRoute {
77
+ const layoutFiles: string[] = [];
78
+ if (registry["root.tsx"]) layoutFiles.push("root.tsx");
79
+ else if (registry["root.ts"]) layoutFiles.push("root.ts");
80
+
81
+ for (const dir of layoutDirs(routeFile.urlPattern)) {
82
+ const tsxKey = `routes/${dir}/layout.tsx`;
83
+ const tsKey = `routes/${dir}/layout.ts`;
84
+ if (registry[tsxKey]) layoutFiles.push(tsxKey);
85
+ else if (registry[tsKey]) layoutFiles.push(tsKey);
86
+ }
87
+
88
+ return { ...routeFile, layoutFiles };
89
+ }
90
+
58
91
  // ── importRouteModule ──────────────────────────────────────────────────────
59
92
 
60
93
  export async function importRouteModule(filePath: string): Promise<RouteModule> {
@@ -69,12 +102,52 @@ export async function importRouteModule(filePath: string): Promise<RouteModule>
69
102
  };
70
103
  }
71
104
 
105
+ /**
106
+ * Project a registry entry (raw `import * as ns` namespace) into the
107
+ * subset shape `RouteModule` requires. Mirrors `importRouteModule` but
108
+ * skips the dynamic `import()` because the module is already loaded.
109
+ */
110
+ function pickRouteModule(mod: Record<string, unknown> | RouteModule | undefined): RouteModule {
111
+ if (!mod) return {};
112
+ const m = mod as Record<string, unknown>;
113
+ return {
114
+ loader: m.loader as RouteModule["loader"],
115
+ action: m.action as RouteModule["action"],
116
+ meta: m.meta as RouteModule["meta"],
117
+ handle: m.handle as RouteModule["handle"],
118
+ ErrorBoundary: m.ErrorBoundary as RouteModule["ErrorBoundary"],
119
+ default: m.default as RouteModule["default"],
120
+ };
121
+ }
122
+
72
123
  // ── resolveRouteChain ──────────────────────────────────────────────────────
73
124
 
125
+ /**
126
+ * Build the route + layout chain for a matched route.
127
+ *
128
+ * Two modes:
129
+ * - Registry mode (production / compiled binary): when `registry` is provided,
130
+ * no filesystem checks and no dynamic imports run. Every module lookup is a
131
+ * `Record` access keyed by appDir-relative path.
132
+ * - Dev mode (no registry): existing filesystem-probe + `import(absPath)`
133
+ * path, used by `bractjs dev` so edits to layouts/routes don't require a
134
+ * codegen rerun.
135
+ */
74
136
  export async function resolveRouteChain(
75
137
  routeFile: RouteFile,
76
- appDir: string
138
+ appDir: string,
139
+ registry?: ModuleRegistry,
77
140
  ): Promise<LayoutChain> {
141
+ if (registry) {
142
+ const resolved = resolveLayoutChainFromRegistry(routeFile, registry);
143
+ const [rootKey, ...layoutKeys] = resolved.layoutFiles;
144
+ const rootMod = rootKey ? pickRouteModule(registry[rootKey]) : {};
145
+ const layoutMods = layoutKeys.map((k) => pickRouteModule(registry[k]));
146
+ const routeKey = routeFile.filePath.split("\\").join("/");
147
+ const routeMod = pickRouteModule(registry[routeKey]);
148
+ return { root: rootMod, layouts: layoutMods, route: routeMod };
149
+ }
150
+
78
151
  const resolved = await resolveLayoutChain(routeFile, appDir);
79
152
 
80
153
  const [rootMod, ...layoutMods] = await Promise.all(
@@ -1,9 +1,23 @@
1
+ export type OnErrorHook = (err: unknown, request?: Request) => Promise<void> | void;
2
+
1
3
  export interface LifecycleHooks {
2
4
  onStart?: () => Promise<void> | void;
3
5
  onShutdown?: () => Promise<void> | void;
6
+ /** Called for every unexpected error: loader failures, action throws, and uncaught process exceptions. Redirects and HttpErrors are intentional control flow and are NOT reported here. Use this to send errors to Sentry, Datadog, etc. The request is undefined for process-level exceptions. */
7
+ onError?: OnErrorHook;
4
8
  }
5
9
 
6
10
  /** Type-safe helper for declaring server lifecycle hooks in app/lifecycle.ts. */
7
11
  export function defineLifecycle(hooks: LifecycleHooks): LifecycleHooks {
8
12
  return hooks;
9
13
  }
14
+
15
+ /** Safely invokes the onError hook. Errors thrown inside the hook are caught and logged so they never mask the original error or alter the response. */
16
+ export async function fireOnError(hook: OnErrorHook | undefined, err: unknown, request?: Request): Promise<void> {
17
+ if (!hook) return;
18
+ try {
19
+ await hook(err, request);
20
+ } catch (hookErr) {
21
+ console.error("[bractjs] onError hook threw:", hookErr);
22
+ }
23
+ }
@@ -3,6 +3,7 @@ import type { LayoutChain } from "./layout.ts";
3
3
  import { isRedirect, isHttpError } from "../shared/errors.ts";
4
4
  import { isExplicitDev } from "./env.ts";
5
5
  import type { ContextFactory } from "./context.ts";
6
+ import { fireOnError, type OnErrorHook } from "./lifecycle.ts";
6
7
 
7
8
  // ── Types ──────────────────────────────────────────────────────────────────
8
9
 
@@ -18,7 +19,8 @@ export interface LoaderResults {
18
19
 
19
20
  export async function safeRun<T>(
20
21
  fn: ((args: LoaderArgs) => Promise<T> | T) | undefined,
21
- args: LoaderArgs
22
+ args: LoaderArgs,
23
+ onError?: OnErrorHook,
22
24
  ): Promise<T | { __error: unknown } | null> {
23
25
  if (!fn) return null;
24
26
 
@@ -35,6 +37,7 @@ export async function safeRun<T>(
35
37
  // surface structured user-facing errors should throw an HttpError, not
36
38
  // a custom Error subclass.
37
39
  console.error("[bractjs] loader error:", err);
40
+ await fireOnError(onError, err, args.request);
38
41
  const safe = isExplicitDev()
39
42
  ? {
40
43
  message: err instanceof Error ? err.message : String(err),
@@ -74,20 +77,22 @@ export async function runBeforeLoad(
74
77
 
75
78
  export async function runLoaders(
76
79
  chain: LayoutChain,
77
- args: LoaderArgs
80
+ args: LoaderArgs,
81
+ onError?: OnErrorHook,
78
82
  ): Promise<LoaderResults> {
79
83
  const layoutLoaders = chain.layouts.map((mod) =>
80
- safeRun(mod.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args)
84
+ safeRun(mod.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError)
81
85
  );
82
86
 
83
87
  const [root, ...layoutResults] = await Promise.all([
84
- safeRun(chain.root.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args),
88
+ safeRun(chain.root.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError),
85
89
  ...layoutLoaders,
86
90
  ]);
87
91
 
88
92
  const route = await safeRun(
89
93
  chain.route.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined,
90
- args
94
+ args,
95
+ onError,
91
96
  );
92
97
 
93
98
  return { root, layouts: layoutResults, route };