@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.
Files changed (44) hide show
  1. package/README.md +13 -13
  2. package/package.json +4 -1
  3. package/src/__tests__/action-handler.test.ts +47 -0
  4. package/src/__tests__/action-registry.test.ts +73 -0
  5. package/src/__tests__/codegen.test.ts +50 -0
  6. package/src/__tests__/deferred.test.ts +96 -0
  7. package/src/__tests__/directives.test.ts +52 -0
  8. package/src/__tests__/env.test.ts +73 -0
  9. package/src/__tests__/errors.test.ts +113 -0
  10. package/src/__tests__/hash.test.ts +19 -0
  11. package/src/__tests__/integration.test.ts +1 -1
  12. package/src/__tests__/manifest.test.ts +60 -0
  13. package/src/__tests__/middleware.test.ts +216 -0
  14. package/src/__tests__/response.test.ts +106 -0
  15. package/src/__tests__/security.test.ts +348 -0
  16. package/src/__tests__/session.test.ts +3 -3
  17. package/src/build/bundler.ts +15 -5
  18. package/src/build/directives.ts +30 -3
  19. package/src/build/env-plugin.ts +1 -0
  20. package/src/build/hash.ts +0 -20
  21. package/src/client/ClientRouter.tsx +8 -4
  22. package/src/codegen/route-codegen.ts +33 -9
  23. package/src/dev/hmr-module-handler.ts +14 -4
  24. package/src/image/cache.ts +28 -8
  25. package/src/image/handler.ts +26 -11
  26. package/src/image/optimizer.ts +45 -13
  27. package/src/image/types.ts +1 -0
  28. package/src/middleware/cors.ts +24 -8
  29. package/src/server/action-handler.ts +40 -1
  30. package/src/server/action-registry.ts +14 -1
  31. package/src/server/csrf.ts +16 -0
  32. package/src/server/env.ts +10 -4
  33. package/src/server/middleware.ts +11 -7
  34. package/src/server/render.ts +7 -5
  35. package/src/server/request-handler.ts +14 -13
  36. package/src/server/response.ts +29 -5
  37. package/src/server/scanner.ts +6 -2
  38. package/src/server/session.ts +16 -5
  39. package/src/server/static.ts +23 -7
  40. package/templates/new-app/app/root.tsx +1 -1
  41. package/templates/new-app/app/routes/_index.tsx +3 -3
  42. package/templates/new-app/app/routes/about.tsx +1 -1
  43. package/templates/new-app/bractjs.config.ts +1 -1
  44. package/templates/new-app/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # BractJS
2
2
 
3
- > Production-grade SSR framework for Bun + React 19.
3
+ > Production-grade SSR framework for Bun + React.
4
4
  > File-based routing · Parallel loaders · Streaming SSR · Built-in HMR · Server Actions
5
5
 
6
6
  ---
@@ -41,8 +41,8 @@ Place files inside `app/routes/`. BractJS scans them at startup.
41
41
  Every file in `app/routes/` can export any combination of these:
42
42
 
43
43
  ```tsx
44
- import type { LoaderArgs, ActionArgs, MetaArgs } from "bractjs";
45
- import { redirect } from "bractjs";
44
+ import type { LoaderArgs, ActionArgs, MetaArgs } from "@bractjs/bractjs";
45
+ import { redirect } from "@bractjs/bractjs";
46
46
 
47
47
  // Runs on every GET — return value becomes useLoaderData()
48
48
  export async function loader({ request, params, context }: LoaderArgs) {
@@ -86,7 +86,7 @@ export default function BlogPost() {
86
86
  Required. Provides the `<html>` document shell.
87
87
 
88
88
  ```tsx
89
- import { Scripts, LiveReload, Outlet } from "bractjs";
89
+ import { Scripts, LiveReload, Outlet } from "@bractjs/bractjs";
90
90
 
91
91
  export function meta() {
92
92
  return [{ title: "My App" }, { name: "viewport", content: "width=device-width, initial-scale=1" }];
@@ -113,8 +113,8 @@ export default function Root() {
113
113
  `defer()` streams slow data without blocking the initial HTML response.
114
114
 
115
115
  ```tsx
116
- import { defer } from "bractjs";
117
- import { Await } from "bractjs";
116
+ import { defer } from "@bractjs/bractjs";
117
+ import { Await } from "@bractjs/bractjs";
118
118
  import { Suspense } from "react";
119
119
 
120
120
  export async function loader({ params }: LoaderArgs) {
@@ -148,7 +148,7 @@ export default function BlogPost() {
148
148
  Soft-navigates without a full reload. `prefetch="hover"` preloads the route chunk + loader data on mouse-enter.
149
149
 
150
150
  ```tsx
151
- import { Link } from "bractjs";
151
+ import { Link } from "@bractjs/bractjs";
152
152
 
153
153
  <Link to="/blog/42">Read Post</Link>
154
154
  <Link to="/about" prefetch="hover">About</Link>
@@ -159,7 +159,7 @@ import { Link } from "bractjs";
159
159
  Fetch-based submission. Re-runs the current route's loader after the action completes.
160
160
 
161
161
  ```tsx
162
- import { Form } from "bractjs";
162
+ import { Form } from "@bractjs/bractjs";
163
163
 
164
164
  <Form method="post" action="/blog/new">
165
165
  <input name="title" />
@@ -195,7 +195,7 @@ export default function BlogLayout() {
195
195
  | `useFetcher()` | `{ data, state, load, submit }` | Background fetch without navigation |
196
196
 
197
197
  ```tsx
198
- import { useLoaderData, useNavigation, useFetcher } from "bractjs";
198
+ import { useLoaderData, useNavigation, useFetcher } from "@bractjs/bractjs";
199
199
 
200
200
  const { post } = useLoaderData<LoaderData>();
201
201
 
@@ -213,7 +213,7 @@ fetcher.load("/api/suggestions?q=bun");
213
213
  `<Image>` serves responsively-sized, format-converted images through a built-in `/_image` endpoint. Requires [ImageMagick](https://imagemagick.org) (`magick` or `convert`) — falls back to serving the original if not installed.
214
214
 
215
215
  ```tsx
216
- import { Image } from "bractjs";
216
+ import { Image } from "@bractjs/bractjs";
217
217
 
218
218
  // Basic — lazy, WebP, 80% quality, responsive srcset
219
219
  <Image src="/public/hero.jpg" alt="Hero" width={1200} height={600} />
@@ -363,7 +363,7 @@ export function Counter() {
363
363
  Middleware runs before routing. Register on the module-level `pipeline` singleton.
364
364
 
365
365
  ```ts
366
- import { pipeline, requestLogger, cors, authGuard } from "bractjs";
366
+ import { pipeline, requestLogger, cors, authGuard } from "@bractjs/bractjs";
367
367
 
368
368
  pipeline
369
369
  .use(requestLogger())
@@ -380,7 +380,7 @@ pipeline
380
380
  **Custom middleware:**
381
381
 
382
382
  ```ts
383
- import type { MiddlewareFn } from "bractjs";
383
+ import type { MiddlewareFn } from "@bractjs/bractjs";
384
384
 
385
385
  const trace: MiddlewareFn = async (ctx, next) => {
386
386
  ctx.context.requestId = crypto.randomUUID();
@@ -395,7 +395,7 @@ const trace: MiddlewareFn = async (ctx, next) => {
395
395
  ## Sessions
396
396
 
397
397
  ```ts
398
- import { createCookieSession } from "bractjs";
398
+ import { createCookieSession } from "@bractjs/bractjs";
399
399
 
400
400
  const session = createCookieSession({
401
401
  name: "__session",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bractjs/bractjs",
3
- "version": "0.1.0",
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",
@@ -23,6 +23,9 @@
23
23
  "typescript",
24
24
  "full-stack"
25
25
  ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
26
29
  "files": [
27
30
  "src",
28
31
  "bin",
@@ -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
  });