@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.
- package/README.md +137 -6
- package/bin/cli.ts +93 -5
- package/package.json +1 -1
- package/src/__tests__/action-registry.test.ts +54 -14
- package/src/__tests__/layout-registry.test.ts +95 -0
- package/src/__tests__/module-registry.test.ts +178 -0
- package/src/__tests__/prebuilt-handler.test.ts +94 -0
- package/src/__tests__/security.test.ts +12 -4
- package/src/__tests__/static-embedded.test.ts +74 -0
- package/src/build/bundler.ts +2 -2
- package/src/build/directives.ts +60 -21
- package/src/build/env-plugin.ts +20 -5
- package/src/client/ClientRouter.tsx +4 -1
- package/src/codegen/module-registry.ts +316 -0
- package/src/dev/hmr-module-handler.ts +2 -2
- package/src/dev/rebuilder.ts +2 -2
- package/src/index.ts +30 -0
- package/src/server/action-registry.ts +41 -4
- package/src/server/layout.ts +74 -1
- package/src/server/lifecycle.ts +14 -0
- package/src/server/loader.ts +10 -5
- package/src/server/request-handler.ts +17 -6
- package/src/server/serve.ts +66 -17
- package/src/server/static.ts +27 -7
- package/templates/new-app/app/server.ts +30 -0
- package/templates/new-app/package.json +2 -1
- package/types/config.d.ts +21 -1
- package/types/index.d.ts +33 -0
- package/types/route.d.ts +8 -0
- package/src/__tests__/.tmp-security-action/routes/_index.tsx +0 -2
|
@@ -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: ModuleRegistry`);
|
|
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
|
+
});
|
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
+
});
|
package/src/build/bundler.ts
CHANGED
|
@@ -7,7 +7,7 @@ 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,
|
|
10
|
+
import { useClientStubPlugin, createUseServerProxyPlugin } from "./directives.ts";
|
|
11
11
|
import { cssModulesPlugin } from "./plugins/css-modules.ts";
|
|
12
12
|
|
|
13
13
|
/** Subset of config fields relevant to the build pipeline. */
|
|
@@ -64,7 +64,7 @@ export async function runBuild(config: BuildConfig): Promise<void> {
|
|
|
64
64
|
minify: config.minify ?? true,
|
|
65
65
|
sourcemap: config.sourcemap ?? "external",
|
|
66
66
|
define: buildDefines(config),
|
|
67
|
-
plugins: [serverOnlyPlugin,
|
|
67
|
+
plugins: [serverOnlyPlugin, createUseServerProxyPlugin(appDir), clientEnvPlugin(config.clientEnv ?? [], Bun.env as Record<string, string>), cssModulesPlugin, ...(config.plugins ?? [])],
|
|
68
68
|
});
|
|
69
69
|
if (!clientResult.success) throw new AggregateError(clientResult.logs, "Client build failed");
|
|
70
70
|
|
package/src/build/directives.ts
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
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
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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();
|
package/src/build/env-plugin.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import type { BunPlugin } from "bun";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Resolved at module load so the lookup is cheap (every onLoad call uses it).
|
|
5
|
+
// Equivalent to the absolute path of the directory holding this file
|
|
6
|
+
// (`<bractjs>/src/build/`). We strip `/src/build` to get the framework root.
|
|
7
|
+
const FRAMEWORK_SRC_ROOT = resolve(import.meta.dir, "..");
|
|
2
8
|
|
|
3
9
|
// ── Server-only import guard ───────────────────────────────────────────────
|
|
4
10
|
|
|
@@ -41,15 +47,24 @@ export function clientEnvPlugin(
|
|
|
41
47
|
name: "bractjs-client-env",
|
|
42
48
|
setup(build) {
|
|
43
49
|
build.onLoad({ filter: /\.(ts|tsx|js|jsx)$/ }, async (args) => {
|
|
50
|
+
// Skip third-party packages and the framework's own source. The
|
|
51
|
+
// framework source contains literal strings like
|
|
52
|
+
// `"process.env.NODE_ENV"` (as keys of Bun.build's `define:` maps)
|
|
53
|
+
// that would otherwise be rewritten to `""undefined""`, breaking
|
|
54
|
+
// syntax. The framework also doesn't need allowlist enforcement —
|
|
55
|
+
// it accesses Bun.env directly on the server, not process.env on
|
|
56
|
+
// the client. Without this guard, linking the framework via `file:`
|
|
57
|
+
// produces a build that fails to parse its own source.
|
|
44
58
|
if (args.path.includes("/node_modules/")) return undefined;
|
|
59
|
+
if (args.path.startsWith(FRAMEWORK_SRC_ROOT)) return undefined;
|
|
45
60
|
const src = await Bun.file(args.path).text();
|
|
46
61
|
// SECURITY(medium): textual regex replace runs over the whole source,
|
|
47
62
|
// including inside string literals and comments. A bare `process.env.X`
|
|
48
|
-
// anywhere — even in a documentation string — becomes
|
|
49
|
-
// (or "undefined"). This is acceptable for client
|
|
50
|
-
// unwanted occurrences only yield the string
|
|
51
|
-
// server secret. The allowedKeys gate is the
|
|
52
|
-
// never widen it without auditing callers.
|
|
63
|
+
// anywhere in user code — even in a documentation string — becomes
|
|
64
|
+
// the literal value (or "undefined"). This is acceptable for client
|
|
65
|
+
// builds because unwanted occurrences only yield the string
|
|
66
|
+
// "undefined", never a server secret. The allowedKeys gate is the
|
|
67
|
+
// authoritative leak check; never widen it without auditing callers.
|
|
53
68
|
const contents = src.replace(
|
|
54
69
|
/process\.env\.([A-Z_][A-Z0-9_]*)/g,
|
|
55
70
|
(_match, key: string) =>
|
|
@@ -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,
|