@bractjs/bractjs 0.1.5 → 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/package.json +1 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bractjs/bractjs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { handleActionRequest } from "../server/action-handler.ts";
|
|
3
|
+
import { resolveAction } from "../server/action-registry.ts";
|
|
4
|
+
|
|
5
|
+
// ── handleActionRequest routing guards ────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
describe("handleActionRequest — routing", () => {
|
|
8
|
+
test("returns null for non-/_action paths", async () => {
|
|
9
|
+
const req = new Request("http://localhost/about", { method: "POST" });
|
|
10
|
+
const result = await handleActionRequest(req);
|
|
11
|
+
expect(result).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("returns null for / path (no /_action prefix)", async () => {
|
|
15
|
+
const req = new Request("http://localhost/", { method: "POST" });
|
|
16
|
+
expect(await handleActionRequest(req)).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("returns 405 for GET to /_action", async () => {
|
|
20
|
+
const req = new Request("http://localhost/_action?id=abc", { method: "GET" });
|
|
21
|
+
const res = await handleActionRequest(req);
|
|
22
|
+
expect(res?.status).toBe(405);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("returns 400 when id query param is missing", async () => {
|
|
26
|
+
const req = new Request("http://localhost/_action", { method: "POST", headers: { "X-BractJS-Action": "1" } });
|
|
27
|
+
const res = await handleActionRequest(req);
|
|
28
|
+
expect(res?.status).toBe(400);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("returns 404 for unknown action id", async () => {
|
|
32
|
+
const req = new Request("http://localhost/_action?id=does-not-exist-xyz", { method: "POST", headers: { "X-BractJS-Action": "1" } });
|
|
33
|
+
const res = await handleActionRequest(req);
|
|
34
|
+
expect(res?.status).toBe(404);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ── resolveAction ──────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
describe("resolveAction", () => {
|
|
42
|
+
test("returns null for unknown ids", () => {
|
|
43
|
+
expect(resolveAction("nonexistent-id-xyz")).toBeNull();
|
|
44
|
+
expect(resolveAction("")).toBeNull();
|
|
45
|
+
expect(resolveAction("00000000")).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
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 { loadServerActions, resolveAction } from "../server/action-registry.ts";
|
|
5
|
+
|
|
6
|
+
const TMP = resolve(import.meta.dir, ".tmp-action-registry");
|
|
7
|
+
|
|
8
|
+
async function computeId(filePath: string, name: string): Promise<string> {
|
|
9
|
+
const raw = new TextEncoder().encode(filePath + "#" + name);
|
|
10
|
+
const buf = await crypto.subtle.digest("SHA-256", raw);
|
|
11
|
+
return Array.from(new Uint8Array(buf))
|
|
12
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
13
|
+
.join("")
|
|
14
|
+
.slice(0, 16);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
await rm(TMP, { recursive: true, force: true });
|
|
19
|
+
await mkdir(join(TMP, "routes"), { recursive: true });
|
|
20
|
+
await mkdir(join(TMP, "lib"), { recursive: true });
|
|
21
|
+
|
|
22
|
+
// Eligible: routes/ file with real "use server" directive
|
|
23
|
+
await writeFile(
|
|
24
|
+
join(TMP, "routes", "_index.tsx"),
|
|
25
|
+
`"use server";\nexport async function realAction() { return 1; }\n`,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// Eligible by suffix: .server.ts
|
|
29
|
+
await writeFile(
|
|
30
|
+
join(TMP, "lib", "thing.server.ts"),
|
|
31
|
+
`"use server";\nexport async function suffixAction() { return 2; }\n`,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Ineligible: arbitrary lib file (no .server suffix, not in routes/)
|
|
35
|
+
await writeFile(
|
|
36
|
+
join(TMP, "lib", "helpers.ts"),
|
|
37
|
+
`"use server";\nexport async function shouldNotLoad() { return 3; }\n`,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Ineligible: "use server" inside a template literal (not at start-of-file)
|
|
41
|
+
await writeFile(
|
|
42
|
+
join(TMP, "routes", "fake.tsx"),
|
|
43
|
+
`const s = \`use server\`;\nexport async function notADirective() { return 4; }\n`,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
await loadServerActions(TMP);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterAll(async () => {
|
|
50
|
+
await rm(TMP, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("loadServerActions — eligibility", () => {
|
|
54
|
+
test("routes/ file with real directive registers exports", async () => {
|
|
55
|
+
const id = await computeId(join(TMP, "routes", "_index.tsx"), "realAction");
|
|
56
|
+
expect(resolveAction(id)).not.toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test(".server.ts file registers exports", async () => {
|
|
60
|
+
const id = await computeId(join(TMP, "lib", "thing.server.ts"), "suffixAction");
|
|
61
|
+
expect(resolveAction(id)).not.toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("ineligible path (lib/*.ts) does NOT register", async () => {
|
|
65
|
+
const id = await computeId(join(TMP, "lib", "helpers.ts"), "shouldNotLoad");
|
|
66
|
+
expect(resolveAction(id)).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("'use server' inside template literal does NOT register", async () => {
|
|
70
|
+
const id = await computeId(join(TMP, "routes", "fake.tsx"), "notADirective");
|
|
71
|
+
expect(resolveAction(id)).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { generateRouteTypes } from "../codegen/route-codegen.ts";
|
|
6
|
+
|
|
7
|
+
let appDir = "";
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
appDir = join(tmpdir(), `bract-codegen-${Date.now()}`);
|
|
11
|
+
await mkdir(join(appDir, "routes"), { recursive: true });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterAll(async () => {
|
|
15
|
+
await rm(appDir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("route-codegen — output shape", () => {
|
|
19
|
+
test("emits JSON-quoted keys for safe patterns", async () => {
|
|
20
|
+
const routesDir = join(appDir, "routes");
|
|
21
|
+
await writeFile(join(routesDir, "_index.tsx"), "export default () => null;");
|
|
22
|
+
await mkdir(join(routesDir, "users"), { recursive: true });
|
|
23
|
+
await writeFile(join(routesDir, "users", "[id].tsx"), "export default () => null;");
|
|
24
|
+
|
|
25
|
+
const out = await generateRouteTypes(appDir);
|
|
26
|
+
expect(out).toContain("\"/\":");
|
|
27
|
+
expect(out).toContain("\"/users/:id\":");
|
|
28
|
+
// The pattern key in the literal union must also be JSON-quoted.
|
|
29
|
+
expect(out).toMatch(/\| "\/users\/:id"/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("rejects hostile filenames at codegen time", async () => {
|
|
33
|
+
const hostileApp = join(tmpdir(), `bract-codegen-hostile-${Date.now()}`);
|
|
34
|
+
await mkdir(join(hostileApp, "routes"), { recursive: true });
|
|
35
|
+
// Try to plant a filename with a quote. macOS/Linux accept it; if the FS
|
|
36
|
+
// doesn't, we skip this assertion (FS already rejected the attack).
|
|
37
|
+
const hostileFile = join(hostileApp, "routes", `bad"name.tsx`);
|
|
38
|
+
let planted = false;
|
|
39
|
+
try {
|
|
40
|
+
await writeFile(hostileFile, "export default () => null;");
|
|
41
|
+
planted = await Bun.file(hostileFile).exists();
|
|
42
|
+
} catch {
|
|
43
|
+
planted = false;
|
|
44
|
+
}
|
|
45
|
+
if (planted) {
|
|
46
|
+
await expect(generateRouteTypes(hostileApp)).rejects.toThrow(/unsafe route pattern/);
|
|
47
|
+
}
|
|
48
|
+
await rm(hostileApp, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { Deferred, defer, isDeferred, stripDeferred, promisesOf } from "../shared/deferred.ts";
|
|
3
|
+
|
|
4
|
+
describe("Deferred", () => {
|
|
5
|
+
test("holds a promise", () => {
|
|
6
|
+
const p = Promise.resolve(42);
|
|
7
|
+
const d = new Deferred(p);
|
|
8
|
+
expect(d.promise).toBe(p);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("isDeferred returns true for Deferred instances", () => {
|
|
12
|
+
expect(isDeferred(new Deferred(Promise.resolve()))).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("isDeferred returns false for plain promises", () => {
|
|
16
|
+
expect(isDeferred(Promise.resolve())).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("isDeferred returns false for non-objects", () => {
|
|
20
|
+
expect(isDeferred(null)).toBe(false);
|
|
21
|
+
expect(isDeferred(42)).toBe(false);
|
|
22
|
+
expect(isDeferred("string")).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("defer", () => {
|
|
27
|
+
test("wraps Promise values in Deferred", () => {
|
|
28
|
+
const result = defer({ data: Promise.resolve([1, 2, 3]) });
|
|
29
|
+
expect(isDeferred(result.data)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("passes through non-Promise values unchanged", () => {
|
|
33
|
+
const result = defer({ count: 5, label: "hello" });
|
|
34
|
+
expect(result.count).toBe(5);
|
|
35
|
+
expect(result.label).toBe("hello");
|
|
36
|
+
expect(isDeferred(result.count)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("mixed: some deferred, some immediate", () => {
|
|
40
|
+
const p = Promise.resolve("async-val");
|
|
41
|
+
const result = defer({ sync: "immediate", async: p });
|
|
42
|
+
expect(result.sync).toBe("immediate");
|
|
43
|
+
expect(isDeferred(result.async)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("empty object returns empty object", () => {
|
|
47
|
+
const result = defer({});
|
|
48
|
+
expect(Object.keys(result)).toHaveLength(0);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("stripDeferred", () => {
|
|
53
|
+
test("returns only non-deferred values", () => {
|
|
54
|
+
const input = defer({ a: 1, b: Promise.resolve(2) });
|
|
55
|
+
const stripped = stripDeferred(input as Record<string, unknown>);
|
|
56
|
+
expect(stripped).toHaveProperty("a", 1);
|
|
57
|
+
expect(stripped).not.toHaveProperty("b");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("returns all values when nothing is deferred", () => {
|
|
61
|
+
const stripped = stripDeferred({ x: 10, y: "hello" });
|
|
62
|
+
expect(stripped).toEqual({ x: 10, y: "hello" });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("returns empty object when everything is deferred", () => {
|
|
66
|
+
const input = defer({ a: Promise.resolve(1), b: Promise.resolve(2) });
|
|
67
|
+
const stripped = stripDeferred(input as Record<string, unknown>);
|
|
68
|
+
expect(Object.keys(stripped)).toHaveLength(0);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("promisesOf", () => {
|
|
73
|
+
test("returns only deferred values as their underlying promises", async () => {
|
|
74
|
+
const p = Promise.resolve(99);
|
|
75
|
+
const input = defer({ fast: "sync", slow: p });
|
|
76
|
+
const promises = promisesOf(input as Record<string, unknown>);
|
|
77
|
+
expect(Object.keys(promises)).toEqual(["slow"]);
|
|
78
|
+
const val = await promises.slow;
|
|
79
|
+
expect(val).toBe(99);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("returns empty object when nothing is deferred", () => {
|
|
83
|
+
const result = promisesOf({ a: 1, b: "hello" });
|
|
84
|
+
expect(Object.keys(result)).toHaveLength(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("each promise in result resolves to deferred value", async () => {
|
|
88
|
+
const input = defer({
|
|
89
|
+
x: Promise.resolve("alpha"),
|
|
90
|
+
y: Promise.resolve("beta"),
|
|
91
|
+
});
|
|
92
|
+
const promises = promisesOf(input as Record<string, unknown>);
|
|
93
|
+
expect(await promises.x).toBe("alpha");
|
|
94
|
+
expect(await promises.y).toBe("beta");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { useClientStubPlugin, useServerProxyPlugin } from "../build/directives.ts";
|
|
6
|
+
|
|
7
|
+
let dir = "";
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
dir = join(tmpdir(), `bract-directives-${Date.now()}`);
|
|
11
|
+
await mkdir(dir, { recursive: true });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterAll(async () => {
|
|
15
|
+
await rm(dir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
async function runBundle(entry: string, plugin: typeof useClientStubPlugin): Promise<string> {
|
|
19
|
+
const out = await Bun.build({
|
|
20
|
+
entrypoints: [entry],
|
|
21
|
+
target: "browser",
|
|
22
|
+
minify: false,
|
|
23
|
+
plugins: [plugin],
|
|
24
|
+
});
|
|
25
|
+
if (!out.success) throw new Error(out.logs.join("\n"));
|
|
26
|
+
return await out.outputs[0].text();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("directives — BOM-prefixed 'use server'", () => {
|
|
30
|
+
test("BOM + 'use server' still detected → exports replaced with fetch proxy", async () => {
|
|
31
|
+
const file = join(dir, "bom-server.ts");
|
|
32
|
+
await writeFile(file, "\"use server\";\nexport async function ping(x) { return x; }\n");
|
|
33
|
+
const code = await runBundle(file, useServerProxyPlugin);
|
|
34
|
+
// The proxy helper is inlined when directive is detected.
|
|
35
|
+
expect(code).toContain("__bract");
|
|
36
|
+
expect(code).toContain("/_action?id=");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("BOM + 'use client' detected → exports replaced with null stubs", async () => {
|
|
40
|
+
const file = join(dir, "bom-client.tsx");
|
|
41
|
+
await writeFile(file, "\"use client\";\nexport const Widget = () => null;\n");
|
|
42
|
+
const code = await runBundle(file, useClientStubPlugin);
|
|
43
|
+
expect(code).toMatch(/Widget\s*=\s*\(\)\s*=>\s*null/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("leading whitespace + 'use server' still detected", async () => {
|
|
47
|
+
const file = join(dir, "ws-server.ts");
|
|
48
|
+
await writeFile(file, " \n\t\"use server\";\nexport async function pong() { return 1; }\n");
|
|
49
|
+
const code = await runBundle(file, useServerProxyPlugin);
|
|
50
|
+
expect(code).toContain("__bract");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { safeStringify, requireEnv } from "../server/env.ts";
|
|
3
|
+
|
|
4
|
+
describe("safeStringify", () => {
|
|
5
|
+
test("serializes plain objects", () => {
|
|
6
|
+
const out = safeStringify({ a: 1, b: "hello" });
|
|
7
|
+
expect(JSON.parse(out)).toEqual({ a: 1, b: "hello" });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("serializes arrays", () => {
|
|
11
|
+
const out = safeStringify([1, 2, 3]);
|
|
12
|
+
expect(JSON.parse(out)).toEqual([1, 2, 3]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("serializes null", () => {
|
|
16
|
+
expect(safeStringify(null)).toBe("null");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("escapes < to \\u003c (XSS safe in <script> tags)", () => {
|
|
20
|
+
const out = safeStringify({ html: "<script>" });
|
|
21
|
+
expect(out).not.toContain("<");
|
|
22
|
+
expect(out).toContain("\\u003c");
|
|
23
|
+
expect(JSON.parse(out).html).toBe("<script>");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("escapes > to \\u003e", () => {
|
|
27
|
+
const out = safeStringify({ html: "</script>" });
|
|
28
|
+
expect(out).not.toContain(">");
|
|
29
|
+
expect(out).toContain("\\u003e");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("escapes & to \\u0026", () => {
|
|
33
|
+
const out = safeStringify({ val: "a&&b" });
|
|
34
|
+
expect(out).not.toContain("&&");
|
|
35
|
+
expect(out).toContain("\\u0026");
|
|
36
|
+
expect(JSON.parse(out).val).toBe("a&&b");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("handles circular references with [Circular] sentinel", () => {
|
|
40
|
+
const obj: Record<string, unknown> = { name: "root" };
|
|
41
|
+
obj.self = obj;
|
|
42
|
+
const out = safeStringify(obj);
|
|
43
|
+
const parsed = JSON.parse(out) as { name: string; self: string };
|
|
44
|
+
expect(parsed.name).toBe("root");
|
|
45
|
+
expect(parsed.self).toBe("[Circular]");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("handles nested objects", () => {
|
|
49
|
+
const out = safeStringify({ a: { b: { c: 42 } } });
|
|
50
|
+
expect(JSON.parse(out)).toEqual({ a: { b: { c: 42 } } });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("requireEnv", () => {
|
|
55
|
+
test("returns value when env var is set", () => {
|
|
56
|
+
Bun.env.TEST_VAR_BRACTJS = "hello";
|
|
57
|
+
expect(requireEnv("TEST_VAR_BRACTJS")).toBe("hello");
|
|
58
|
+
delete Bun.env.TEST_VAR_BRACTJS;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("throws when env var is missing", () => {
|
|
62
|
+
delete Bun.env.DEFINITELY_NOT_SET_BRACTJS;
|
|
63
|
+
expect(() => requireEnv("DEFINITELY_NOT_SET_BRACTJS")).toThrow(
|
|
64
|
+
"Missing required environment variable: DEFINITELY_NOT_SET_BRACTJS",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("throws when env var is empty string", () => {
|
|
69
|
+
Bun.env.EMPTY_VAR_BRACTJS = "";
|
|
70
|
+
expect(() => requireEnv("EMPTY_VAR_BRACTJS")).toThrow();
|
|
71
|
+
delete Bun.env.EMPTY_VAR_BRACTJS;
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
BractJSError,
|
|
4
|
+
HttpError,
|
|
5
|
+
isRedirect,
|
|
6
|
+
isHttpError,
|
|
7
|
+
isBractJSError,
|
|
8
|
+
} from "../shared/errors.ts";
|
|
9
|
+
|
|
10
|
+
describe("BractJSError", () => {
|
|
11
|
+
test("sets name, message, and default status 500", () => {
|
|
12
|
+
const e = new BractJSError("something went wrong");
|
|
13
|
+
expect(e.name).toBe("BractJSError");
|
|
14
|
+
expect(e.message).toBe("something went wrong");
|
|
15
|
+
expect(e.status).toBe(500);
|
|
16
|
+
expect(e).toBeInstanceOf(Error);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("accepts custom status", () => {
|
|
20
|
+
const e = new BractJSError("forbidden", 403);
|
|
21
|
+
expect(e.status).toBe(403);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("HttpError", () => {
|
|
26
|
+
test("derives message from known status codes", () => {
|
|
27
|
+
expect(new HttpError(400).message).toBe("Bad Request");
|
|
28
|
+
expect(new HttpError(401).message).toBe("Unauthorized");
|
|
29
|
+
expect(new HttpError(403).message).toBe("Forbidden");
|
|
30
|
+
expect(new HttpError(404).message).toBe("Not Found");
|
|
31
|
+
expect(new HttpError(405).message).toBe("Method Not Allowed");
|
|
32
|
+
expect(new HttpError(422).message).toBe("Unprocessable Entity");
|
|
33
|
+
expect(new HttpError(429).message).toBe("Too Many Requests");
|
|
34
|
+
expect(new HttpError(500).message).toBe("Internal Server Error");
|
|
35
|
+
expect(new HttpError(503).message).toBe("Service Unavailable");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("falls back to generic message for unknown codes", () => {
|
|
39
|
+
expect(new HttpError(418).message).toBe("HTTP Error 418");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("accepts explicit message override", () => {
|
|
43
|
+
const e = new HttpError(403, "Custom denied");
|
|
44
|
+
expect(e.message).toBe("Custom denied");
|
|
45
|
+
expect(e.status).toBe(403);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("name is HttpError and inherits from BractJSError", () => {
|
|
49
|
+
const e = new HttpError(500);
|
|
50
|
+
expect(e.name).toBe("HttpError");
|
|
51
|
+
expect(e).toBeInstanceOf(BractJSError);
|
|
52
|
+
expect(e).toBeInstanceOf(Error);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("isRedirect", () => {
|
|
57
|
+
test("returns true for 3xx Response", () => {
|
|
58
|
+
expect(isRedirect(new Response(null, { status: 302, headers: { Location: "/" } }))).toBe(true);
|
|
59
|
+
expect(isRedirect(new Response(null, { status: 301, headers: { Location: "/new" } }))).toBe(true);
|
|
60
|
+
expect(isRedirect(new Response(null, { status: 307, headers: { Location: "/tmp" } }))).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("returns false for non-3xx Response", () => {
|
|
64
|
+
expect(isRedirect(new Response(null, { status: 200 }))).toBe(false);
|
|
65
|
+
expect(isRedirect(new Response(null, { status: 404 }))).toBe(false);
|
|
66
|
+
expect(isRedirect(new Response(null, { status: 500 }))).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("returns false for non-Response values", () => {
|
|
70
|
+
expect(isRedirect(null)).toBe(false);
|
|
71
|
+
expect(isRedirect(undefined)).toBe(false);
|
|
72
|
+
expect(isRedirect("https://example.com")).toBe(false);
|
|
73
|
+
expect(isRedirect({ status: 302 })).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("isHttpError", () => {
|
|
78
|
+
test("returns true for HttpError instances", () => {
|
|
79
|
+
expect(isHttpError(new HttpError(404))).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("returns false for plain Error", () => {
|
|
83
|
+
expect(isHttpError(new Error("boom"))).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("returns false for BractJSError (not HttpError)", () => {
|
|
87
|
+
expect(isHttpError(new BractJSError("oops"))).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("returns false for non-errors", () => {
|
|
91
|
+
expect(isHttpError(null)).toBe(false);
|
|
92
|
+
expect(isHttpError(404)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("isBractJSError", () => {
|
|
97
|
+
test("returns true for BractJSError", () => {
|
|
98
|
+
expect(isBractJSError(new BractJSError("x"))).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("returns true for HttpError (subclass)", () => {
|
|
102
|
+
expect(isBractJSError(new HttpError(500))).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("returns false for plain Error", () => {
|
|
106
|
+
expect(isBractJSError(new Error("plain"))).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("returns false for non-errors", () => {
|
|
110
|
+
expect(isBractJSError(null)).toBe(false);
|
|
111
|
+
expect(isBractJSError("error")).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { hashString } from "../build/hash.ts";
|
|
3
|
+
|
|
4
|
+
test("same content → same hash", async () => {
|
|
5
|
+
const a = await hashString("hello world");
|
|
6
|
+
const b = await hashString("hello world");
|
|
7
|
+
expect(a).toBe(b);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("different content → different hash", async () => {
|
|
11
|
+
const a = await hashString("foo");
|
|
12
|
+
const b = await hashString("bar");
|
|
13
|
+
expect(a).not.toBe(b);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("hash is 8 hex chars", async () => {
|
|
17
|
+
const h = await hashString("bractjs");
|
|
18
|
+
expect(h).toMatch(/^[0-9a-f]{8}$/);
|
|
19
|
+
});
|
|
@@ -37,7 +37,7 @@ test("GET /_data?path=/ returns JSON with route key", async () => {
|
|
|
37
37
|
test("POST / runs action and returns 200 HTML", async () => {
|
|
38
38
|
const form = new FormData();
|
|
39
39
|
form.set("name", "bract");
|
|
40
|
-
const res = await fetch(`${BASE}/`, { method: "POST", body: form });
|
|
40
|
+
const res = await fetch(`${BASE}/`, { method: "POST", body: form, headers: { Origin: BASE } });
|
|
41
41
|
expect(res.status).toBe(200);
|
|
42
42
|
expect(res.headers.get("content-type")).toContain("text/html");
|
|
43
43
|
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { generateManifest } from "../build/manifest.ts";
|
|
3
|
+
|
|
4
|
+
describe("generateManifest", () => {
|
|
5
|
+
test("version is always 1", () => {
|
|
6
|
+
const m = generateManifest({ clientEntry: "/build/client.js", routeChunks: new Map() });
|
|
7
|
+
expect(m.version).toBe(1);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("sets clientEntry field", () => {
|
|
11
|
+
const m = generateManifest({ clientEntry: "/build/client.abc123.js", routeChunks: new Map() });
|
|
12
|
+
expect(m.clientEntry).toBe("/build/client.abc123.js");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("converts routeChunks map to routes object", () => {
|
|
16
|
+
const chunks = new Map([
|
|
17
|
+
["", "/build/route-index.abc.js"],
|
|
18
|
+
["about", "/build/route-about.def.js"],
|
|
19
|
+
]);
|
|
20
|
+
const m = generateManifest({ clientEntry: "/build/client.js", routeChunks: chunks });
|
|
21
|
+
expect(m.routes[""]).toEqual({ chunk: "/build/route-index.abc.js", pattern: "" });
|
|
22
|
+
expect(m.routes["about"]).toEqual({ chunk: "/build/route-about.def.js", pattern: "about" });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("empty routeChunks produces empty routes object", () => {
|
|
26
|
+
const m = generateManifest({ clientEntry: "/client.js", routeChunks: new Map() });
|
|
27
|
+
expect(Object.keys(m.routes)).toHaveLength(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("sets optional rootChunk when provided", () => {
|
|
31
|
+
const m = generateManifest({
|
|
32
|
+
clientEntry: "/client.js",
|
|
33
|
+
rootChunk: "/build/root.chunk.js",
|
|
34
|
+
routeChunks: new Map(),
|
|
35
|
+
});
|
|
36
|
+
expect(m.rootChunk).toBe("/build/root.chunk.js");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("rootChunk is undefined when not provided", () => {
|
|
40
|
+
const m = generateManifest({ clientEntry: "/client.js", routeChunks: new Map() });
|
|
41
|
+
expect(m.rootChunk).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("each route entry has both chunk and pattern fields", () => {
|
|
45
|
+
const chunks = new Map([["blog/[id]", "/build/blog-id.chunk.js"]]);
|
|
46
|
+
const m = generateManifest({ clientEntry: "/client.js", routeChunks: chunks });
|
|
47
|
+
const entry = m.routes["blog/[id]"];
|
|
48
|
+
expect(entry.chunk).toBe("/build/blog-id.chunk.js");
|
|
49
|
+
expect(entry.pattern).toBe("blog/[id]");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("handles many routes", () => {
|
|
53
|
+
const chunks = new Map(
|
|
54
|
+
Array.from({ length: 20 }, (_, i) => [`route/${i}`, `/build/chunk-${i}.js`])
|
|
55
|
+
);
|
|
56
|
+
const m = generateManifest({ clientEntry: "/client.js", routeChunks: chunks });
|
|
57
|
+
expect(Object.keys(m.routes)).toHaveLength(20);
|
|
58
|
+
expect(m.routes["route/10"].chunk).toBe("/build/chunk-10.js");
|
|
59
|
+
});
|
|
60
|
+
});
|