@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
@@ -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
+ });
@@ -0,0 +1,216 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { MiddlewarePipeline } from "../server/middleware.ts";
3
+ import type { MiddlewareContext } from "../server/middleware.ts";
4
+ import { cors } from "../middleware/cors.ts";
5
+ import { authGuard } from "../middleware/authGuard.ts";
6
+ import { requestLogger } from "../middleware/requestLogger.ts";
7
+
8
+ // ── Helpers ────────────────────────────────────────────────────────────────
9
+
10
+ function makeCtx(override: Partial<MiddlewareContext> = {}): MiddlewareContext {
11
+ return {
12
+ request: new Request("http://localhost/"),
13
+ params: {},
14
+ context: {},
15
+ ...override,
16
+ };
17
+ }
18
+
19
+ const ok200 = async () => new Response("ok", { status: 200 });
20
+
21
+ // ── MiddlewarePipeline ─────────────────────────────────────────────────────
22
+
23
+ describe("MiddlewarePipeline", () => {
24
+ test("calls handler when no middleware registered", async () => {
25
+ const pipeline = new MiddlewarePipeline();
26
+ const res = await pipeline.run(makeCtx(), ok200);
27
+ expect(res.status).toBe(200);
28
+ });
29
+
30
+ test("runs middleware in registration order", async () => {
31
+ const order: number[] = [];
32
+ const pipeline = new MiddlewarePipeline();
33
+ pipeline
34
+ .use(async (ctx, next) => { order.push(1); const r = await next(); order.push(4); return r; })
35
+ .use(async (ctx, next) => { order.push(2); const r = await next(); order.push(3); return r; });
36
+
37
+ await pipeline.run(makeCtx(), ok200);
38
+ expect(order).toEqual([1, 2, 3, 4]);
39
+ });
40
+
41
+ test("middleware can short-circuit without calling next()", async () => {
42
+ const pipeline = new MiddlewarePipeline();
43
+ pipeline.use(async () => new Response("blocked", { status: 403 }));
44
+ const res = await pipeline.run(makeCtx(), ok200);
45
+ expect(res.status).toBe(403);
46
+ expect(await res.text()).toBe("blocked");
47
+ });
48
+
49
+ test("middleware can modify the response", async () => {
50
+ const pipeline = new MiddlewarePipeline();
51
+ pipeline.use(async (ctx, next) => {
52
+ const res = await next();
53
+ const patched = new Response(res.body, res);
54
+ patched.headers.set("X-Test", "injected");
55
+ return patched;
56
+ });
57
+ const res = await pipeline.run(makeCtx(), ok200);
58
+ expect(res.headers.get("X-Test")).toBe("injected");
59
+ });
60
+
61
+ test("use() is chainable", () => {
62
+ const pipeline = new MiddlewarePipeline();
63
+ const ret = pipeline.use(async (_, next) => next());
64
+ expect(ret).toBe(pipeline);
65
+ });
66
+
67
+ test("multiple pipelines are independent", async () => {
68
+ const p1 = new MiddlewarePipeline();
69
+ const p2 = new MiddlewarePipeline();
70
+ p1.use(async () => new Response("p1", { status: 201 }));
71
+ const r1 = await p1.run(makeCtx(), ok200);
72
+ const r2 = await p2.run(makeCtx(), ok200);
73
+ expect(r1.status).toBe(201);
74
+ expect(r2.status).toBe(200);
75
+ });
76
+ });
77
+
78
+ // ── cors() ─────────────────────────────────────────────────────────────────
79
+
80
+ describe("cors()", () => {
81
+ test("adds CORS headers to regular requests for allowed origin", async () => {
82
+ const mw = cors({ origin: "https://example.com" });
83
+ const ctx = makeCtx({ request: new Request("http://localhost/", { headers: { Origin: "https://example.com" } }) });
84
+ const pipeline = new MiddlewarePipeline();
85
+ pipeline.use(mw);
86
+ const res = await pipeline.run(ctx, ok200);
87
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("https://example.com");
88
+ });
89
+
90
+ test("wildcard origin allows any origin", async () => {
91
+ const mw = cors({ origin: "*" });
92
+ const ctx = makeCtx({ request: new Request("http://localhost/", { headers: { Origin: "https://any.com" } }) });
93
+ const pipeline = new MiddlewarePipeline();
94
+ pipeline.use(mw);
95
+ const res = await pipeline.run(ctx, ok200);
96
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBeTruthy();
97
+ });
98
+
99
+ test("responds 204 to OPTIONS preflight", async () => {
100
+ const mw = cors({ origin: "*" });
101
+ const ctx = makeCtx({ request: new Request("http://localhost/", { method: "OPTIONS" }) });
102
+ const pipeline = new MiddlewarePipeline();
103
+ pipeline.use(mw);
104
+ const res = await pipeline.run(ctx, ok200);
105
+ expect(res.status).toBe(204);
106
+ });
107
+
108
+ test("does not set CORS header for disallowed origin", async () => {
109
+ const mw = cors({ origin: "https://allowed.com" });
110
+ const ctx = makeCtx({ request: new Request("http://localhost/", { headers: { Origin: "https://evil.com" } }) });
111
+ const pipeline = new MiddlewarePipeline();
112
+ pipeline.use(mw);
113
+ const res = await pipeline.run(ctx, ok200);
114
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
115
+ });
116
+
117
+ test("accepts array of origins", async () => {
118
+ const mw = cors({ origin: ["https://a.com", "https://b.com"] });
119
+ const ctx = makeCtx({ request: new Request("http://localhost/", { headers: { Origin: "https://b.com" } }) });
120
+ const pipeline = new MiddlewarePipeline();
121
+ pipeline.use(mw);
122
+ const res = await pipeline.run(ctx, ok200);
123
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("https://b.com");
124
+ });
125
+
126
+ test("sets Access-Control-Allow-Methods header", async () => {
127
+ const mw = cors({ origin: "*", methods: ["GET", "POST"] });
128
+ const ctx = makeCtx({ request: new Request("http://localhost/", { method: "OPTIONS" }) });
129
+ const pipeline = new MiddlewarePipeline();
130
+ pipeline.use(mw);
131
+ const res = await pipeline.run(ctx, ok200);
132
+ expect(res.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST");
133
+ });
134
+ });
135
+
136
+ // ── authGuard() ───────────────────────────────────────────────────────────
137
+
138
+ describe("authGuard()", () => {
139
+ function makeSession(user: unknown) {
140
+ return {
141
+ getSession: async () => ({ get: (key: string) => (key === "user" ? user : undefined) }),
142
+ };
143
+ }
144
+
145
+ test("sets ctx.context.user from session", async () => {
146
+ const mw = authGuard({ session: makeSession({ id: 1 }) });
147
+ const ctx = makeCtx();
148
+ const pipeline = new MiddlewarePipeline();
149
+ pipeline.use(mw);
150
+ await pipeline.run(ctx, ok200);
151
+ expect(ctx.context.user).toEqual({ id: 1 });
152
+ });
153
+
154
+ test("sets ctx.context.user to null when no session user", async () => {
155
+ const mw = authGuard({ session: makeSession(undefined) });
156
+ const ctx = makeCtx();
157
+ const pipeline = new MiddlewarePipeline();
158
+ pipeline.use(mw);
159
+ await pipeline.run(ctx, ok200);
160
+ expect(ctx.context.user).toBeNull();
161
+ });
162
+
163
+ test("required=true returns 401 when no user", async () => {
164
+ const mw = authGuard({ session: makeSession(undefined), required: true });
165
+ const pipeline = new MiddlewarePipeline();
166
+ pipeline.use(mw);
167
+ const res = await pipeline.run(makeCtx(), ok200);
168
+ expect(res.status).toBe(401);
169
+ const body = await res.json() as { error: string };
170
+ expect(body.error).toBe("Unauthorized");
171
+ });
172
+
173
+ test("required=true allows request when user is present", async () => {
174
+ const mw = authGuard({ session: makeSession({ id: 99 }), required: true });
175
+ const pipeline = new MiddlewarePipeline();
176
+ pipeline.use(mw);
177
+ const res = await pipeline.run(makeCtx(), ok200);
178
+ expect(res.status).toBe(200);
179
+ });
180
+
181
+ test("required=false (default) allows unauthenticated request", async () => {
182
+ const mw = authGuard({ session: makeSession(undefined), required: false });
183
+ const pipeline = new MiddlewarePipeline();
184
+ pipeline.use(mw);
185
+ const res = await pipeline.run(makeCtx(), ok200);
186
+ expect(res.status).toBe(200);
187
+ });
188
+ });
189
+
190
+ // ── requestLogger() ───────────────────────────────────────────────────────
191
+
192
+ describe("requestLogger()", () => {
193
+ test("passes the response through unchanged", async () => {
194
+ const mw = requestLogger();
195
+ const pipeline = new MiddlewarePipeline();
196
+ pipeline.use(mw);
197
+ const res = await pipeline.run(makeCtx(), ok200);
198
+ expect(res.status).toBe(200);
199
+ expect(await res.text()).toBe("ok");
200
+ });
201
+
202
+ test("logs path and status to console (smoke test)", async () => {
203
+ const logs: string[] = [];
204
+ const original = console.log;
205
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
206
+ const mw = requestLogger();
207
+ const ctx = makeCtx({ request: new Request("http://localhost/hello") });
208
+ const pipeline = new MiddlewarePipeline();
209
+ pipeline.use(mw);
210
+ await pipeline.run(ctx, ok200);
211
+ console.log = original;
212
+ expect(logs.length).toBeGreaterThan(0);
213
+ expect(logs[0]).toContain("/hello");
214
+ expect(logs[0]).toContain("200");
215
+ });
216
+ });
@@ -0,0 +1,106 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { redirect, json, error } from "../server/response.ts";
3
+
4
+ describe("redirect", () => {
5
+ test("returns 302 by default with Location header", () => {
6
+ const res = redirect("/dashboard");
7
+ expect(res.status).toBe(302);
8
+ expect(res.headers.get("Location")).toBe("/dashboard");
9
+ });
10
+
11
+ test("accepts custom status codes (301, 307, 308)", () => {
12
+ expect(redirect("/old", 301).status).toBe(301);
13
+ expect(redirect("/tmp", 307).status).toBe(307);
14
+ expect(redirect("/perm", 308).status).toBe(308);
15
+ });
16
+
17
+ test("body is null", async () => {
18
+ const res = redirect("/");
19
+ expect(await res.text()).toBe("");
20
+ });
21
+
22
+ describe("open-redirect guard", () => {
23
+ test("rejects absolute http(s) URLs", () => {
24
+ expect(() => redirect("https://evil.com/")).toThrow();
25
+ expect(() => redirect("http://evil.com")).toThrow();
26
+ });
27
+
28
+ test("rejects protocol-relative URLs (//evil.com)", () => {
29
+ expect(() => redirect("//evil.com/path")).toThrow();
30
+ });
31
+
32
+ test("rejects backslash protocol-relative variant", () => {
33
+ expect(() => redirect("/\\evil.com")).toThrow();
34
+ });
35
+
36
+ test("rejects javascript: and data: schemes", () => {
37
+ expect(() => redirect("javascript:alert(1)")).toThrow();
38
+ expect(() => redirect("data:text/html,x")).toThrow();
39
+ });
40
+
41
+ test("allows same-path redirects", () => {
42
+ expect(redirect("/foo").headers.get("Location")).toBe("/foo");
43
+ expect(redirect("/foo?q=1#x").headers.get("Location")).toBe("/foo?q=1#x");
44
+ });
45
+
46
+ test("opt-in: allowExternal lets through absolute URL", () => {
47
+ const res = redirect("https://allowed.example/path", 302, undefined, { allowExternal: true });
48
+ expect(res.headers.get("Location")).toBe("https://allowed.example/path");
49
+ });
50
+ });
51
+ });
52
+
53
+ describe("json", () => {
54
+ test("sets Content-Type to application/json", () => {
55
+ const res = json({ ok: true });
56
+ expect(res.headers.get("Content-Type")).toContain("application/json");
57
+ });
58
+
59
+ test("serializes data to JSON body", async () => {
60
+ const res = json({ name: "bract", version: 1 });
61
+ const body = await res.json() as { name: string; version: number };
62
+ expect(body.name).toBe("bract");
63
+ expect(body.version).toBe(1);
64
+ });
65
+
66
+ test("defaults to 200 status", () => {
67
+ expect(json({}).status).toBe(200);
68
+ });
69
+
70
+ test("respects custom status in init", () => {
71
+ expect(json({}, { status: 201 }).status).toBe(201);
72
+ });
73
+
74
+ test("merges custom headers while preserving Content-Type", () => {
75
+ const res = json({}, { headers: { "X-Custom": "yes" } });
76
+ expect(res.headers.get("X-Custom")).toBe("yes");
77
+ expect(res.headers.get("Content-Type")).toContain("application/json");
78
+ });
79
+
80
+ test("serializes arrays", async () => {
81
+ const res = json([1, 2, 3]);
82
+ const body = await res.json() as number[];
83
+ expect(body).toEqual([1, 2, 3]);
84
+ });
85
+
86
+ test("serializes null", async () => {
87
+ const res = json(null);
88
+ expect(await res.text()).toBe("null");
89
+ });
90
+ });
91
+
92
+ describe("error", () => {
93
+ test("returns 500 by default", () => {
94
+ expect(error("internal").status).toBe(500);
95
+ });
96
+
97
+ test("body is JSON with error field", async () => {
98
+ const res = error("something failed");
99
+ const body = await res.json() as { error: string };
100
+ expect(body.error).toBe("something failed");
101
+ });
102
+
103
+ test("accepts custom status", () => {
104
+ expect(error("not allowed", 403).status).toBe(403);
105
+ });
106
+ });