@bractjs/bractjs 0.1.0 → 0.1.6
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 +13 -13
- package/package.json +4 -1
- package/src/__tests__/action-handler.test.ts +47 -0
- package/src/__tests__/action-registry.test.ts +73 -0
- package/src/__tests__/codegen.test.ts +50 -0
- package/src/__tests__/deferred.test.ts +96 -0
- package/src/__tests__/directives.test.ts +52 -0
- package/src/__tests__/env.test.ts +73 -0
- package/src/__tests__/errors.test.ts +113 -0
- package/src/__tests__/hash.test.ts +19 -0
- package/src/__tests__/integration.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +60 -0
- package/src/__tests__/middleware.test.ts +216 -0
- package/src/__tests__/response.test.ts +106 -0
- package/src/__tests__/security.test.ts +348 -0
- package/src/__tests__/session.test.ts +3 -3
- package/src/build/bundler.ts +15 -5
- package/src/build/directives.ts +30 -3
- package/src/build/env-plugin.ts +1 -0
- package/src/build/hash.ts +0 -20
- package/src/client/ClientRouter.tsx +8 -4
- package/src/codegen/route-codegen.ts +33 -9
- package/src/dev/hmr-module-handler.ts +14 -4
- package/src/image/cache.ts +28 -8
- package/src/image/handler.ts +26 -11
- package/src/image/optimizer.ts +45 -13
- package/src/image/types.ts +1 -0
- package/src/middleware/cors.ts +24 -8
- package/src/server/action-handler.ts +40 -1
- package/src/server/action-registry.ts +14 -1
- package/src/server/csrf.ts +16 -0
- package/src/server/env.ts +10 -4
- package/src/server/middleware.ts +11 -7
- package/src/server/render.ts +7 -5
- package/src/server/request-handler.ts +14 -13
- package/src/server/response.ts +29 -5
- package/src/server/scanner.ts +6 -2
- package/src/server/session.ts +16 -5
- package/src/server/static.ts +23 -7
- package/templates/new-app/app/root.tsx +1 -1
- package/templates/new-app/app/routes/_index.tsx +3 -3
- package/templates/new-app/app/routes/about.tsx +1 -1
- package/templates/new-app/bractjs.config.ts +1 -1
- package/templates/new-app/package.json +1 -1
|
@@ -0,0 +1,348 @@
|
|
|
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 { tmpdir } from "node:os";
|
|
5
|
+
import { createServer } from "../server/serve.ts";
|
|
6
|
+
import { handleActionRequest } from "../server/action-handler.ts";
|
|
7
|
+
import { loadServerActions } from "../server/action-registry.ts";
|
|
8
|
+
import { safeStringify } from "../server/env.ts";
|
|
9
|
+
import { cors } from "../middleware/cors.ts";
|
|
10
|
+
import { createCookieSession } from "../server/session.ts";
|
|
11
|
+
import { MiddlewarePipeline, type MiddlewareContext } from "../server/middleware.ts";
|
|
12
|
+
import { serveStatic } from "../server/static.ts";
|
|
13
|
+
import { handleImageRequest } from "../image/handler.ts";
|
|
14
|
+
|
|
15
|
+
const ACTION_TMP = resolve(import.meta.dir, ".tmp-security-action");
|
|
16
|
+
let registeredActionId = "";
|
|
17
|
+
|
|
18
|
+
async function computeId(filePath: string, name: string): Promise<string> {
|
|
19
|
+
const raw = new TextEncoder().encode(filePath + "#" + name);
|
|
20
|
+
const buf = await crypto.subtle.digest("SHA-256", raw);
|
|
21
|
+
return Array.from(new Uint8Array(buf))
|
|
22
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
23
|
+
.join("")
|
|
24
|
+
.slice(0, 16);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PORT = 3998;
|
|
28
|
+
const BASE = `http://localhost:${PORT}`;
|
|
29
|
+
const FIXTURE_APP = resolve(import.meta.dir, "fixtures/app");
|
|
30
|
+
|
|
31
|
+
let handle: ReturnType<typeof createServer>;
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
handle = createServer({
|
|
35
|
+
port: PORT,
|
|
36
|
+
appDir: FIXTURE_APP,
|
|
37
|
+
manifest: { clientEntry: "/build/client/client.js", routes: {} },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await rm(ACTION_TMP, { recursive: true, force: true });
|
|
41
|
+
await mkdir(join(ACTION_TMP, "routes"), { recursive: true });
|
|
42
|
+
const actionFile = join(ACTION_TMP, "routes", "_index.tsx");
|
|
43
|
+
await writeFile(actionFile, `"use server";\nexport async function ping(...args) { return args; }\n`);
|
|
44
|
+
await loadServerActions(ACTION_TMP);
|
|
45
|
+
registeredActionId = await computeId(actionFile, "ping");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
handle.stop();
|
|
50
|
+
await rm(ACTION_TMP, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ── Item 1 — path traversal ───────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe("path traversal", () => {
|
|
56
|
+
test("GET /public/../package.json returns 404, not file contents", async () => {
|
|
57
|
+
const res = await fetch(`${BASE}/public/../package.json`);
|
|
58
|
+
expect(res.status).toBe(404);
|
|
59
|
+
const body = await res.text();
|
|
60
|
+
expect(body).not.toContain("@bractjs/bractjs");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── Item 2 — action arg validation ────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
describe("action-handler — arg validation", () => {
|
|
67
|
+
test("non-array JSON body → 400", async () => {
|
|
68
|
+
const req = new Request(`http://x/_action?id=${registeredActionId}`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "Content-Type": "application/json", "X-BractJS-Action": "1" },
|
|
71
|
+
body: JSON.stringify({ foo: "bar" }),
|
|
72
|
+
});
|
|
73
|
+
const res = await handleActionRequest(req);
|
|
74
|
+
expect(res?.status).toBe(400);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("array with __proto__ key → 400", async () => {
|
|
78
|
+
const req = new Request(`http://x/_action?id=${registeredActionId}`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "Content-Type": "application/json", "X-BractJS-Action": "1" },
|
|
81
|
+
// Use JSON.parse to actually inject __proto__ as an own key
|
|
82
|
+
body: '[{"__proto__":{"polluted":true}}]',
|
|
83
|
+
});
|
|
84
|
+
const res = await handleActionRequest(req);
|
|
85
|
+
expect(res?.status).toBe(400);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("array with constructor key → 400", async () => {
|
|
89
|
+
const req = new Request(`http://x/_action?id=${registeredActionId}`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { "Content-Type": "application/json", "X-BractJS-Action": "1" },
|
|
92
|
+
body: '[{"constructor":1}]',
|
|
93
|
+
});
|
|
94
|
+
const res = await handleActionRequest(req);
|
|
95
|
+
expect(res?.status).toBe(400);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("JSON body > 1 MiB rejected with 413 (advertised via Content-Length)", async () => {
|
|
99
|
+
const huge = "a".repeat(2 * 1024 * 1024);
|
|
100
|
+
const req = new Request(`http://x/_action?id=${registeredActionId}`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: {
|
|
103
|
+
"Content-Type": "application/json",
|
|
104
|
+
"X-BractJS-Action": "1",
|
|
105
|
+
"Content-Length": String(2 * 1024 * 1024 + 2),
|
|
106
|
+
},
|
|
107
|
+
body: `["${huge}"]`,
|
|
108
|
+
});
|
|
109
|
+
const res = await handleActionRequest(req);
|
|
110
|
+
expect(res?.status).toBe(413);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("JSON body > 1 MiB rejected with 413 even when Content-Length lies", async () => {
|
|
114
|
+
const huge = "a".repeat(2 * 1024 * 1024);
|
|
115
|
+
const req = new Request(`http://x/_action?id=${registeredActionId}`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: {
|
|
118
|
+
"Content-Type": "application/json",
|
|
119
|
+
"X-BractJS-Action": "1",
|
|
120
|
+
"Content-Length": "10",
|
|
121
|
+
},
|
|
122
|
+
body: `["${huge}"]`,
|
|
123
|
+
});
|
|
124
|
+
const res = await handleActionRequest(req);
|
|
125
|
+
expect(res?.status).toBe(413);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ── Item 3 — CSRF ─────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
describe("CSRF — cross-origin mutation", () => {
|
|
132
|
+
test("/_action without X-BractJS-Action and with cross-origin Origin → 403", async () => {
|
|
133
|
+
const req = new Request("http://localhost/_action?id=abc", {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: { Origin: "https://evil.example" },
|
|
136
|
+
body: "[]",
|
|
137
|
+
});
|
|
138
|
+
const res = await handleActionRequest(req);
|
|
139
|
+
expect(res?.status).toBe(403);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("/_action with same-origin Origin → not 403 (404 unknown id)", async () => {
|
|
143
|
+
const req = new Request("http://localhost/_action?id=abc", {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { Origin: "http://localhost", "Content-Type": "application/json" },
|
|
146
|
+
body: "[]",
|
|
147
|
+
});
|
|
148
|
+
const res = await handleActionRequest(req);
|
|
149
|
+
expect(res?.status).not.toBe(403);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("route POST with mismatched Origin → 403", async () => {
|
|
153
|
+
const res = await fetch(`${BASE}/`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
body: new FormData(),
|
|
156
|
+
headers: { Origin: "https://evil.example" },
|
|
157
|
+
});
|
|
158
|
+
expect(res.status).toBe(403);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── Item 5 — safeStringify ───────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
describe("safeStringify", () => {
|
|
165
|
+
test("escapes U+2028 / U+2029", () => {
|
|
166
|
+
const ls = String.fromCharCode(0x2028);
|
|
167
|
+
const ps = String.fromCharCode(0x2029);
|
|
168
|
+
const out = safeStringify({ a: `x${ls}y${ps}z` });
|
|
169
|
+
expect(out).not.toContain(ls);
|
|
170
|
+
expect(out).not.toContain(ps);
|
|
171
|
+
expect(out).toContain("\\u2028");
|
|
172
|
+
expect(out).toContain("\\u2029");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("escapes < > &", () => {
|
|
176
|
+
const out = safeStringify({ x: "<script>&" });
|
|
177
|
+
expect(out).toContain("\\u003c");
|
|
178
|
+
expect(out).toContain("\\u003e");
|
|
179
|
+
expect(out).toContain("\\u0026");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── Item 6 — session ─────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
describe("session — secret validation", () => {
|
|
186
|
+
test("empty secrets throws", () => {
|
|
187
|
+
expect(() => createCookieSession({ name: "s", secrets: [] })).toThrow();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("short secret throws", () => {
|
|
191
|
+
expect(() => createCookieSession({ name: "s", secrets: ["short"] })).toThrow();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("valid secret roundtrips", async () => {
|
|
195
|
+
const s = createCookieSession({ name: "s", secrets: ["a-secret-that-is-long-enough-1"] });
|
|
196
|
+
const sess = await s.getSession(null);
|
|
197
|
+
sess.set("k", "v");
|
|
198
|
+
const cookie = await s.commitSession(sess);
|
|
199
|
+
const rt = await s.getSession(cookie.split(";")[0]);
|
|
200
|
+
expect(rt.get("k")).toBe("v");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("tampered signature rejected", async () => {
|
|
204
|
+
const s = createCookieSession({ name: "s", secrets: ["a-secret-that-is-long-enough-1"] });
|
|
205
|
+
const sess = await s.getSession(null);
|
|
206
|
+
sess.set("k", "v");
|
|
207
|
+
const cookie = await s.commitSession(sess);
|
|
208
|
+
const tampered = cookie.replace(/=([^;]+)/, (_, val) => `=${val.slice(0, -1)}X`);
|
|
209
|
+
const rt = await s.getSession(tampered.split(";")[0]);
|
|
210
|
+
expect(rt.has("k")).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── Item 7 — CORS ────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
describe("cors middleware", () => {
|
|
217
|
+
async function runOnce(mw: ReturnType<typeof cors>, req: Request): Promise<Response> {
|
|
218
|
+
const ctx: MiddlewareContext = { request: req, params: {}, context: {} };
|
|
219
|
+
const pipeline = new MiddlewarePipeline();
|
|
220
|
+
pipeline.use(mw);
|
|
221
|
+
return pipeline.run(ctx, () => Promise.resolve(new Response("ok")));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
test("wildcard never reflects Origin", async () => {
|
|
225
|
+
const mw = cors({ origin: "*" });
|
|
226
|
+
const res = await runOnce(mw, new Request("http://x/", { headers: { Origin: "https://evil.example" } }));
|
|
227
|
+
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("always emits Vary: Origin", async () => {
|
|
231
|
+
const mw = cors({ origin: "https://ok.example" });
|
|
232
|
+
const res = await runOnce(mw, new Request("http://x/", { headers: { Origin: "https://ok.example" } }));
|
|
233
|
+
expect(res.headers.get("Vary")).toContain("Origin");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("credentials + wildcard throws at setup", () => {
|
|
237
|
+
expect(() => cors({ origin: "*", credentials: true })).toThrow();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ── Item 8 — image dim validation ────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
describe("image handler — dim allowlist", () => {
|
|
244
|
+
test("w=999 (not in allowlist) → 400", async () => {
|
|
245
|
+
const res = await fetch(`${BASE}/_image?src=/public/a.jpg&w=999`);
|
|
246
|
+
expect(res.status).toBe(400);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("w=320 (in allowlist) → not 400 (404 because file missing)", async () => {
|
|
250
|
+
const res = await fetch(`${BASE}/_image?src=/public/missing.jpg&w=320`);
|
|
251
|
+
expect(res.status).toBe(404);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("w=3840&h=3840 (area too large) → 400", async () => {
|
|
255
|
+
const res = await fetch(`${BASE}/_image?src=/public/a.jpg&w=3840&h=3840`);
|
|
256
|
+
expect(res.status).toBe(400);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ── Item 11 — HttpError → response ───────────────────────────────────────
|
|
261
|
+
// (Implicitly covered: integration.test.ts hits a route; this test exercises
|
|
262
|
+
// the conversion via a direct loader throw is awkward without fixtures. Skip
|
|
263
|
+
// here — the request-handler change is type-checked + reachable via redirect.)
|
|
264
|
+
|
|
265
|
+
// ── Item 12 — Content-Type branching for action ──────────────────────────
|
|
266
|
+
|
|
267
|
+
describe("action Content-Type branching", () => {
|
|
268
|
+
test("JSON content-type does not require multipart formData", async () => {
|
|
269
|
+
const res = await fetch(`${BASE}/`, {
|
|
270
|
+
method: "POST",
|
|
271
|
+
headers: { "Content-Type": "application/json", Origin: BASE },
|
|
272
|
+
body: JSON.stringify({ name: "bract" }),
|
|
273
|
+
});
|
|
274
|
+
// The fixture's action accepts FormData; with JSON CT, request-handler
|
|
275
|
+
// passes an empty FormData and the action returns. Should not 500 from
|
|
276
|
+
// formData() throwing.
|
|
277
|
+
expect([200, 302, 400, 404]).toContain(res.status);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ── Item 14 — meta is array in __BRACTJS_DATA__ ─────────────────────────
|
|
282
|
+
|
|
283
|
+
describe("render meta shape", () => {
|
|
284
|
+
test("__BRACTJS_DATA__.meta is an array", async () => {
|
|
285
|
+
const res = await fetch(`${BASE}/`);
|
|
286
|
+
const html = await res.text();
|
|
287
|
+
const match = html.match(/window\.__BRACTJS_DATA__=({[\s\S]*?});/);
|
|
288
|
+
expect(match).not.toBeNull();
|
|
289
|
+
const data = JSON.parse(match![1]) as { meta: unknown };
|
|
290
|
+
expect(Array.isArray(data.meta)).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ── Item 20 — middleware double-next ─────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
describe("middleware — double next()", () => {
|
|
297
|
+
test("calling next() twice rejects", async () => {
|
|
298
|
+
const pipeline = new MiddlewarePipeline();
|
|
299
|
+
pipeline.use(async (_ctx, next) => {
|
|
300
|
+
await next();
|
|
301
|
+
return next(); // illegal
|
|
302
|
+
});
|
|
303
|
+
const ctx: MiddlewareContext = { request: new Request("http://x/"), params: {}, context: {} };
|
|
304
|
+
await expect(pipeline.run(ctx, () => Promise.resolve(new Response("ok")))).rejects.toThrow(/more than once/);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ── Symlink escape (static + image) ─────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
describe("symlink escape — static", () => {
|
|
311
|
+
let pub: string;
|
|
312
|
+
let outside: string;
|
|
313
|
+
let buildDir: string;
|
|
314
|
+
|
|
315
|
+
beforeAll(async () => {
|
|
316
|
+
const root = await Bun.file(tmpdir()).exists() ? tmpdir() : ".";
|
|
317
|
+
pub = join(root, `bract-sym-pub-${Date.now()}`);
|
|
318
|
+
outside = join(root, `bract-sym-out-${Date.now()}`);
|
|
319
|
+
buildDir = join(root, `bract-sym-build-${Date.now()}`);
|
|
320
|
+
await mkdir(pub, { recursive: true });
|
|
321
|
+
await mkdir(outside, { recursive: true });
|
|
322
|
+
await mkdir(join(buildDir, "client"), { recursive: true });
|
|
323
|
+
await writeFile(join(outside, "secret.txt"), "PWNED");
|
|
324
|
+
// Symlink inside /public/ that points outside the root.
|
|
325
|
+
await symlink(join(outside, "secret.txt"), join(pub, "escape.txt"));
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
afterAll(async () => {
|
|
329
|
+
await rm(pub, { recursive: true, force: true });
|
|
330
|
+
await rm(outside, { recursive: true, force: true });
|
|
331
|
+
await rm(buildDir, { recursive: true, force: true });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("static refuses to serve a symlink whose target is outside publicDir", async () => {
|
|
335
|
+
const res = await serveStatic("/public/escape.txt", buildDir, pub);
|
|
336
|
+
expect(res).toBeNull();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("image /_image refuses src that symlinks outside publicDir", async () => {
|
|
340
|
+
const cacheDir = join(tmpdir(), `bract-sym-cache-${Date.now()}`);
|
|
341
|
+
await mkdir(cacheDir, { recursive: true });
|
|
342
|
+
const req = new Request(`http://x/_image?src=/public/escape.txt&w=320`);
|
|
343
|
+
const res = await handleImageRequest(req, pub, cacheDir);
|
|
344
|
+
expect(res?.status === 400 || res?.status === 404).toBe(true);
|
|
345
|
+
await rm(cacheDir, { recursive: true, force: true });
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
@@ -3,7 +3,7 @@ import { createCookieSession } from "../server/session.ts";
|
|
|
3
3
|
|
|
4
4
|
const sessionStorage = createCookieSession({
|
|
5
5
|
name: "__test",
|
|
6
|
-
secrets: ["secret-one", "secret-two"],
|
|
6
|
+
secrets: ["secret-one-1234567890", "secret-two-1234567890"],
|
|
7
7
|
secure: false,
|
|
8
8
|
sameSite: "Lax",
|
|
9
9
|
});
|
|
@@ -71,7 +71,7 @@ describe("createCookieSession — commitSession + roundtrip", () => {
|
|
|
71
71
|
test("secret rotation: old secret still verifies", async () => {
|
|
72
72
|
const oldStorage = createCookieSession({
|
|
73
73
|
name: "__test",
|
|
74
|
-
secrets: ["secret-two"], // only the old secret
|
|
74
|
+
secrets: ["secret-two-1234567890"], // only the old secret
|
|
75
75
|
secure: false,
|
|
76
76
|
});
|
|
77
77
|
const s1 = await oldStorage.getSession(null);
|
|
@@ -81,7 +81,7 @@ describe("createCookieSession — commitSession + roundtrip", () => {
|
|
|
81
81
|
// New storage has new secret first, old secret second (rotation)
|
|
82
82
|
const newStorage = createCookieSession({
|
|
83
83
|
name: "__test",
|
|
84
|
-
secrets: ["secret-one", "secret-two"],
|
|
84
|
+
secrets: ["secret-one-1234567890", "secret-two-1234567890"],
|
|
85
85
|
secure: false,
|
|
86
86
|
});
|
|
87
87
|
const cookieValue = cookie.split(";")[0];
|
package/src/build/bundler.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
1
|
+
import { join, basename, extname, resolve } from "node:path";
|
|
2
2
|
import { rename } from "node:fs/promises";
|
|
3
3
|
import type { BractJSConfig } from "../server/serve.ts";
|
|
4
4
|
import { scanRoutes } from "../server/scanner.ts";
|
|
@@ -47,6 +47,10 @@ export async function runBuild(config: BractJSConfig): Promise<void> {
|
|
|
47
47
|
const routeChunks = new Map<string, string>();
|
|
48
48
|
let clientEntry = "";
|
|
49
49
|
let rootChunk: string | undefined;
|
|
50
|
+
const outdirAbs = resolve("build/client");
|
|
51
|
+
const appDirClean = appDir.replace(/^\.\//, "");
|
|
52
|
+
const entryBase = basename("src/client/entry.tsx", extname("src/client/entry.tsx")); // "entry"
|
|
53
|
+
const rootBase = basename(rootFilePath, extname(rootFilePath)); // "root"
|
|
50
54
|
|
|
51
55
|
for (const artifact of clientResult.outputs) {
|
|
52
56
|
if (artifact.kind !== "chunk" && artifact.kind !== "entry-point") continue;
|
|
@@ -57,13 +61,19 @@ export async function runBuild(config: BractJSConfig): Promise<void> {
|
|
|
57
61
|
await rename(artifact.path, hashedPath);
|
|
58
62
|
|
|
59
63
|
const publicPath = "/" + hashedPath.replace(/^build\//, "build/");
|
|
60
|
-
|
|
64
|
+
const absPath = resolve(artifact.path);
|
|
65
|
+
const rel = absPath.startsWith(outdirAbs + "/") ? absPath.slice(outdirAbs.length + 1) : basename(artifact.path);
|
|
66
|
+
const outBase = basename(artifact.path, extname(artifact.path));
|
|
67
|
+
|
|
68
|
+
if (artifact.kind === "entry-point" && outBase === entryBase) {
|
|
61
69
|
clientEntry = publicPath;
|
|
62
|
-
} else if (artifact.kind === "entry-point" &&
|
|
70
|
+
} else if (artifact.kind === "entry-point" && outBase === rootBase) {
|
|
63
71
|
rootChunk = publicPath;
|
|
64
72
|
} else {
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
const matched = routes.find((r) => {
|
|
74
|
+
const expected = join(appDirClean, r.filePath).replace(/\.[^.]+$/, ".js");
|
|
75
|
+
return rel === expected;
|
|
76
|
+
});
|
|
67
77
|
if (matched) routeChunks.set(matched.urlPattern, publicPath);
|
|
68
78
|
}
|
|
69
79
|
}
|
package/src/build/directives.ts
CHANGED
|
@@ -3,10 +3,37 @@ import type { BunPlugin } from "bun";
|
|
|
3
3
|
const CLIENT_RE = /^["']use client["']/m;
|
|
4
4
|
const SERVER_RE = /^["']use server["']/m;
|
|
5
5
|
|
|
6
|
+
// Strip a UTF-8 BOM and any leading ASCII whitespace before testing the
|
|
7
|
+
// directive regex. Editors that save files with BOM otherwise let "use server"
|
|
8
|
+
// fall through and ship server code to the client bundle.
|
|
9
|
+
function normalizeForDirectiveCheck(src: string): string {
|
|
10
|
+
return src.replace(/^/, "").replace(/^\s+/, "");
|
|
11
|
+
}
|
|
12
|
+
function hasClientDirective(src: string): boolean {
|
|
13
|
+
return CLIENT_RE.test(normalizeForDirectiveCheck(src));
|
|
14
|
+
}
|
|
15
|
+
function hasServerDirective(src: string): boolean {
|
|
16
|
+
return SERVER_RE.test(normalizeForDirectiveCheck(src));
|
|
17
|
+
}
|
|
18
|
+
|
|
6
19
|
function extractExports(src: string): string[] {
|
|
7
20
|
const names: string[] = [];
|
|
8
21
|
for (const m of src.matchAll(/^export\s+(?:async\s+)?function\s+(\w+)/gm)) names.push(m[1]);
|
|
9
|
-
for (const m of src.matchAll(/^export\s+(?:let|const)\s+(\w+)\s*=/gm)) names.push(m[1]);
|
|
22
|
+
for (const m of src.matchAll(/^export\s+(?:let|const|var)\s+(\w+)\s*=/gm)) names.push(m[1]);
|
|
23
|
+
for (const m of src.matchAll(/^export\s+default\s+(?:async\s+)?function\s+(\w+)/gm)) names.push(m[1]);
|
|
24
|
+
for (const m of src.matchAll(/^export\s+class\s+(\w+)/gm)) names.push(m[1]);
|
|
25
|
+
for (const m of src.matchAll(/^export\s*\{([^}]+)\}/gm)) {
|
|
26
|
+
for (const part of m[1].split(",")) {
|
|
27
|
+
const trimmed = part.trim();
|
|
28
|
+
if (!trimmed) continue;
|
|
29
|
+
const asMatch = trimmed.match(/\bas\s+(\w+)$/);
|
|
30
|
+
if (asMatch) names.push(asMatch[1]);
|
|
31
|
+
else {
|
|
32
|
+
const idMatch = trimmed.match(/^(\w+)/);
|
|
33
|
+
if (idMatch) names.push(idMatch[1]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
10
37
|
return names;
|
|
11
38
|
}
|
|
12
39
|
|
|
@@ -25,7 +52,7 @@ export const useClientStubPlugin: BunPlugin = {
|
|
|
25
52
|
setup(build) {
|
|
26
53
|
build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async ({ path }) => {
|
|
27
54
|
const src = await Bun.file(path).text();
|
|
28
|
-
if (!
|
|
55
|
+
if (!hasClientDirective(src)) return undefined;
|
|
29
56
|
const stubs = extractExports(src).map((n) => `export const ${n} = () => null;`).join("\n");
|
|
30
57
|
return { contents: stubs || "export {};", loader: "ts" };
|
|
31
58
|
});
|
|
@@ -52,7 +79,7 @@ export const useServerProxyPlugin: BunPlugin = {
|
|
|
52
79
|
setup(build) {
|
|
53
80
|
build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async ({ path }) => {
|
|
54
81
|
const src = await Bun.file(path).text();
|
|
55
|
-
if (!
|
|
82
|
+
if (!hasServerDirective(src)) return undefined;
|
|
56
83
|
const names = extractExports(src);
|
|
57
84
|
if (names.length === 0) return { contents: "export {};", loader: "ts" };
|
|
58
85
|
const proxies = await Promise.all(
|
package/src/build/env-plugin.ts
CHANGED
|
@@ -41,6 +41,7 @@ export function clientEnvPlugin(
|
|
|
41
41
|
name: "bractjs-client-env",
|
|
42
42
|
setup(build) {
|
|
43
43
|
build.onLoad({ filter: /\.(ts|tsx|js|jsx)$/ }, async (args) => {
|
|
44
|
+
if (args.path.includes("/node_modules/")) return undefined;
|
|
44
45
|
const src = await Bun.file(args.path).text();
|
|
45
46
|
const contents = src.replace(
|
|
46
47
|
/process\.env\.([A-Z_][A-Z0-9_]*)/g,
|
package/src/build/hash.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { extname, basename, dirname, join } from "node:path";
|
|
2
|
-
import { test, expect } from "bun:test";
|
|
3
2
|
|
|
4
3
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
5
4
|
|
|
@@ -35,22 +34,3 @@ export async function renameWithHash(filePath: string): Promise<string> {
|
|
|
35
34
|
const base = basename(filePath, ext);
|
|
36
35
|
return join(dirname(filePath), `${base}.${hash}${ext}`);
|
|
37
36
|
}
|
|
38
|
-
|
|
39
|
-
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
40
|
-
|
|
41
|
-
test("same content → same hash", async () => {
|
|
42
|
-
const a = await hashString("hello world");
|
|
43
|
-
const b = await hashString("hello world");
|
|
44
|
-
expect(a).toBe(b);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("different content → different hash", async () => {
|
|
48
|
-
const a = await hashString("foo");
|
|
49
|
-
const b = await hashString("bar");
|
|
50
|
-
expect(a).not.toBe(b);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("hash is 8 hex chars", async () => {
|
|
54
|
-
const h = await hashString("bractjs");
|
|
55
|
-
expect(h).toMatch(/^[0-9a-f]{8}$/);
|
|
56
|
-
});
|
|
@@ -68,8 +68,10 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
68
68
|
setPathname(to);
|
|
69
69
|
setCurrentModule(routeModule);
|
|
70
70
|
});
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
const metaList = data.meta as Array<Record<string, unknown>> | undefined;
|
|
72
|
+
const titleEntry = metaList?.find((m) => "title" in m);
|
|
73
|
+
if (titleEntry && typeof titleEntry.title === "string") {
|
|
74
|
+
document.title = titleEntry.title;
|
|
73
75
|
}
|
|
74
76
|
} catch (err) {
|
|
75
77
|
console.error("[bractjs] loadRoute error:", err);
|
|
@@ -93,9 +95,11 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
93
95
|
// Module-level HMR: swap the current route module without a full reload.
|
|
94
96
|
// The injected HMR client script calls window.__BRACTJS_HMR_ACCEPT__(pattern, mod)
|
|
95
97
|
// after importing the freshly-built chunk from /_hmr/module.
|
|
98
|
+
// Dev gate: prod builds inject __BRACT_DEV__ = false; absence in browser also
|
|
99
|
+
// counts as prod since we never reference `process` here.
|
|
96
100
|
useEffect(() => {
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
const w = window as unknown as { __BRACT_DEV__?: boolean; __BRACTJS_HMR_ACCEPT__?: unknown };
|
|
102
|
+
if (w.__BRACT_DEV__ !== true) return;
|
|
99
103
|
w.__BRACTJS_HMR_ACCEPT__ = (pattern: string, mod: RouteModuleClient) => {
|
|
100
104
|
const current = matchPatternForPath(pathname, manifest);
|
|
101
105
|
if (current === pattern) startTransition(() => setCurrentModule(mod));
|
|
@@ -29,23 +29,44 @@ function substituteParams(pattern: string, params: string[]): string {
|
|
|
29
29
|
).join("/");
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// Allowed pattern: "/" + (segment | ":ident") (segments are filename-derived).
|
|
33
|
+
// Restricting upfront removes any chance that a hostile filename injects a
|
|
34
|
+
// backtick, ${ }, or quote into the generated TS source.
|
|
35
|
+
const SAFE_PATTERN_RE = /^\/(?:[A-Za-z0-9_\-]+|:[A-Za-z_][A-Za-z0-9_]*)(?:\/(?:[A-Za-z0-9_\-]+|:[A-Za-z_][A-Za-z0-9_]*))*$|^\/$/;
|
|
36
|
+
const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
37
|
+
|
|
38
|
+
function assertSafePattern(pattern: string): void {
|
|
39
|
+
if (!SAFE_PATTERN_RE.test(pattern)) {
|
|
40
|
+
throw new Error(`[bractjs] codegen: refusing to emit unsafe route pattern: ${JSON.stringify(pattern)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function assertSafeParam(name: string): void {
|
|
44
|
+
if (!SAFE_IDENT_RE.test(name)) {
|
|
45
|
+
throw new Error(`[bractjs] codegen: refusing to emit unsafe param name: ${JSON.stringify(name)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
function builderEntry(pattern: string, params: string[]): string {
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
assertSafePattern(pattern);
|
|
51
|
+
params.forEach(assertSafeParam);
|
|
52
|
+
const key = JSON.stringify(pattern);
|
|
53
|
+
if (params.length === 0) return " " + key + ": () => " + key + " as const,";
|
|
35
54
|
const paramType = params.map((p) => p + ": string").join("; ");
|
|
36
55
|
const body = substituteParams(pattern, params);
|
|
37
|
-
return "
|
|
56
|
+
return " " + key + ": (params: { " + paramType + " }) => `" + body + "` as const,";
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
function paramsTypeLines(routes: Array<{ pattern: string; params: string[] }>): string {
|
|
41
60
|
const dynamic = routes.filter((r) => r.params.length > 0);
|
|
42
61
|
if (dynamic.length === 0) return "export type RouteParams<_T extends AppRoutes> = Record<never, never>;";
|
|
43
62
|
const branches = dynamic
|
|
44
|
-
.map((r) =>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
+ "
|
|
48
|
-
|
|
63
|
+
.map((r) => {
|
|
64
|
+
assertSafePattern(r.pattern);
|
|
65
|
+
r.params.forEach(assertSafeParam);
|
|
66
|
+
return " T extends " + JSON.stringify(r.pattern) + " ? { "
|
|
67
|
+
+ r.params.map((p) => p + ": string").join("; ")
|
|
68
|
+
+ " } :";
|
|
69
|
+
})
|
|
49
70
|
.join("\n");
|
|
50
71
|
return "export type RouteParams<T extends AppRoutes> =\n" + branches + "\n Record<never, never>;";
|
|
51
72
|
}
|
|
@@ -65,7 +86,10 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
|
|
|
65
86
|
}));
|
|
66
87
|
|
|
67
88
|
const union = routes.length > 0
|
|
68
|
-
? routes.map((r) =>
|
|
89
|
+
? routes.map((r) => {
|
|
90
|
+
assertSafePattern(r.pattern);
|
|
91
|
+
return " | " + JSON.stringify(r.pattern);
|
|
92
|
+
}).join("\n")
|
|
69
93
|
: " never";
|
|
70
94
|
|
|
71
95
|
const builderEntries = routes.map((r) => builderEntry(r.pattern, r.params)).join("\n");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { resolve, join } from "node:path";
|
|
1
|
+
import { resolve, join, sep } from "node:path";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Dev-only HTTP handler for /_hmr/module?file=routes/about.tsx
|
|
@@ -17,10 +18,19 @@ export async function handleHmrModuleRequest(
|
|
|
17
18
|
return new Response("Missing file param", { status: 400 });
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
// Resolve and guard against path traversal
|
|
21
|
+
// Resolve and guard against path traversal AND symlink escape.
|
|
21
22
|
const rootDir = resolve(appDir);
|
|
22
|
-
const
|
|
23
|
-
if (!
|
|
23
|
+
const candidate = resolve(join(rootDir, file));
|
|
24
|
+
if (!candidate.startsWith(rootDir + sep) && candidate !== rootDir) {
|
|
25
|
+
return new Response("Forbidden", { status: 403 });
|
|
26
|
+
}
|
|
27
|
+
let fullPath: string;
|
|
28
|
+
try {
|
|
29
|
+
fullPath = await realpath(candidate);
|
|
30
|
+
} catch {
|
|
31
|
+
return new Response("Not Found", { status: 404 });
|
|
32
|
+
}
|
|
33
|
+
if (!fullPath.startsWith(rootDir + sep) && fullPath !== rootDir) {
|
|
24
34
|
return new Response("Forbidden", { status: 403 });
|
|
25
35
|
}
|
|
26
36
|
|