@bractjs/bractjs 0.1.21 → 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.
@@ -0,0 +1,178 @@
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
+ generateRouteRegistry,
6
+ generateActionRegistry,
7
+ generateManifestModule,
8
+ writeModuleRegistries,
9
+ writeManifestModule,
10
+ } from "../codegen/module-registry.ts";
11
+
12
+ const TMP = resolve(import.meta.dir, ".tmp-module-registry");
13
+
14
+ beforeAll(async () => {
15
+ await rm(TMP, { recursive: true, force: true });
16
+ await mkdir(join(TMP, "routes", "blog"), { recursive: true });
17
+
18
+ await writeFile(join(TMP, "root.tsx"), `export default function Root() { return null; }\n`);
19
+ await writeFile(join(TMP, "routes", "_index.tsx"), `export default function Home() { return null; }\n`);
20
+ await writeFile(join(TMP, "routes", "blog", "layout.tsx"), `export default function L({ children }: any) { return children; }\n`);
21
+ // Nested route — `routes/blog/layout.tsx` only applies because there is a
22
+ // deeper route under /blog/. Layouts wrap children, not the leaf at the
23
+ // same path level (matches `layout.ts`'s `layoutDirs` resolution).
24
+ await writeFile(join(TMP, "routes", "blog", "[slug].tsx"), `export default function P() { return null; }\n`);
25
+
26
+ await writeFile(
27
+ join(TMP, "contact.server.ts"),
28
+ `"use server";\nexport async function send() { return 1; }\n`,
29
+ );
30
+ });
31
+
32
+ afterAll(async () => {
33
+ await rm(TMP, { recursive: true, force: true });
34
+ });
35
+
36
+ describe("generateRouteRegistry", () => {
37
+ test("emits static imports and module map for root, layout, routes", () => {
38
+ const src = generateRouteRegistry({
39
+ appDir: TMP,
40
+ routes: [
41
+ { filePath: "routes/_index.tsx", urlPattern: "", segments: [] },
42
+ { filePath: "routes/blog/_index.tsx", urlPattern: "blog", segments: ["blog"] },
43
+ ],
44
+ layoutRelPaths: ["routes/blog/layout.tsx"],
45
+ hasRoot: true,
46
+ });
47
+
48
+ expect(src).toContain(`import * as mod_root_tsx from "../root.tsx";`);
49
+ expect(src).toContain(`import * as mod_routes_blog_layout_tsx from "../routes/blog/layout.tsx";`);
50
+ expect(src).toContain(`import * as mod_routes__index_tsx from "../routes/_index.tsx";`);
51
+ expect(src).toContain(`"root.tsx": mod_root_tsx,`);
52
+ expect(src).toContain(`"routes/blog/layout.tsx": mod_routes_blog_layout_tsx,`);
53
+ expect(src).toContain(`export const moduleRegistry: Record<string, RouteModule>`);
54
+ expect(src).toContain(`export const routeFiles: RouteFile[]`);
55
+ });
56
+
57
+ test("rejects hostile file paths", () => {
58
+ expect(() =>
59
+ generateRouteRegistry({
60
+ appDir: TMP,
61
+ routes: [{ filePath: "routes/`evil`.tsx", urlPattern: "evil", segments: ["evil"] }],
62
+ layoutRelPaths: [],
63
+ hasRoot: false,
64
+ }),
65
+ ).toThrow(/unsafe file path/);
66
+ });
67
+
68
+ test("rejects .. segments", () => {
69
+ expect(() =>
70
+ generateRouteRegistry({
71
+ appDir: TMP,
72
+ routes: [{ filePath: "routes/../evil.tsx", urlPattern: "evil", segments: ["evil"] }],
73
+ layoutRelPaths: [],
74
+ hasRoot: false,
75
+ }),
76
+ ).toThrow(/\.\. segment/);
77
+ });
78
+ });
79
+
80
+ describe("generateActionRegistry", () => {
81
+ test("emits static imports and registers each action file", () => {
82
+ const src = generateActionRegistry({
83
+ appDir: TMP,
84
+ actionRelPaths: ["routes/contact.server.ts"],
85
+ });
86
+ expect(src).toContain(`import * as act_routes_contact_server_ts from "../routes/contact.server.ts";`);
87
+ expect(src).toContain(`{ relPath: "routes/contact.server.ts", mod: act_routes_contact_server_ts as Record<string, unknown> }`);
88
+ });
89
+
90
+ test("rejects hostile action paths", () => {
91
+ expect(() =>
92
+ generateActionRegistry({
93
+ appDir: TMP,
94
+ actionRelPaths: ["routes/`evil`.server.ts"],
95
+ }),
96
+ ).toThrow(/unsafe file path/);
97
+ });
98
+ });
99
+
100
+ describe("writeModuleRegistries", () => {
101
+ test("scans the fixture app and writes both registry files", async () => {
102
+ const { routesPath, actionsPath } = await writeModuleRegistries(TMP);
103
+ expect(routesPath).toBe(resolve(join(TMP, "_generated", "routes.ts")));
104
+ expect(actionsPath).toBe(resolve(join(TMP, "_generated", "actions.ts")));
105
+
106
+ const routesSrc = await Bun.file(routesPath).text();
107
+ expect(routesSrc).toContain(`import * as mod_root_tsx from "../root.tsx";`);
108
+ // Layout is discovered because `routes/blog/[slug].tsx` is a deeper route
109
+ expect(routesSrc).toContain(`"routes/blog/layout.tsx": `);
110
+ expect(routesSrc).toContain(`urlPattern: ""`);
111
+
112
+ const actionsSrc = await Bun.file(actionsPath).text();
113
+ expect(actionsSrc).toContain(`relPath: "contact.server.ts"`);
114
+ });
115
+ });
116
+
117
+ describe("generateManifestModule", () => {
118
+ test("emits a ServerManifest constant with file+chunk per route", () => {
119
+ const src = generateManifestModule({
120
+ version: 1,
121
+ mode: "production",
122
+ clientEntry: "/build/client/entry.abc.js",
123
+ rootChunk: "/build/client/root.def.js",
124
+ routes: {
125
+ "": { chunk: "/build/client/_index.ghi.js", pattern: "" },
126
+ "blog": { chunk: "/build/client/blog.jkl.js", pattern: "blog" },
127
+ },
128
+ });
129
+ expect(src).toContain(`export const manifest: ServerManifest =`);
130
+ expect(src).toContain(`"clientEntry": "/build/client/entry.abc.js"`);
131
+ expect(src).toContain(`"rootChunk": "/build/client/root.def.js"`);
132
+ // Each route entry exposes both `file` and `chunk` keys (mirrors the
133
+ // RouteManifest → ServerManifest projection in serve.ts).
134
+ expect(src).toContain(`"file": "/build/client/_index.ghi.js"`);
135
+ expect(src).toContain(`"chunk": "/build/client/_index.ghi.js"`);
136
+ });
137
+
138
+ test("string escaping resists injection through chunk paths", () => {
139
+ const src = generateManifestModule({
140
+ clientEntry: `evil"; throw new Error('pwned'); //`,
141
+ routes: {},
142
+ });
143
+ // JSON.stringify escapes the inner quote — the throw doesn't break out.
144
+ expect(src).toContain(`\\"; throw new Error`);
145
+ expect(src).not.toContain(`evil"; throw`);
146
+ });
147
+ });
148
+
149
+ describe("writeManifestModule", () => {
150
+ test("reads route-manifest.json and writes _generated/manifest.ts", async () => {
151
+ const tmpBuild = resolve(join(TMP, ".tmp-build"));
152
+ await mkdir(tmpBuild, { recursive: true });
153
+ await writeFile(
154
+ join(tmpBuild, "route-manifest.json"),
155
+ JSON.stringify({
156
+ version: 1,
157
+ mode: "production",
158
+ clientEntry: "/build/client/entry.abc.js",
159
+ routes: { "": { chunk: "/build/client/_index.ghi.js", pattern: "" } },
160
+ }),
161
+ );
162
+ const out = await writeManifestModule(TMP, tmpBuild);
163
+ expect(out).toBe(resolve(join(TMP, "_generated", "manifest.ts")));
164
+ const src = await Bun.file(out).text();
165
+ expect(src).toContain(`"clientEntry": "/build/client/entry.abc.js"`);
166
+ });
167
+
168
+ test("throws a clear error when route-manifest.json is missing", async () => {
169
+ let caught: unknown;
170
+ try {
171
+ await writeManifestModule(TMP, "/this/path/does/not/exist-bract-manifest");
172
+ } catch (err) {
173
+ caught = err;
174
+ }
175
+ expect(caught).toBeInstanceOf(Error);
176
+ expect((caught as Error).message).toContain("Run the client build before manifest codegen");
177
+ });
178
+ });
@@ -0,0 +1,94 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { buildFetchHandler } from "../server/serve.ts";
3
+ import type { RouteModule } from "../shared/route-types.ts";
4
+
5
+ // Validates the `bun build --compile` code path: every input that the
6
+ // framework would normally derive from the filesystem (`Bun.Glob` of routes/,
7
+ // `import(absPath)` of route modules, `loadManifest` from disk) is provided
8
+ // upfront. The appDir/publicDir/buildDir paths point at a nonexistent
9
+ // directory to prove no filesystem fall-through happens.
10
+
11
+ const NON_EXISTENT_DIR = "/this/path/does/not/exist/bractjs-prebuilt";
12
+
13
+ const indexRouteModule: RouteModule = {
14
+ loader: async () => ({ ok: true, source: "prebuilt-loader" }),
15
+ meta: () => [{ title: "Prebuilt Title" }],
16
+ default: () => null,
17
+ };
18
+
19
+ const rootModule: RouteModule = {
20
+ default: () => null,
21
+ };
22
+
23
+ const sendActionMod = {
24
+ send: async (payload: unknown) => ({ echoed: payload }),
25
+ };
26
+
27
+ // Hash matches the ID a client proxy would compute for relPath "actions.server.ts" + "send".
28
+ async function clientId(relPath: string, name: string): Promise<string> {
29
+ const raw = new TextEncoder().encode(relPath + "#" + name);
30
+ const buf = await crypto.subtle.digest("SHA-256", raw);
31
+ return Array.from(new Uint8Array(buf))
32
+ .map((b) => b.toString(16).padStart(2, "0"))
33
+ .join("")
34
+ .slice(0, 16);
35
+ }
36
+
37
+ const fetchHandler = buildFetchHandler({
38
+ appDir: NON_EXISTENT_DIR,
39
+ publicDir: NON_EXISTENT_DIR,
40
+ buildDir: NON_EXISTENT_DIR,
41
+ manifest: { clientEntry: "/build/client/client.js", routes: {} },
42
+ routeFiles: [
43
+ { filePath: "routes/_index.tsx", urlPattern: "", segments: [] },
44
+ ],
45
+ moduleRegistry: {
46
+ "root.tsx": rootModule,
47
+ "routes/_index.tsx": indexRouteModule,
48
+ },
49
+ actionModules: [
50
+ { relPath: "actions.server.ts", mod: sendActionMod },
51
+ ],
52
+ });
53
+
54
+ describe("buildFetchHandler — pre-built (compiled-binary) path", () => {
55
+ test("GET / renders HTML with loader data from registry", async () => {
56
+ const res = await fetchHandler(new Request("http://x/"));
57
+ expect(res.status).toBe(200);
58
+ expect(res.headers.get("content-type")).toContain("text/html");
59
+ const html = await res.text();
60
+ expect(html).toContain("prebuilt-loader");
61
+ expect(html).toContain("Prebuilt Title");
62
+ });
63
+
64
+ test("GET /_data?path=/ returns JSON loader output", async () => {
65
+ const res = await fetchHandler(new Request("http://x/_data?path=/"));
66
+ expect(res.status).toBe(200);
67
+ const data = (await res.json()) as Record<string, unknown>;
68
+ expect(data).toHaveProperty("route");
69
+ expect((data.route as Record<string, unknown>).ok).toBe(true);
70
+ });
71
+
72
+ test("GET /missing returns 404 from the prebuilt trie", async () => {
73
+ const res = await fetchHandler(new Request("http://x/missing"));
74
+ expect(res.status).toBe(404);
75
+ });
76
+
77
+ test("POST /_action with registry-derived ID succeeds", async () => {
78
+ const id = await clientId("actions.server.ts", "send");
79
+ const res = await fetchHandler(
80
+ new Request(`http://x/_action?id=${id}`, {
81
+ method: "POST",
82
+ headers: {
83
+ "Content-Type": "application/json",
84
+ "X-BractJS-Action": "1",
85
+ Origin: "http://x",
86
+ },
87
+ body: JSON.stringify([{ hello: "world" }]),
88
+ }),
89
+ );
90
+ expect(res.status).toBe(200);
91
+ const json = (await res.json()) as { echoed: Record<string, unknown> };
92
+ expect(json.echoed).toEqual({ hello: "world" });
93
+ });
94
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Tests for the programmatic API: createDevServer, runBuild, loadUserConfig.
3
+ *
4
+ * Note: tests that start Bun.serve() (like integration.test.ts) are excluded
5
+ * here because the process.on("beforeExit") handler in createServer causes
6
+ * bun:test to exit before printing results — a known pre-existing issue.
7
+ * Behavioral coverage (HTTP response, HMR) lives in integration.test.ts.
8
+ */
9
+ import { test, expect } from "bun:test";
10
+ import { loadUserConfig } from "../config/load.ts";
11
+ import { runBuild } from "../build/bundler.ts";
12
+ import { createDevServer } from "../dev/server.ts";
13
+ import type { BuildConfig } from "../build/bundler.ts";
14
+ import type { DevServerOptions, DevServer } from "../dev/server.ts";
15
+
16
+ // ── loadUserConfig ────────────────────────────────────────────────────────
17
+
18
+ test("loadUserConfig is exported from config/load", () => {
19
+ expect(typeof loadUserConfig).toBe("function");
20
+ });
21
+
22
+ test("loadUserConfig returns an object when no bractjs.config.ts exists", async () => {
23
+ // Repo root has no bractjs.config.ts — must return a plain empty object.
24
+ const cfg = await loadUserConfig();
25
+ expect(typeof cfg).toBe("object");
26
+ expect(cfg).not.toBeNull();
27
+ });
28
+
29
+ // ── runBuild ──────────────────────────────────────────────────────────────
30
+
31
+ test("runBuild is exported from build/bundler", () => {
32
+ expect(typeof runBuild).toBe("function");
33
+ });
34
+
35
+ test("runBuild signature does not require server-only fields (port/manifest/publicDir)", () => {
36
+ // Compile-time guard: if BuildConfig mistakenly included required server fields,
37
+ // this line would fail TypeScript type-checking.
38
+ const config: BuildConfig = { appDir: "./app", minify: false };
39
+ expect(typeof config).toBe("object");
40
+ // Ensure none of the server-only keys are present as required fields.
41
+ expect("port" in config).toBe(false);
42
+ expect("manifest" in config).toBe(false);
43
+ expect("publicDir" in config).toBe(false);
44
+ });
45
+
46
+ test("runBuild rejects with a defined error when appDir does not exist", async () => {
47
+ await expect(
48
+ runBuild({ appDir: "/definitely/does/not/exist/__bractjs_test__" }),
49
+ ).rejects.toBeDefined();
50
+ });
51
+
52
+ // ── createDevServer ───────────────────────────────────────────────────────
53
+
54
+ test("createDevServer is exported from dev/server", () => {
55
+ expect(typeof createDevServer).toBe("function");
56
+ });
57
+
58
+ test("createDevServer accepts DevServerOptions shape without error at compile time", () => {
59
+ // Compile-time guard: verify the options interface has the expected fields.
60
+ const opts: DevServerOptions = {
61
+ port: 3997,
62
+ hmrPort: 3996,
63
+ skipUserConfig: true,
64
+ config: { appDir: "./app" },
65
+ };
66
+ expect(typeof opts).toBe("object");
67
+ expect(opts.port).toBe(3997);
68
+ expect(opts.hmrPort).toBe(3996);
69
+ expect(opts.skipUserConfig).toBe(true);
70
+ });
71
+
72
+ test("DevServer interface has a stop() method", () => {
73
+ // Compile-time guard: verify DevServer shape.
74
+ const stub: DevServer = { stop: () => {} };
75
+ expect(typeof stub.stop).toBe("function");
76
+ });
77
+
78
+ // ── Re-exports from src/index.ts ──────────────────────────────────────────
79
+
80
+ test("createDevServer, runBuild, loadUserConfig are all re-exported from src/index.ts", async () => {
81
+ const mod = await import("../index.ts");
82
+ expect(typeof mod.createDevServer).toBe("function");
83
+ expect(typeof mod.runBuild).toBe("function");
84
+ expect(typeof mod.loadUserConfig).toBe("function");
85
+ });
@@ -1,6 +1,6 @@
1
1
  import { test, expect, describe, beforeAll, afterAll } from "bun:test";
2
2
  import { mkdir, rm, writeFile, symlink } from "node:fs/promises";
3
- import { resolve, join } from "node:path";
3
+ import { resolve, join, relative, isAbsolute } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { createServer } from "../server/serve.ts";
6
6
  import { handleActionRequest } from "../server/action-handler.ts";
@@ -15,8 +15,16 @@ import { handleImageRequest } from "../image/handler.ts";
15
15
  const ACTION_TMP = resolve(import.meta.dir, ".tmp-security-action");
16
16
  let registeredActionId = "";
17
17
 
18
- async function computeId(filePath: string, name: string): Promise<string> {
19
- const raw = new TextEncoder().encode(filePath + "#" + name);
18
+ // Mirrors `pathKeyForAction` action IDs hash the appDir-relative path so
19
+ // they stay consistent between the server registry and the client proxy.
20
+ function pathKey(absPath: string, appDir: string): string {
21
+ const absAppDir = isAbsolute(appDir) ? appDir : resolve(appDir);
22
+ const rel = relative(absAppDir, absPath);
23
+ return rel.startsWith("..") ? absPath : rel;
24
+ }
25
+
26
+ async function computeId(absPath: string, name: string, appDir: string): Promise<string> {
27
+ const raw = new TextEncoder().encode(pathKey(absPath, appDir) + "#" + name);
20
28
  const buf = await crypto.subtle.digest("SHA-256", raw);
21
29
  return Array.from(new Uint8Array(buf))
22
30
  .map((b) => b.toString(16).padStart(2, "0"))
@@ -42,7 +50,7 @@ beforeAll(async () => {
42
50
  const actionFile = join(ACTION_TMP, "routes", "_index.tsx");
43
51
  await writeFile(actionFile, `"use server";\nexport async function ping(...args) { return args; }\n`);
44
52
  await loadServerActions(ACTION_TMP);
45
- registeredActionId = await computeId(actionFile, "ping");
53
+ registeredActionId = await computeId(actionFile, "ping", ACTION_TMP);
46
54
  });
47
55
 
48
56
  afterAll(async () => {
@@ -0,0 +1,74 @@
1
+ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
2
+ import { mkdir, rm, writeFile, symlink } from "node:fs/promises";
3
+ import { resolve, join } from "node:path";
4
+ import { serveStatic } from "../server/static.ts";
5
+
6
+ const TMP = resolve(import.meta.dir, ".tmp-static-embedded");
7
+ const BUILD = join(TMP, "build");
8
+ const PUBLIC = join(TMP, "public");
9
+
10
+ beforeAll(async () => {
11
+ await rm(TMP, { recursive: true, force: true });
12
+ await mkdir(join(BUILD, "client"), { recursive: true });
13
+ await mkdir(PUBLIC, { recursive: true });
14
+
15
+ await writeFile(join(BUILD, "client", "app.js"), "console.log('app');");
16
+ await writeFile(join(PUBLIC, "logo.svg"), "<svg/>");
17
+
18
+ // Outside-root file for symlink-escape test
19
+ await writeFile(join(TMP, "secret.txt"), "shhh");
20
+ // Symlink from inside build/client/ to a file OUTSIDE the root
21
+ await symlink(join(TMP, "secret.txt"), join(BUILD, "client", "escape.js"));
22
+ });
23
+
24
+ afterAll(async () => {
25
+ await rm(TMP, { recursive: true, force: true });
26
+ });
27
+
28
+ describe("serveStatic — normal filesystem path", () => {
29
+ test("serves a client asset", async () => {
30
+ const res = await serveStatic("/build/client/app.js", BUILD, PUBLIC);
31
+ expect(res).not.toBeNull();
32
+ expect(res!.status).toBe(200);
33
+ expect(res!.headers.get("Cache-Control")).toContain("immutable");
34
+ expect(await res!.text()).toContain("console.log");
35
+ });
36
+
37
+ test("serves a public asset", async () => {
38
+ const res = await serveStatic("/public/logo.svg", BUILD, PUBLIC);
39
+ expect(res).not.toBeNull();
40
+ expect(res!.status).toBe(200);
41
+ expect(res!.headers.get("Cache-Control")).toContain("no-cache");
42
+ });
43
+
44
+ test("returns null for unmatched prefix", async () => {
45
+ expect(await serveStatic("/api/foo", BUILD, PUBLIC)).toBeNull();
46
+ });
47
+
48
+ test("blocks .. traversal segments", async () => {
49
+ expect(
50
+ await serveStatic("/build/client/../secret.txt", BUILD, PUBLIC),
51
+ ).toBeNull();
52
+ });
53
+
54
+ test("blocks symlink that escapes the root after realpath", async () => {
55
+ // realpath resolves escape.js → ../secret.txt → outside BUILD/client.
56
+ // Must return null, NOT serve the secret file.
57
+ const res = await serveStatic("/build/client/escape.js", BUILD, PUBLIC);
58
+ expect(res).toBeNull();
59
+ });
60
+ });
61
+
62
+ describe("serveStatic — embedded-binary fallback", () => {
63
+ test("structural traversal guard still blocks .. even when realpath throws", async () => {
64
+ // `/build/client/__definitely_missing__.js` doesn't exist → realpath
65
+ // throws → fallback runs → Bun.file().exists() returns false → null.
66
+ // Guarantees the fallback doesn't accidentally serve nonexistent paths.
67
+ const res = await serveStatic(
68
+ "/build/client/__definitely_missing__.js",
69
+ BUILD,
70
+ PUBLIC,
71
+ );
72
+ expect(res).toBeNull();
73
+ });
74
+ });
@@ -1,16 +1,26 @@
1
1
  import { join, basename, extname, resolve } from "node:path";
2
2
  import { rename, rm } from "node:fs/promises";
3
- import type { BractJSConfig } from "../server/serve.ts";
3
+ import type { BunPlugin } from "bun";
4
4
  import { scanRoutes } from "../server/scanner.ts";
5
5
  import { contentHash } from "./hash.ts";
6
6
  import { generateManifest, writeManifest } from "./manifest.ts";
7
7
  import { serverOnlyPlugin, clientEnvPlugin } from "./env-plugin.ts";
8
8
  import { buildDefines } from "./defines.ts";
9
9
  import { writeRouteTypes } from "../codegen/route-codegen.ts";
10
- import { useClientStubPlugin, useServerProxyPlugin } from "./directives.ts";
10
+ import { useClientStubPlugin, createUseServerProxyPlugin } from "./directives.ts";
11
11
  import { cssModulesPlugin } from "./plugins/css-modules.ts";
12
12
 
13
- export async function runBuild(config: BractJSConfig): Promise<void> {
13
+ /** Subset of config fields relevant to the build pipeline. */
14
+ export interface BuildConfig {
15
+ appDir?: string;
16
+ buildDir?: string;
17
+ sourcemap?: "none" | "linked" | "inline" | "external";
18
+ minify?: boolean;
19
+ clientEnv?: string[];
20
+ plugins?: BunPlugin[];
21
+ }
22
+
23
+ export async function runBuild(config: BuildConfig): Promise<void> {
14
24
  const appDir = config.appDir ?? "./app";
15
25
 
16
26
  // ── 0. Codegen — typed routes ───────────────────────────────────────────
@@ -54,7 +64,7 @@ export async function runBuild(config: BractJSConfig): Promise<void> {
54
64
  minify: config.minify ?? true,
55
65
  sourcemap: config.sourcemap ?? "external",
56
66
  define: buildDefines(config),
57
- plugins: [serverOnlyPlugin, useServerProxyPlugin, clientEnvPlugin(config.clientEnv ?? [], Bun.env as Record<string, string>), cssModulesPlugin, ...(config.plugins ?? [])],
67
+ plugins: [serverOnlyPlugin, createUseServerProxyPlugin(appDir), clientEnvPlugin(config.clientEnv ?? [], Bun.env as Record<string, string>), cssModulesPlugin, ...(config.plugins ?? [])],
58
68
  });
59
69
  if (!clientResult.success) throw new AggregateError(clientResult.logs, "Client build failed");
60
70
 
@@ -1,4 +1,5 @@
1
1
  import type { BunPlugin } from "bun";
2
+ import { relative, resolve, isAbsolute } from "node:path";
2
3
 
3
4
  const CLIENT_RE = /^["']use client["']/m;
4
5
  const SERVER_RE = /^["']use server["']/m;
@@ -37,8 +38,14 @@ function extractExports(src: string): string[] {
37
38
  return names;
38
39
  }
39
40
 
40
- async function actionId(filePath: string, name: string): Promise<string> {
41
- const raw = new TextEncoder().encode(filePath + "#" + name);
41
+ /**
42
+ * Compute stable action ID from a path key (relative when appDir provided)
43
+ * and an exported function name. Server-side counterpart is `computeId` in
44
+ * `src/server/action-registry.ts` — both MUST hash identical input strings
45
+ * or the client proxy hits a 404 at `/_action?id=...`.
46
+ */
47
+ async function actionId(pathKey: string, name: string): Promise<string> {
48
+ const raw = new TextEncoder().encode(pathKey + "#" + name);
42
49
  const buf = await crypto.subtle.digest("SHA-256", raw);
43
50
  return Array.from(new Uint8Array(buf))
44
51
  .map((b) => b.toString(16).padStart(2, "0"))
@@ -46,6 +53,22 @@ async function actionId(filePath: string, name: string): Promise<string> {
46
53
  .slice(0, 16);
47
54
  }
48
55
 
56
+ /**
57
+ * Convert the absolute path Bun's onLoad passes us into the path key we hash
58
+ * for action IDs. When `appDir` is provided, returns appDir-relative path so
59
+ * IDs survive CI→prod machine moves and compiled-binary embedding. Without
60
+ * `appDir`, falls back to the absolute path (legacy behavior).
61
+ */
62
+ function pathKeyForAction(absPath: string, appDir?: string): string {
63
+ if (!appDir) return absPath;
64
+ const absAppDir = isAbsolute(appDir) ? appDir : resolve(appDir);
65
+ const rel = relative(absAppDir, absPath);
66
+ // If the file lives outside appDir (escape), `relative` returns a path
67
+ // starting with "..". Fall back to the absolute path so external imports
68
+ // remain hashable but stay distinct from in-tree files.
69
+ return rel.startsWith("..") ? absPath : rel;
70
+ }
71
+
49
72
  /** Server build: stub "use client" modules → null components to prevent browser API crashes. */
50
73
  export const useClientStubPlugin: BunPlugin = {
51
74
  name: "bractjs:use-client-stub",
@@ -73,22 +96,38 @@ const PROXY_HELPER = `async function __bract(id: string, args: unknown[]): Promi
73
96
  return r.json() as Promise<unknown>;
74
97
  }`;
75
98
 
76
- /** Client build: replace "use server" exports with fetch proxy stubs. */
77
- export const useServerProxyPlugin: BunPlugin = {
78
- name: "bractjs:use-server-proxy",
79
- setup(build) {
80
- build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async ({ path }) => {
81
- const src = await Bun.file(path).text();
82
- if (!hasServerDirective(src)) return undefined;
83
- const names = extractExports(src);
84
- if (names.length === 0) return { contents: "export {};", loader: "ts" };
85
- const proxies = await Promise.all(
86
- names.map(async (name) => {
87
- const id = await actionId(path, name);
88
- return `export const ${name} = (...args: unknown[]) => __bract("${id}", args);`;
89
- }),
90
- );
91
- return { contents: PROXY_HELPER + "\n" + proxies.join("\n"), loader: "ts" };
92
- });
93
- },
94
- };
99
+ /**
100
+ * Client build: replace "use server" exports with fetch proxy stubs.
101
+ *
102
+ * Factory form so the plugin can compute appDir-relative action IDs that
103
+ * match the server registry across machines and inside compiled binaries.
104
+ * Pass the same `appDir` used by `loadServerActions` on the server.
105
+ */
106
+ export function createUseServerProxyPlugin(appDir?: string): BunPlugin {
107
+ return {
108
+ name: "bractjs:use-server-proxy",
109
+ setup(build) {
110
+ build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async ({ path }) => {
111
+ const src = await Bun.file(path).text();
112
+ if (!hasServerDirective(src)) return undefined;
113
+ const names = extractExports(src);
114
+ if (names.length === 0) return { contents: "export {};", loader: "ts" };
115
+ const key = pathKeyForAction(path, appDir);
116
+ const proxies = await Promise.all(
117
+ names.map(async (name) => {
118
+ const id = await actionId(key, name);
119
+ return `export const ${name} = (...args: unknown[]) => __bract("${id}", args);`;
120
+ }),
121
+ );
122
+ return { contents: PROXY_HELPER + "\n" + proxies.join("\n"), loader: "ts" };
123
+ });
124
+ },
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Backwards-compatible default — hashes by absolute path. New code should
130
+ * call `createUseServerProxyPlugin(appDir)` so IDs are stable across the
131
+ * client bundle and server registry regardless of where the build runs.
132
+ */
133
+ export const useServerProxyPlugin: BunPlugin = createUseServerProxyPlugin();
@@ -157,7 +157,10 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
157
157
  if (w.__BRACT_DEV__ === true) {
158
158
  // Use the dev-only HTTP endpoint (registered in serve.ts) rather than
159
159
  // a relative source-path import — Bun preserves dynamic-import paths
160
- // as runtime URLs, and a relative .ts path 404s in the browser.
160
+ // as runtime URLs, and a relative .ts path 404s in the browser. TS
161
+ // can't resolve the absolute URL spec, but `.catch()` below swallows
162
+ // the import failure in prod where the endpoint isn't registered.
163
+ // @ts-expect-error TS2307 — runtime URL served by serve.ts in dev only
161
164
  void import(/* @vite-ignore */ "/_bractjs/devtools.js").then(({ updateDevtoolsState }) => {
162
165
  updateDevtoolsState({
163
166
  route: toPathname,