@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
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# BractJS
|
|
2
2
|
|
|
3
|
-
> Production-grade SSR framework for Bun + React
|
|
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.
|
|
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
|
});
|