@bractjs/bractjs 0.1.22 → 0.1.23

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.
package/README.md CHANGED
@@ -442,12 +442,13 @@ export const db = new Database(Bun.env.DATABASE_URL);
442
442
 
443
443
  ## Server Lifecycle Hooks
444
444
 
445
- Use `defineLifecycle()` in `app/lifecycle.ts` to run code when the server starts or shuts down. The shutdown hook runs on **any** exit signal (`SIGTERM`, `SIGINT`, `SIGUSR2`, `beforeExit`, and uncaught exceptions), so database connections are always closed cleanly.
445
+ Use `defineLifecycle()` in `app/lifecycle.ts` to run code when the server starts, shuts down, or encounters an error. The shutdown hook runs on **any** exit signal (`SIGTERM`, `SIGINT`, `SIGUSR2`, `beforeExit`, and uncaught exceptions), so database connections are always closed cleanly.
446
446
 
447
447
  ```ts
448
448
  // app/lifecycle.ts
449
449
  import { defineLifecycle } from "@bractjs/bractjs";
450
450
  import { db } from "./db.server.ts";
451
+ import * as Sentry from "@sentry/bun";
451
452
 
452
453
  export default defineLifecycle({
453
454
  async onStart() {
@@ -458,6 +459,12 @@ export default defineLifecycle({
458
459
  await db.disconnect();
459
460
  console.log("Database disconnected");
460
461
  },
462
+ async onError(err, request) {
463
+ // request is undefined for process-level uncaught exceptions
464
+ Sentry.captureException(err, {
465
+ extra: { url: request?.url },
466
+ });
467
+ },
461
468
  });
462
469
  ```
463
470
 
@@ -474,7 +481,22 @@ createServer({ port: 3000, ...lifecycle });
474
481
  | Hook | When it runs |
475
482
  |------|-------------|
476
483
  | `onStart` | Once, after the server begins accepting requests |
477
- | `onShutdown` | Before process exit — any signal or uncaught exception |
484
+ | `onShutdown` | Before process exit — any signal, programmatic `stop()`, or uncaught exception |
485
+ | `onError` | Every unexpected error: loader failures, action throws, uncaught exceptions. Redirects and `HttpError` throws are intentional control flow and are **not** reported. |
486
+
487
+ ### Programmatic stop vs signal-driven termination
488
+
489
+ `createServer()` returns a `{ stop }` handle:
490
+
491
+ ```ts
492
+ const srv = createServer({ port: 3000 });
493
+ // later:
494
+ srv.stop(); // runs onShutdown, closes the listener, does NOT exit the process
495
+ ```
496
+
497
+ `stop()` returns the process to a normal idle state — useful in tests, integration harnesses, or any parent that wants to manage its own lifecycle. It does **not** call `process.exit()`.
498
+
499
+ The full termination path (`process.exit(0)`) only fires when a signal handler picks up the shutdown: `SIGTERM`, `SIGINT`, `SIGUSR2`, or `uncaughtException`. If you want a programmatic stop to terminate the process, call `process.exit(0)` yourself after `stop()` returns.
478
500
 
479
501
  ---
480
502
 
@@ -494,6 +516,7 @@ All fields are optional. BractJS works with zero configuration.
494
516
  | `clientEnv` | `string[]` | `[]` | `process.env` keys exposed to the client |
495
517
  | `onStart` | `() => void \| Promise<void>` | — | Called once after the server starts listening |
496
518
  | `onShutdown` | `() => void \| Promise<void>` | — | Called before process exit on any signal |
519
+ | `onError` | `(err, request?) => void \| Promise<void>` | — | Called on every unexpected error; `request` is `undefined` for process-level exceptions |
497
520
 
498
521
  ---
499
522
 
@@ -506,11 +529,102 @@ All fields are optional. BractJS works with zero configuration.
506
529
  | `bractjs build` | Dual server + client build with content-hashed output |
507
530
  | `bractjs start` | Serve the production build |
508
531
  | `bractjs codegen [app] [out]` | Generate typed route types into `app/route-types.gen.ts` |
532
+ | `bractjs codegen:registry [app]` | Generate `app/_generated/{routes,actions}.ts` (single-binary prep) |
533
+ | `bractjs codegen:manifest [app] [build]` | Snapshot `route-manifest.json` into `app/_generated/manifest.ts` |
534
+ | `bractjs compile [outfile] [entry]` | Full single-binary pipeline (codegen → build → compile) |
509
535
 
510
536
  The CLI is a thin convenience layer. Every command delegates to a public programmatic API — you can call the same functions directly from your own scripts without the CLI.
511
537
 
512
538
  ---
513
539
 
540
+ ## Single-Binary Deployment (`bun build --compile`)
541
+
542
+ BractJS can be packaged as a single executable using Bun's `--compile` flag. Because `bun build --compile` can't trace runtime filesystem scans or dynamic `import(absPath)` calls, BractJS provides a codegen step that materialises every route, layout, and server action into static imports. The compiled binary has zero filesystem dependence at startup.
543
+
544
+ ### One-shot
545
+
546
+ ```sh
547
+ bractjs compile ./myapp
548
+ # Equivalent to:
549
+ # bractjs codegen:registry # writes app/_generated/{routes,actions}.ts
550
+ # bractjs build # writes build/client/* + route-manifest.json
551
+ # bractjs codegen:manifest # snapshots manifest → app/_generated/manifest.ts
552
+ # bun build --compile app/server.ts --outfile ./myapp
553
+ ```
554
+
555
+ ### Manual pipeline (custom build step)
556
+
557
+ ```sh
558
+ bractjs codegen:registry # A — scan routes/actions
559
+ bractjs build # B — client + server bundles
560
+ bractjs codegen:manifest # C — embed manifest as a TS constant
561
+ bun build --compile app/server.ts \ # D — single binary
562
+ --asset build/client/ \ # (embeds JS/CSS into the binary)
563
+ --outfile ./myapp
564
+ ```
565
+
566
+ Asset embedding (`--asset build/client/`) is optional. Without it you ship `myapp` + the `build/client/` folder side-by-side. With it, you get a true single file.
567
+
568
+ ### The `app/server.ts` entry
569
+
570
+ The scaffold template (`bractjs new`) includes `app/server.ts`:
571
+
572
+ ```ts
573
+ import { createServer } from "@bractjs/bractjs";
574
+ import { routeFiles, moduleRegistry } from "./_generated/routes.ts";
575
+ import { actionModules } from "./_generated/actions.ts";
576
+ import { manifest } from "./_generated/manifest.ts";
577
+
578
+ createServer({
579
+ port: Number(process.env.PORT ?? 3000),
580
+ appDir: "./app",
581
+ publicDir: "./public",
582
+ manifest,
583
+ routeFiles, // skips Bun.Glob route scan
584
+ moduleRegistry, // skips dynamic import(absPath) of route modules
585
+ actionModules, // skips Bun.Glob + dynamic import for "use server" files
586
+ });
587
+ ```
588
+
589
+ When all four of `manifest`, `routeFiles`, `moduleRegistry`, and `actionModules` are present, the server boots with **no filesystem reads of `appDir`** — the routing trie, layout chain, server-action registry, and asset manifest all come from the pre-imported modules.
590
+
591
+ ### Custom client builds — required plugins
592
+
593
+ If you write your own `Bun.build()` call (instead of running `bractjs build`), you MUST apply these plugins or face crashes / secret leaks:
594
+
595
+ | Bundle | Plugin | What breaks without it |
596
+ |---|---|---|
597
+ | Server | `useClientStubPlugin` | Server binary crashes when React tries to invoke browser-only hooks/APIs from `"use client"` modules |
598
+ | Client | `createUseServerProxyPlugin(appDir)` | Server-action bodies (DB queries, secrets) ship inside the browser JS |
599
+ | Client | `serverOnlyPlugin` | Imports of `*.server.ts` leak into the client bundle |
600
+ | Client | `clientEnvPlugin(allowedKeys, env)` | Server env vars leak into the browser bundle |
601
+ | Client | `cssModulesPlugin` | `*.module.css` imports don't resolve |
602
+
603
+ ```ts
604
+ import {
605
+ useClientStubPlugin,
606
+ createUseServerProxyPlugin,
607
+ serverOnlyPlugin,
608
+ clientEnvPlugin,
609
+ cssModulesPlugin,
610
+ } from "@bractjs/bractjs";
611
+
612
+ // Server bundle (target: "bun"):
613
+ plugins: [useClientStubPlugin];
614
+
615
+ // Client bundle (target: "browser"):
616
+ plugins: [
617
+ serverOnlyPlugin,
618
+ createUseServerProxyPlugin("./app"),
619
+ clientEnvPlugin(["PUBLIC_API_URL"], Bun.env as Record<string, string>),
620
+ cssModulesPlugin,
621
+ ];
622
+ ```
623
+
624
+ The `createUseServerProxyPlugin(appDir)` factory exists because server-action IDs are SHA-256 hashes of the appDir-relative path. If the server and client compute different paths (e.g. CI vs prod), every `/_action?id=...` returns 404. Always pass the same `appDir` you pass to `createServer`.
625
+
626
+ ---
627
+
514
628
  ## Programmatic API
515
629
 
516
630
  All three runtime operations are importable, so BractJS can be embedded in existing servers or custom build scripts without the CLI.
@@ -576,8 +690,13 @@ createServer({ port: 3000, buildDir: "./build", ...lifecycle });
576
690
  my-app/
577
691
  ├── app/
578
692
  │ ├── root.tsx # required — <html> shell
579
- │ ├── lifecycle.ts # optional onStart / onShutdown hooks
693
+ │ ├── server.ts # bun build --compile entrypoint (single-binary build)
694
+ │ ├── lifecycle.ts # optional — onStart / onShutdown / onError hooks
580
695
  │ ├── route-types.gen.ts # generated by bractjs codegen
696
+ │ ├── _generated/ # generated by bractjs codegen:registry / codegen:manifest
697
+ │ │ ├── routes.ts # static imports for routes + layouts + root
698
+ │ │ ├── actions.ts # static imports for "use server" modules
699
+ │ │ └── manifest.ts # inline ServerManifest constant
581
700
  │ ├── actions.ts # "use server" actions
582
701
  │ └── routes/
583
702
  │ ├── _index.tsx # → /
@@ -596,6 +715,8 @@ my-app/
596
715
  └── route-manifest.json
597
716
  ```
598
717
 
718
+ The `_generated/` directory is only required for the single-binary workflow. Regular `bractjs dev` / `bractjs build` / `bractjs start` work without it.
719
+
599
720
  ---
600
721
 
601
722
  ## Architecture
@@ -623,10 +744,19 @@ Build pipeline (`bractjs build`):
623
744
  ```
624
745
  1. codegen → app/route-types.gen.ts
625
746
  2. server bundle → Bun.build (target: bun) + useClientStubPlugin
626
- 3. client bundle → Bun.build (target: browser, splitting) + useServerProxyPlugin
747
+ 3. client bundle → Bun.build (target: browser, splitting) + createUseServerProxyPlugin(appDir)
627
748
  4. content-hash → rename outputs, write route-manifest.json
628
749
  ```
629
750
 
751
+ Single-binary pipeline (`bractjs compile`):
752
+ ```
753
+ A. registry codegen → app/_generated/{routes,actions}.ts (static imports)
754
+ B. dual build → build/server/, build/client/, route-manifest.json
755
+ C. manifest codegen → app/_generated/manifest.ts (inline constant)
756
+ D. bun build → single executable with no runtime fs scans
757
+ --compile app/server.ts [--asset build/client/]
758
+ ```
759
+
630
760
  ---
631
761
 
632
762
  ## Package Structure
@@ -637,7 +767,7 @@ bractjs/
637
767
  │ ├── server/ # SSR, routing, loaders, actions, sessions, action-registry
638
768
  │ ├── client/ # hydrateRoot, contexts, hooks, Link/Form/Image components
639
769
  │ ├── build/ # Bun.build orchestration, manifest, hashing, directives
640
- │ ├── codegen/ # route-types.gen.ts generator
770
+ │ ├── codegen/ # route-types.gen.ts + module-registry codegen (_generated/*)
641
771
  │ ├── image/ # /_image handler, ImageMagick optimizer, LRU cache
642
772
  │ ├── dev/ # watcher, HMR server + client, error overlay
643
773
  │ ├── shared/ # types, errors, deferred, context
@@ -676,8 +806,9 @@ bractjs/
676
806
  - Typed routes codegen (`AppRoutes`, `RouteParams<T>`, `TypedLoaderArgs<T>`, `routes` builder)
677
807
  - `"use server"` / `"use client"` directive system with `/_action` endpoint
678
808
  - **Programmatic API** — `createDevServer`, `runBuild`, `loadUserConfig` importable without the CLI
809
+ - **Native `bun build --compile` support** — module-registry codegen produces single-binary deployables; build plugins exported from the public API; action IDs use relative paths for cross-machine stability
679
810
 
680
- Remaining on the roadmap: Edge runtime (Cloudflare Workers), CSS modules, i18n routing, streaming `useFetcher()`.
811
+ Remaining on the roadmap: streaming `useFetcher()` (full implementation).
681
812
 
682
813
  ---
683
814
 
package/bin/cli.ts CHANGED
@@ -35,6 +35,28 @@ async function scaffoldNew(appName: string): Promise<void> {
35
35
  process.exit(result.exitCode ?? 1);
36
36
  }
37
37
 
38
+ // Seed `app/_generated/` so the template's `app/server.ts` typechecks
39
+ // before the user runs a build. Only the route/action registries can run
40
+ // here (no manifest yet — that needs `bractjs build` first). The manifest
41
+ // module is stubbed in below.
42
+ console.log("Seeding _generated/ registries...");
43
+ try {
44
+ const { writeModuleRegistries } = await import("../src/codegen/module-registry.ts");
45
+ await writeModuleRegistries(join(appDir, "app"));
46
+ // Manifest stub — overwritten by `bractjs codegen:manifest` after a build
47
+ const stubManifest = [
48
+ "// Stub manifest — replaced by `bractjs codegen:manifest` after running",
49
+ "// `bractjs build`. Allows `app/server.ts` to typecheck before the",
50
+ "// first build completes.",
51
+ `import type { ServerManifest } from "@bractjs/bractjs";`,
52
+ `export const manifest: ServerManifest = { clientEntry: "/build/client/client.js", routes: {} };`,
53
+ "",
54
+ ].join("\n");
55
+ await Bun.write(join(appDir, "app", "_generated", "manifest.ts"), stubManifest);
56
+ } catch (err) {
57
+ console.warn("[bract] codegen seed skipped:", err instanceof Error ? err.message : err);
58
+ }
59
+
38
60
  console.log(`\n✓ Created ${appName}\n`);
39
61
  console.log("Next steps:");
40
62
  console.log(` cd ${appName}`);
@@ -99,14 +121,80 @@ switch (command) {
99
121
  break;
100
122
  }
101
123
 
124
+ case "codegen:registry": {
125
+ // Phase A of the `bun build --compile` pipeline: scan routes/layouts and
126
+ // server actions, then write static-import registries under
127
+ // `<appDir>/_generated/` so the resulting bundle has no fs-scan or
128
+ // `import(absPath)` calls at runtime.
129
+ const { writeModuleRegistries } = await import("../src/codegen/module-registry.ts");
130
+ const appDir = resolve(process.cwd(), process.argv[3] ?? "./app");
131
+ const { routesPath, actionsPath } = await writeModuleRegistries(appDir);
132
+ console.log("[bract] registry codegen →", routesPath);
133
+ console.log("[bract] registry codegen →", actionsPath);
134
+ break;
135
+ }
136
+
137
+ case "codegen:manifest": {
138
+ // Phase C of the pipeline: snapshot `<buildDir>/route-manifest.json`
139
+ // into `<appDir>/_generated/manifest.ts` so the compiled binary never
140
+ // reads the JSON from disk at startup. Must run AFTER the client build.
141
+ const { writeManifestModule } = await import("../src/codegen/module-registry.ts");
142
+ const appDir = resolve(process.cwd(), process.argv[3] ?? "./app");
143
+ const buildDir = resolve(process.cwd(), process.argv[4] ?? "./build");
144
+ const out = await writeManifestModule(appDir, buildDir);
145
+ console.log("[bract] manifest codegen →", out);
146
+ break;
147
+ }
148
+
149
+ case "compile": {
150
+ // Convenience: run the entire `bun build --compile` pipeline.
151
+ // A) registry codegen → B) client build → C) manifest codegen → D) compile.
152
+ // The user can also invoke A/C and D separately if they want a custom
153
+ // client build step.
154
+ if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
155
+ const { writeModuleRegistries, writeManifestModule } = await import("../src/codegen/module-registry.ts");
156
+ const { runBuild } = await import("../src/build/bundler.ts");
157
+ const { loadUserConfig } = await import("../src/config/load.ts");
158
+
159
+ const appDir = resolve(process.cwd(), "./app");
160
+ const buildDir = resolve(process.cwd(), "./build");
161
+ const outFile = process.argv[3] ?? "./bractjs-app";
162
+ const entryPath = process.argv[4] ?? "./app/server.ts";
163
+
164
+ console.log("[bract] (1/4) registry codegen…");
165
+ await writeModuleRegistries(appDir);
166
+
167
+ console.log("[bract] (2/4) client + server build…");
168
+ const userCfg = await loadUserConfig();
169
+ await runBuild({ appDir: "./app", buildDir: "./build", ...userCfg });
170
+
171
+ console.log("[bract] (3/4) manifest codegen…");
172
+ await writeManifestModule(appDir, buildDir);
173
+
174
+ console.log("[bract] (4/4) bun build --compile →", outFile);
175
+ const result = Bun.spawnSync(
176
+ ["bun", "build", "--compile", entryPath, "--outfile", outFile],
177
+ { cwd: process.cwd(), stdio: ["inherit", "inherit", "inherit"] },
178
+ );
179
+ if (result.exitCode !== 0) {
180
+ console.error("[bract] bun build --compile failed");
181
+ process.exit(result.exitCode ?? 1);
182
+ }
183
+ console.log(`[bract] ✓ single-binary build complete: ${outFile}`);
184
+ break;
185
+ }
186
+
102
187
  default:
103
188
  console.log(
104
189
  "Usage: bractjs <command>\n" +
105
- " new <app-name> Scaffold a new BractJS app\n" +
106
- " dev Start dev server with HMR\n" +
107
- " build Build for production\n" +
108
- " start Start production server\n" +
109
- " codegen [app] [out] Generate typed route types",
190
+ " new <app-name> Scaffold a new BractJS app\n" +
191
+ " dev Start dev server with HMR\n" +
192
+ " build Build for production (build/ dir)\n" +
193
+ " start Start production server\n" +
194
+ " codegen [app] [out] Generate typed route types\n" +
195
+ " codegen:registry [app] Generate _generated/{routes,actions}.ts\n" +
196
+ " codegen:manifest [app] [build] Generate _generated/manifest.ts\n" +
197
+ " compile [outfile] [entry] Full single-binary pipeline",
110
198
  );
111
199
  process.exit(1);
112
200
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bractjs/bractjs",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "Production-grade SSR framework for Bun + React 19. File-based routing, streaming SSR, server actions, typed routes.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/bractjs/bractjs#readme",
@@ -1,12 +1,21 @@
1
1
  import { test, expect, describe, beforeAll, afterAll } from "bun:test";
2
2
  import { mkdir, rm, writeFile } from "node:fs/promises";
3
- import { resolve, join } from "node:path";
4
- import { loadServerActions, resolveAction } from "../server/action-registry.ts";
3
+ import { resolve, relative, isAbsolute } from "node:path";
4
+ import { loadServerActions, loadServerActionsFromRegistry, resolveAction } from "../server/action-registry.ts";
5
5
 
6
6
  const TMP = resolve(import.meta.dir, ".tmp-action-registry");
7
7
 
8
- async function computeId(filePath: string, name: string): Promise<string> {
9
- const raw = new TextEncoder().encode(filePath + "#" + name);
8
+ // Mirrors `pathKeyForAction` in src/server/action-registry.ts + src/build/directives.ts.
9
+ // Action IDs are SHA-256(appDir-relative path + "#" + name) so the IDs are
10
+ // stable across machines and inside a compiled binary.
11
+ function pathKey(absPath: string, appDir: string): string {
12
+ const absAppDir = isAbsolute(appDir) ? appDir : resolve(appDir);
13
+ const rel = relative(absAppDir, absPath);
14
+ return rel.startsWith("..") ? absPath : rel;
15
+ }
16
+
17
+ async function computeId(absPath: string, name: string): Promise<string> {
18
+ const raw = new TextEncoder().encode(pathKey(absPath, TMP) + "#" + name);
10
19
  const buf = await crypto.subtle.digest("SHA-256", raw);
11
20
  return Array.from(new Uint8Array(buf))
12
21
  .map((b) => b.toString(16).padStart(2, "0"))
@@ -16,30 +25,30 @@ async function computeId(filePath: string, name: string): Promise<string> {
16
25
 
17
26
  beforeAll(async () => {
18
27
  await rm(TMP, { recursive: true, force: true });
19
- await mkdir(join(TMP, "routes"), { recursive: true });
20
- await mkdir(join(TMP, "lib"), { recursive: true });
28
+ await mkdir(resolve(TMP, "routes"), { recursive: true });
29
+ await mkdir(resolve(TMP, "lib"), { recursive: true });
21
30
 
22
31
  // Eligible: routes/ file with real "use server" directive
23
32
  await writeFile(
24
- join(TMP, "routes", "_index.tsx"),
33
+ resolve(TMP, "routes", "_index.tsx"),
25
34
  `"use server";\nexport async function realAction() { return 1; }\n`,
26
35
  );
27
36
 
28
37
  // Eligible by suffix: .server.ts
29
38
  await writeFile(
30
- join(TMP, "lib", "thing.server.ts"),
39
+ resolve(TMP, "lib", "thing.server.ts"),
31
40
  `"use server";\nexport async function suffixAction() { return 2; }\n`,
32
41
  );
33
42
 
34
43
  // Ineligible: arbitrary lib file (no .server suffix, not in routes/)
35
44
  await writeFile(
36
- join(TMP, "lib", "helpers.ts"),
45
+ resolve(TMP, "lib", "helpers.ts"),
37
46
  `"use server";\nexport async function shouldNotLoad() { return 3; }\n`,
38
47
  );
39
48
 
40
49
  // Ineligible: "use server" inside a template literal (not at start-of-file)
41
50
  await writeFile(
42
- join(TMP, "routes", "fake.tsx"),
51
+ resolve(TMP, "routes", "fake.tsx"),
43
52
  `const s = \`use server\`;\nexport async function notADirective() { return 4; }\n`,
44
53
  );
45
54
 
@@ -52,22 +61,53 @@ afterAll(async () => {
52
61
 
53
62
  describe("loadServerActions — eligibility", () => {
54
63
  test("routes/ file with real directive registers exports", async () => {
55
- const id = await computeId(join(TMP, "routes", "_index.tsx"), "realAction");
64
+ const id = await computeId(resolve(TMP, "routes", "_index.tsx"), "realAction");
56
65
  expect(resolveAction(id)).not.toBeNull();
57
66
  });
58
67
 
59
68
  test(".server.ts file registers exports", async () => {
60
- const id = await computeId(join(TMP, "lib", "thing.server.ts"), "suffixAction");
69
+ const id = await computeId(resolve(TMP, "lib", "thing.server.ts"), "suffixAction");
61
70
  expect(resolveAction(id)).not.toBeNull();
62
71
  });
63
72
 
64
73
  test("ineligible path (lib/*.ts) does NOT register", async () => {
65
- const id = await computeId(join(TMP, "lib", "helpers.ts"), "shouldNotLoad");
74
+ const id = await computeId(resolve(TMP, "lib", "helpers.ts"), "shouldNotLoad");
66
75
  expect(resolveAction(id)).toBeNull();
67
76
  });
68
77
 
69
78
  test("'use server' inside template literal does NOT register", async () => {
70
- const id = await computeId(join(TMP, "routes", "fake.tsx"), "notADirective");
79
+ const id = await computeId(resolve(TMP, "routes", "fake.tsx"), "notADirective");
71
80
  expect(resolveAction(id)).toBeNull();
72
81
  });
73
82
  });
83
+
84
+ describe("loadServerActionsFromRegistry", () => {
85
+ test("statically-imported modules register under SHA-256(relPath#name)", async () => {
86
+ // Action ID must match what the client proxy plugin would emit. The
87
+ // codegen path passes relPath directly (no appDir stripping needed) so
88
+ // the hash input is the literal entry.relPath string.
89
+ async function rawId(relPath: string, name: string): Promise<string> {
90
+ const raw = new TextEncoder().encode(relPath + "#" + name);
91
+ const buf = await crypto.subtle.digest("SHA-256", raw);
92
+ return Array.from(new Uint8Array(buf))
93
+ .map((b) => b.toString(16).padStart(2, "0"))
94
+ .join("")
95
+ .slice(0, 16);
96
+ }
97
+
98
+ const fakeMod = {
99
+ sendEmail: async (to: string) => `sent ${to}`,
100
+ __ignored: 42, // non-function — must be skipped
101
+ };
102
+ await loadServerActionsFromRegistry([
103
+ { relPath: "routes/contact.server.ts", mod: fakeMod },
104
+ ]);
105
+ const id = await rawId("routes/contact.server.ts", "sendEmail");
106
+ const fn = resolveAction(id);
107
+ expect(fn).not.toBeNull();
108
+ expect(await fn!("a@b.com")).toBe("sent a@b.com");
109
+
110
+ const ignored = await rawId("routes/contact.server.ts", "__ignored");
111
+ expect(resolveAction(ignored)).toBeNull();
112
+ });
113
+ });
@@ -0,0 +1,95 @@
1
+ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { resolve, join } from "node:path";
4
+ import {
5
+ resolveRouteChain,
6
+ resolveLayoutChainFromRegistry,
7
+ type ModuleRegistry,
8
+ } from "../server/layout.ts";
9
+
10
+ const TMP = resolve(import.meta.dir, ".tmp-layout-registry");
11
+
12
+ // Sentinel default components let us distinguish which module ended up where.
13
+ const rootModule = { default: () => "root" } as const;
14
+ const blogLayoutModule = { default: () => "blog-layout" } as const;
15
+ const blogPostModule = { default: () => "blog-post" } as const;
16
+ const indexModule = { default: () => "index" } as const;
17
+
18
+ const registry: ModuleRegistry = {
19
+ "root.tsx": rootModule,
20
+ "routes/blog/layout.tsx": blogLayoutModule,
21
+ "routes/blog/[slug].tsx": blogPostModule,
22
+ "routes/_index.tsx": indexModule,
23
+ };
24
+
25
+ beforeAll(async () => {
26
+ await rm(TMP, { recursive: true, force: true });
27
+ await mkdir(join(TMP, "routes", "blog"), { recursive: true });
28
+ });
29
+
30
+ afterAll(async () => {
31
+ await rm(TMP, { recursive: true, force: true });
32
+ });
33
+
34
+ describe("resolveLayoutChainFromRegistry", () => {
35
+ test("includes root + ancestor layouts in order", () => {
36
+ const r = resolveLayoutChainFromRegistry(
37
+ { filePath: "routes/blog/[slug].tsx", urlPattern: "blog/[slug]", segments: ["blog", { param: "slug" }] },
38
+ registry,
39
+ );
40
+ expect(r.layoutFiles).toEqual(["root.tsx", "routes/blog/layout.tsx"]);
41
+ });
42
+
43
+ test("omits root when registry has no root entry", () => {
44
+ const r = resolveLayoutChainFromRegistry(
45
+ { filePath: "routes/_index.tsx", urlPattern: "", segments: [] },
46
+ { "routes/_index.tsx": indexModule },
47
+ );
48
+ expect(r.layoutFiles).toEqual([]);
49
+ });
50
+
51
+ test("matches sibling-dir layouts only when route is deeper", () => {
52
+ // urlPattern "blog" → layoutDirs returns [] (no ancestor)
53
+ const r = resolveLayoutChainFromRegistry(
54
+ { filePath: "routes/blog/_index.tsx", urlPattern: "blog", segments: ["blog"] },
55
+ registry,
56
+ );
57
+ expect(r.layoutFiles).toEqual(["root.tsx"]);
58
+ });
59
+ });
60
+
61
+ describe("resolveRouteChain — registry mode", () => {
62
+ test("returns the pre-loaded route module without touching disk", async () => {
63
+ const chain = await resolveRouteChain(
64
+ { filePath: "routes/blog/[slug].tsx", urlPattern: "blog/[slug]", segments: ["blog", { param: "slug" }] },
65
+ // appDir intentionally points at a path that does NOT exist — registry mode
66
+ // must skip every fs check.
67
+ "/nonexistent/appdir",
68
+ registry,
69
+ );
70
+ expect(chain.root.default).toBe(rootModule.default);
71
+ expect(chain.layouts).toHaveLength(1);
72
+ expect(chain.layouts[0].default).toBe(blogLayoutModule.default);
73
+ expect(chain.route.default).toBe(blogPostModule.default);
74
+ });
75
+
76
+ test("missing route key yields an empty module shape (no throw)", async () => {
77
+ const chain = await resolveRouteChain(
78
+ { filePath: "routes/missing.tsx", urlPattern: "missing", segments: ["missing"] },
79
+ "/nonexistent",
80
+ registry,
81
+ );
82
+ expect(chain.route.default).toBeUndefined();
83
+ expect(chain.route.loader).toBeUndefined();
84
+ expect(chain.root.default).toBe(rootModule.default);
85
+ });
86
+
87
+ test("forward-slash normalisation: filePath with backslashes resolves the same key", async () => {
88
+ const chain = await resolveRouteChain(
89
+ { filePath: "routes\\blog\\[slug].tsx", urlPattern: "blog/[slug]", segments: ["blog", { param: "slug" }] },
90
+ "/nonexistent",
91
+ registry,
92
+ );
93
+ expect(chain.route.default).toBe(blogPostModule.default);
94
+ });
95
+ });