@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
@@ -1,5 +1,5 @@
1
1
  import { join } from "node:path";
2
- import { mkdir } from "node:fs/promises";
2
+ import { mkdir, rename, unlink } from "node:fs/promises";
3
3
  import type { ImageTransformParams, TransformResult, ImageFormat } from "./types.ts";
4
4
 
5
5
  const MAX_MEM = 200;
@@ -50,10 +50,14 @@ export async function getFromDisk(
50
50
  const key = await cacheKey(src, params);
51
51
  const metaFile = Bun.file(join(dir, `${key}.json`));
52
52
  const dataFile = Bun.file(join(dir, `${key}.bin`));
53
- if (!(await metaFile.exists()) || !(await dataFile.exists())) return null;
53
+ // No existence pre-check: it would create a TOCTOU race where the file is
54
+ // deleted between exists() and read(). Just attempt the reads and let either
55
+ // a missing file or invalid JSON fall through to MISS.
54
56
  try {
55
- const meta = await metaFile.json() as { contentType: string; format: ImageFormat };
56
- const data = await dataFile.arrayBuffer();
57
+ const [meta, data] = await Promise.all([
58
+ metaFile.json() as Promise<{ contentType: string; format: ImageFormat }>,
59
+ dataFile.arrayBuffer(),
60
+ ]);
57
61
  return { data, contentType: meta.contentType, format: meta.format };
58
62
  } catch {
59
63
  return null;
@@ -68,8 +72,24 @@ export async function setOnDisk(
68
72
  ): Promise<void> {
69
73
  await mkdir(dir, { recursive: true });
70
74
  const key = await cacheKey(src, params);
71
- await Promise.all([
72
- Bun.write(join(dir, `${key}.json`), JSON.stringify({ contentType: result.contentType, format: result.format })),
73
- Bun.write(join(dir, `${key}.bin`), result.data),
74
- ]);
75
+ const jsonFinal = join(dir, `${key}.json`);
76
+ const binFinal = join(dir, `${key}.bin`);
77
+ const jsonTmp = `${jsonFinal}.tmp`;
78
+ const binTmp = `${binFinal}.tmp`;
79
+ // Write both temp files, then atomically rename. Readers see either both
80
+ // files present or neither — never a half-written pair.
81
+ try {
82
+ await Promise.all([
83
+ Bun.write(jsonTmp, JSON.stringify({ contentType: result.contentType, format: result.format })),
84
+ Bun.write(binTmp, result.data),
85
+ ]);
86
+ await Promise.all([rename(jsonTmp, jsonFinal), rename(binTmp, binFinal)]);
87
+ } catch (err) {
88
+ // Best-effort cleanup so failed writes don't leak .tmp files indefinitely.
89
+ await Promise.all([
90
+ unlink(jsonTmp).catch(() => {}),
91
+ unlink(binTmp).catch(() => {}),
92
+ ]);
93
+ throw err;
94
+ }
75
95
  }
@@ -1,36 +1,51 @@
1
- import { join, resolve } from "node:path";
1
+ import { join, resolve, sep } from "node:path";
2
+ import { realpath } from "node:fs/promises";
2
3
  import type { ImageTransformParams, ImageFormat, ImageFit } from "./types.ts";
3
- import { QUALITY_DEFAULT, FORMAT_DEFAULT, FIT_DEFAULT, MIME } from "./types.ts";
4
+ import { QUALITY_DEFAULT, FORMAT_DEFAULT, FIT_DEFAULT, MIME, ALLOWED_FITS } from "./types.ts";
4
5
  import { transformImage } from "./optimizer.ts";
5
6
  import { getFromMemory, setInMemory, getFromDisk, setOnDisk } from "./cache.ts";
6
7
 
7
- const MAX_DIM = 4096;
8
+ const ALLOWED_DIMS = new Set([320, 640, 768, 1024, 1280, 1536, 1920, 3840]);
9
+ const MAX_AREA = 4_000_000;
8
10
  const CACHE_CTRL = "public, max-age=31536000, immutable";
9
11
 
10
- function parseParams(
12
+ async function parseParams(
11
13
  sp: URLSearchParams,
12
14
  publicDir: string,
13
- ): { src: string; filePath: string; params: ImageTransformParams } | null {
15
+ ): Promise<{ src: string; filePath: string; params: ImageTransformParams } | null> {
14
16
  const src = sp.get("src");
15
17
  // src must be a /public/ path with no traversal sequences
16
18
  if (!src || !src.startsWith("/public/") || src.includes("..")) return null;
17
19
 
18
20
  const rel = src.slice("/public/".length);
19
21
  const root = resolve(publicDir);
20
- const filePath = resolve(join(root, rel));
21
- if (!filePath.startsWith(root + "/") && filePath !== root) return null;
22
+ const candidate = resolve(join(root, rel));
23
+ if (!candidate.startsWith(root + sep) && candidate !== root) return null;
24
+ // Re-check after symlink resolution. If the file doesn't exist yet, realpath
25
+ // throws — fall through and let the existence check below handle it.
26
+ let filePath = candidate;
27
+ try {
28
+ const real = await realpath(candidate);
29
+ if (!real.startsWith(root + sep) && real !== root) return null;
30
+ filePath = real;
31
+ } catch {
32
+ // missing file: defer to Bun.file(...).exists() below
33
+ }
22
34
 
23
35
  const wRaw = sp.get("w");
24
36
  const hRaw = sp.get("h");
25
37
  const w = wRaw ? parseInt(wRaw, 10) : undefined;
26
38
  const h = hRaw ? parseInt(hRaw, 10) : undefined;
27
- if (w !== undefined && (isNaN(w) || w < 1 || w > MAX_DIM)) return null;
28
- if (h !== undefined && (isNaN(h) || h < 1 || h > MAX_DIM)) return null;
39
+ if (w !== undefined && (isNaN(w) || !ALLOWED_DIMS.has(w))) return null;
40
+ if (h !== undefined && (isNaN(h) || !ALLOWED_DIMS.has(h))) return null;
41
+ if (w !== undefined && h !== undefined && w * h > MAX_AREA) return null;
29
42
 
30
43
  const q = Math.min(100, Math.max(1, parseInt(sp.get("q") ?? String(QUALITY_DEFAULT), 10)));
31
44
  const fmt = (sp.get("format") ?? FORMAT_DEFAULT) as ImageFormat;
32
- const fit = (sp.get("fit") ?? FIT_DEFAULT) as ImageFit;
45
+ const fitRaw = sp.get("fit") ?? FIT_DEFAULT;
33
46
  if (!MIME[fmt]) return null;
47
+ if (!ALLOWED_FITS.has(fitRaw as ImageFit)) return null;
48
+ const fit = fitRaw as ImageFit;
34
49
 
35
50
  return { src, filePath, params: { w, h, q, format: fmt, fit } };
36
51
  }
@@ -53,7 +68,7 @@ export async function handleImageRequest(
53
68
  const url = new URL(request.url);
54
69
  if (url.pathname !== "/_image") return null;
55
70
 
56
- const parsed = parseParams(url.searchParams, publicDir);
71
+ const parsed = await parseParams(url.searchParams, publicDir);
57
72
  if (!parsed) return new Response("Bad Request", { status: 400 });
58
73
 
59
74
  const { src, filePath, params } = parsed;
@@ -1,6 +1,30 @@
1
1
  import type { ImageTransformParams, TransformResult, ImageFormat } from "./types.ts";
2
2
  import { MIME } from "./types.ts";
3
3
 
4
+ // In-process semaphore (DoS guard): cap concurrent ImageMagick spawns so a
5
+ // burst of /_image requests can't fork-bomb the server.
6
+ const MAX_CONCURRENT = 4;
7
+ // Per-spawn timeout (ms). A pathological input must not hold a slot forever;
8
+ // without this, four hung spawns wedge the whole image pipeline.
9
+ const SPAWN_TIMEOUT_MS = 15_000;
10
+ let inFlight = 0;
11
+ const waiters: Array<() => void> = [];
12
+
13
+ async function acquireSlot(): Promise<void> {
14
+ if (inFlight < MAX_CONCURRENT) {
15
+ inFlight++;
16
+ return;
17
+ }
18
+ await new Promise<void>((resolve) => waiters.push(resolve));
19
+ inFlight++;
20
+ }
21
+
22
+ function releaseSlot(): void {
23
+ inFlight--;
24
+ const next = waiters.shift();
25
+ if (next) next();
26
+ }
27
+
4
28
  // Probe for an available ImageMagick binary once, then cache the result.
5
29
  let _binary: string | null | undefined;
6
30
 
@@ -57,20 +81,28 @@ export async function transformImage(
57
81
  return { data, contentType: MIME[fmt] ?? "image/jpeg", format: fmt };
58
82
  }
59
83
 
60
- const proc = Bun.spawn(buildArgs(binary, filePath, params), {
61
- stdout: "pipe",
62
- stderr: "pipe",
63
- });
84
+ await acquireSlot();
85
+ try {
86
+ const proc = Bun.spawn(buildArgs(binary, filePath, params), {
87
+ stdout: "pipe",
88
+ stderr: "ignore",
89
+ timeout: SPAWN_TIMEOUT_MS,
90
+ killSignal: "SIGKILL",
91
+ });
64
92
 
65
- const [data, , exitCode] = await Promise.all([
66
- new Response(proc.stdout!).arrayBuffer(),
67
- new Response(proc.stderr!).arrayBuffer(),
68
- proc.exited,
69
- ]);
93
+ const [data, exitCode] = await Promise.all([
94
+ new Response(proc.stdout!).arrayBuffer(),
95
+ proc.exited,
96
+ ]);
70
97
 
71
- if (exitCode !== 0) {
72
- throw new Error(`[bractjs] ImageMagick exited ${exitCode} for ${filePath}`);
73
- }
98
+ if (exitCode !== 0) {
99
+ // Non-zero exit covers normal failures AND timeout-induced SIGKILL,
100
+ // since Bun reports the signal as a non-zero exit code.
101
+ throw new Error(`[bractjs] ImageMagick exited ${exitCode} for ${filePath}`);
102
+ }
74
103
 
75
- return { data, contentType: MIME[params.format], format: params.format };
104
+ return { data, contentType: MIME[params.format], format: params.format };
105
+ } finally {
106
+ releaseSlot();
107
+ }
76
108
  }
@@ -25,3 +25,4 @@ export const MIME: Record<ImageFormat, string> = {
25
25
  jpeg: "image/jpeg",
26
26
  png: "image/png",
27
27
  };
28
+ export const ALLOWED_FITS: ReadonlySet<ImageFit> = new Set(["cover", "contain", "fill"]);
@@ -3,25 +3,39 @@ import type { MiddlewareFn } from "../server/middleware.ts";
3
3
  export interface CorsOptions {
4
4
  origin: string | string[];
5
5
  methods?: string[];
6
+ credentials?: boolean;
6
7
  }
7
8
 
8
9
  /**
9
10
  * Sets CORS headers. Handles OPTIONS preflight with 204.
11
+ *
12
+ * Never reflects the Origin header when "*" is configured — emits literal "*".
13
+ * Refuses to combine credentials:true with "*" (browsers reject it anyway).
14
+ * Always sets `Vary: Origin` so caches don't serve a cross-origin response to
15
+ * the wrong site.
10
16
  */
11
17
  export function cors(options: CorsOptions): MiddlewareFn {
12
- const allowedOrigins = Array.isArray(options.origin)
13
- ? options.origin
14
- : [options.origin];
18
+ const allowedOrigins = Array.isArray(options.origin) ? options.origin : [options.origin];
15
19
  const allowedMethods = options.methods?.join(", ") ?? "GET, POST, PUT, DELETE, PATCH, OPTIONS";
20
+ const wildcard = allowedOrigins.includes("*");
21
+ const credentials = options.credentials === true;
22
+ if (wildcard && credentials) {
23
+ throw new Error("cors: credentials=true cannot be combined with origin='*'");
24
+ }
16
25
 
17
26
  return async (ctx, next) => {
18
27
  const origin = ctx.request.headers.get("Origin") ?? "";
19
- const allowed = allowedOrigins.includes("*") || allowedOrigins.includes(origin);
20
28
  const corsHeaders: Record<string, string> = {
21
29
  "Access-Control-Allow-Methods": allowedMethods,
22
30
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
31
+ "Vary": "Origin",
23
32
  };
24
- if (allowed) corsHeaders["Access-Control-Allow-Origin"] = origin || "*";
33
+ if (wildcard) {
34
+ corsHeaders["Access-Control-Allow-Origin"] = "*";
35
+ } else if (origin && allowedOrigins.includes(origin)) {
36
+ corsHeaders["Access-Control-Allow-Origin"] = origin;
37
+ }
38
+ if (credentials) corsHeaders["Access-Control-Allow-Credentials"] = "true";
25
39
 
26
40
  // Preflight
27
41
  if (ctx.request.method === "OPTIONS") {
@@ -29,8 +43,10 @@ export function cors(options: CorsOptions): MiddlewareFn {
29
43
  }
30
44
 
31
45
  const response = await next();
32
- const patched = new Response(response.body, response);
33
- for (const [k, v] of Object.entries(corsHeaders)) patched.headers.set(k, v);
34
- return patched;
46
+ // Mutate headers in place rather than wrapping body. Wrapping with
47
+ // `new Response(response.body, response)` makes the original Response
48
+ // unusable to anyone holding a reference (single-shot stream).
49
+ for (const [k, v] of Object.entries(corsHeaders)) response.headers.set(k, v);
50
+ return response;
35
51
  };
36
52
  }
@@ -1,10 +1,25 @@
1
1
  import { resolveAction } from "./action-registry.ts";
2
2
  import { json } from "./response.ts";
3
+ import { isAllowedMutation } from "./csrf.ts";
4
+
5
+ const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
6
+ // Cap action JSON bodies. Anything over this looks like an abuse attempt;
7
+ // FormData uploads (large files) take the multipart branch and bypass this.
8
+ const MAX_JSON_BODY_BYTES = 1_048_576; // 1 MiB
9
+
10
+ function hasForbiddenKey(value: unknown): boolean {
11
+ if (!value || typeof value !== "object") return false;
12
+ for (const key of Object.keys(value as Record<string, unknown>)) {
13
+ if (FORBIDDEN_KEYS.has(key)) return true;
14
+ }
15
+ return false;
16
+ }
3
17
 
4
18
  export async function handleActionRequest(request: Request): Promise<Response | null> {
5
19
  const url = new URL(request.url);
6
20
  if (!url.pathname.startsWith("/_action")) return null;
7
21
  if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
22
+ if (!isAllowedMutation(request)) return new Response("Forbidden", { status: 403 });
8
23
 
9
24
  const id = url.searchParams.get("id");
10
25
  if (!id) return new Response("Bad Request: missing action id", { status: 400 });
@@ -18,8 +33,32 @@ export async function handleActionRequest(request: Request): Promise<Response |
18
33
  if (ct.includes("multipart/form-data") || ct.includes("application/x-www-form-urlencoded")) {
19
34
  args = [await request.formData()];
20
35
  } else {
36
+ // Cheap pre-check: trust Content-Length if the client sent one.
37
+ const clRaw = request.headers.get("Content-Length");
38
+ if (clRaw) {
39
+ const cl = Number(clRaw);
40
+ if (Number.isFinite(cl) && cl > MAX_JSON_BODY_BYTES) {
41
+ return new Response("Payload Too Large", { status: 413 });
42
+ }
43
+ }
21
44
  const text = await request.text();
22
- args = text ? JSON.parse(text) as unknown[] : [];
45
+ // Defense in depth: clients can lie about Content-Length, so verify the
46
+ // actual decoded text length too.
47
+ if (text.length > MAX_JSON_BODY_BYTES) {
48
+ return new Response("Payload Too Large", { status: 413 });
49
+ }
50
+ if (!text) {
51
+ args = [];
52
+ } else {
53
+ const parsed: unknown = JSON.parse(text);
54
+ if (!Array.isArray(parsed)) {
55
+ return new Response("Bad Request: args must be array", { status: 400 });
56
+ }
57
+ if (parsed.some(hasForbiddenKey)) {
58
+ return new Response("Bad Request: forbidden keys", { status: 400 });
59
+ }
60
+ args = parsed;
61
+ }
23
62
  }
24
63
  } catch {
25
64
  return new Response("Bad Request: invalid body", { status: 400 });
@@ -1,6 +1,9 @@
1
1
  import { join } from "node:path";
2
2
 
3
- const SERVER_RE = /^["']use server["']/m;
3
+ // Anchored at start-of-file. Allow whitespace and line/block comments before
4
+ // the "use server" string literal. This prevents false matches from a "use
5
+ // server" found inside template literals or runtime strings.
6
+ const SERVER_RE = /^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*["']use server["']/;
4
7
  const registry = new Map<string, (...args: unknown[]) => Promise<unknown>>();
5
8
 
6
9
  async function computeId(filePath: string, name: string): Promise<string> {
@@ -16,9 +19,19 @@ export function resolveAction(id: string): ((...args: unknown[]) => Promise<unkn
16
19
  return registry.get(id) ?? null;
17
20
  }
18
21
 
22
+ function isEligible(rel: string): boolean {
23
+ return (
24
+ rel.endsWith(".server.ts") ||
25
+ rel.endsWith(".server.tsx") ||
26
+ rel.startsWith("routes/") ||
27
+ rel.startsWith("routes\\")
28
+ );
29
+ }
30
+
19
31
  export async function loadServerActions(appDir: string): Promise<void> {
20
32
  const glob = new Bun.Glob("**/*.{ts,tsx}");
21
33
  for await (const rel of glob.scan(appDir)) {
34
+ if (!isEligible(rel)) continue;
22
35
  const filePath = join(appDir, rel);
23
36
  let src: string;
24
37
  try { src = await Bun.file(filePath).text(); } catch { continue; }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Cross-origin POST/PUT/DELETE/PATCH protection.
3
+ * Allow when: request carries X-BractJS-Action header (client-issued, blocked
4
+ * cross-origin by CORS for non-simple requests), OR the Origin header matches
5
+ * the request URL's origin.
6
+ */
7
+ export function isAllowedMutation(request: Request): boolean {
8
+ if (request.headers.get("X-BractJS-Action")) return true;
9
+ const origin = request.headers.get("Origin");
10
+ if (!origin) return false;
11
+ try {
12
+ return new URL(origin).origin === new URL(request.url).origin;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
package/src/server/env.ts CHANGED
@@ -10,6 +10,11 @@ export function requireEnv(key: string): string {
10
10
  return value;
11
11
  }
12
12
 
13
+ // Build LS/PS at runtime so the source contains no raw U+2028/U+2029
14
+ // (which would break JS parsing as LineTerminators).
15
+ const LS = String.fromCharCode(0x2028);
16
+ const PS = String.fromCharCode(0x2029);
17
+
13
18
  export function safeStringify(data: unknown): string {
14
19
  const seen = new WeakSet();
15
20
  const json = JSON.stringify(data, (_key, value) => {
@@ -19,11 +24,12 @@ export function safeStringify(data: unknown): string {
19
24
  }
20
25
  return value;
21
26
  });
22
- // Escape HTML-sensitive characters so this JSON is safe to embed inside a
23
- // <script> tag. \u003c / \u003e / \u0026 are valid JSON unicode escapes —
24
- // JSON.parse on the client decodes them transparently.
27
+ // Escape HTML-sensitive chars + JS LineTerminators (U+2028 / U+2029) so this
28
+ // JSON is safe to embed inside a <script> tag.
25
29
  return json
26
30
  .replace(/</g, "\\u003c")
27
31
  .replace(/>/g, "\\u003e")
28
- .replace(/&/g, "\\u0026");
32
+ .replace(/&/g, "\\u0026")
33
+ .replaceAll(LS, "\\u2028")
34
+ .replaceAll(PS, "\\u2029");
29
35
  }
@@ -30,16 +30,20 @@ export class MiddlewarePipeline {
30
30
  ctx: MiddlewareContext,
31
31
  handler: () => Promise<Response>,
32
32
  ): Promise<Response> {
33
- let index = 0;
34
33
  const fns = this.fns;
35
-
36
- const dispatch = (): Promise<Response> => {
37
- if (index >= fns.length) return handler();
38
- const fn = fns[index++];
39
- return fn(ctx, dispatch);
34
+ let lastCalled = -1;
35
+
36
+ const dispatch = (i: number): Promise<Response> => {
37
+ if (i <= lastCalled) {
38
+ return Promise.reject(new Error("middleware: next() called more than once"));
39
+ }
40
+ lastCalled = i;
41
+ if (i >= fns.length) return handler();
42
+ const fn = fns[i];
43
+ return fn(ctx, () => dispatch(i + 1));
40
44
  };
41
45
 
42
- return dispatch();
46
+ return dispatch(0);
43
47
  }
44
48
  }
45
49
 
@@ -35,12 +35,14 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
35
35
  status = 200,
36
36
  } = options;
37
37
 
38
- const devOverlay = isDev() ? errorOverlayScript + "\n" : "";
39
- const metaHtml = renderMetaTags(mergeMeta(options.meta ?? []));
40
- // Include manifest + routeFile so the client can pre-import the route module
41
- // before hydrateRoot(), preventing the SSR/client tree mismatch.
38
+ const devFlag = isDev() ? "window.__BRACT_DEV__=true;" : "";
39
+ const devOverlay = isDev() ? devFlag + errorOverlayScript + "\n" : "";
40
+ const mergedMeta = mergeMeta(options.meta ?? []);
41
+ // metaHtml is injected into <head> via React (the renderToReadableStream tree
42
+ // is expected to use it). The merged descriptor array is what the client
43
+ // reads — keep it shaped, not stringified HTML.
42
44
  const bootstrapScriptContent =
43
- devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, manifest, routeFile: options.routeFile, meta: metaHtml })};`;
45
+ devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, manifest, routeFile: options.routeFile, meta: mergedMeta })};`;
44
46
 
45
47
  let renderError: unknown;
46
48
 
@@ -1,4 +1,3 @@
1
- import { join } from "node:path";
2
1
  import { createElement } from "react";
3
2
  import type { TrieNode } from "./matcher.ts";
4
3
  import { matchRoute } from "./matcher.ts";
@@ -7,9 +6,11 @@ import { runLoaders, runAction, buildLoaderArgs } from "./loader.ts";
7
6
  import { renderRoute, type ServerManifest } from "./render.ts";
8
7
  import { resolveMeta } from "./meta.ts";
9
8
  import { json, error } from "./response.ts";
10
- import { isRedirect } from "../shared/errors.ts";
9
+ import { isRedirect, isHttpError } from "../shared/errors.ts";
10
+ import { isDev } from "./env.ts";
11
11
  import { pipeline, type MiddlewareContext } from "./middleware.ts";
12
12
  import { BractJSProvider } from "../shared/context.ts";
13
+ import { isAllowedMutation } from "./csrf.ts";
13
14
 
14
15
  export interface HandlerConfig {
15
16
  appDir: string;
@@ -39,17 +40,10 @@ async function route(
39
40
  config: HandlerConfig,
40
41
  context: Record<string, unknown>,
41
42
  ): Promise<Response> {
42
- const { appDir, publicDir, manifest } = config;
43
+ const { appDir, manifest } = config;
43
44
  const url = new URL(request.url);
44
45
  const { pathname, searchParams } = url;
45
46
 
46
- // ── Static public assets ──────────────────────────────────────────────
47
- if (pathname.startsWith("/public/")) {
48
- const file = Bun.file(join(publicDir, pathname.slice("/public/".length)));
49
- if (await file.exists()) return new Response(file);
50
- return error("Not Found", 404);
51
- }
52
-
53
47
  // ── /_data soft-nav JSON endpoint ─────────────────────────────────────
54
48
  if (pathname.startsWith("/_data")) {
55
49
  const targetPath = searchParams.get("path") ?? "/";
@@ -77,12 +71,17 @@ async function route(
77
71
  // ── Action (mutating methods) ─────────────────────────────────────────
78
72
  let actionData: unknown = null;
79
73
  if (MUTATING_METHODS.has(request.method)) {
74
+ if (!isAllowedMutation(request)) return error("Forbidden", 403);
80
75
  try {
81
- const formData = await request.formData();
76
+ const ct = request.headers.get("Content-Type") ?? "";
77
+ const isFormLike = ct.includes("multipart/form-data") || ct.includes("application/x-www-form-urlencoded");
78
+ const formData = isFormLike ? await request.formData() : new FormData();
82
79
  actionData = await runAction(chain.route, { ...args, formData });
83
80
  } catch (err) {
84
81
  if (isRedirect(err)) return err as Response;
85
- throw err;
82
+ if (isHttpError(err)) return error(err.message, err.status);
83
+ if (isDev()) return error(err instanceof Error ? err.message : String(err), 500);
84
+ return error("Internal Server Error", 500);
86
85
  }
87
86
 
88
87
  // Client-side Form submits with this header — return JSON, not HTML.
@@ -97,7 +96,9 @@ async function route(
97
96
  loaderResults = await runLoaders(chain, args);
98
97
  } catch (err) {
99
98
  if (isRedirect(err)) return err as Response;
100
- throw err;
99
+ if (isHttpError(err)) return error(err.message, err.status);
100
+ if (isDev()) return error(err instanceof Error ? err.message : String(err), 500);
101
+ return error("Internal Server Error", 500);
101
102
  }
102
103
 
103
104
  const loaderData = {
@@ -1,8 +1,32 @@
1
- export function redirect(url: string, status: number = 302): Response {
2
- return new Response(null, {
3
- status,
4
- headers: { Location: url },
5
- });
1
+ export interface RedirectOptions {
2
+ /** Allow absolute URLs to other origins. Default false. */
3
+ allowExternal?: boolean;
4
+ }
5
+
6
+ function isSafeInternalRedirect(url: string): boolean {
7
+ // Must be path-only: single leading "/" not followed by "/" or "\".
8
+ // Rejects: "//evil.com", "/\\evil.com", "https://...", "javascript:...", "".
9
+ if (url.length === 0) return false;
10
+ if (url[0] !== "/") return false;
11
+ if (url[1] === "/" || url[1] === "\\") return false;
12
+ return true;
13
+ }
14
+
15
+ export function redirect(
16
+ url: string,
17
+ status: number = 302,
18
+ headers?: HeadersInit,
19
+ options?: RedirectOptions,
20
+ ): Response {
21
+ if (!options?.allowExternal && !isSafeInternalRedirect(url)) {
22
+ throw new Error(
23
+ `[bractjs] redirect: unsafe Location "${url}". ` +
24
+ `Pass { allowExternal: true } to redirect off-origin.`,
25
+ );
26
+ }
27
+ const h = new Headers(headers);
28
+ h.set("Location", url);
29
+ return new Response(null, { status, headers: h });
6
30
  }
7
31
 
8
32
  export function json<T>(data: T, init?: ResponseInit): Response {
@@ -1,3 +1,5 @@
1
+ import { basename } from "node:path";
2
+
1
3
  // ── Types ──────────────────────────────────────────────────────────────────
2
4
 
3
5
  export type Segment = string | { param: string } | { catchAll: string };
@@ -54,8 +56,10 @@ export async function scanRoutes(appDir: string): Promise<RouteFile[]> {
54
56
  const routes: RouteFile[] = [];
55
57
 
56
58
  for await (const filePath of glob.scan(appDir)) {
57
- // Skip layout files — handled separately
58
- if (filePath.endsWith("/layout.tsx") || filePath.endsWith("/layout.ts")) {
59
+ // Skip layout files — handled separately. Use basename so this also
60
+ // skips top-level "routes/layout.tsx" on any OS.
61
+ const base = basename(filePath);
62
+ if (base === "layout.tsx" || base === "layout.ts") {
59
63
  continue;
60
64
  }
61
65
 
@@ -52,15 +52,20 @@ async function sign(data: string, secret: string): Promise<string> {
52
52
  }
53
53
 
54
54
  async function verify(data: string, sig: string, secrets: string[]): Promise<boolean> {
55
+ // Iterate ALL secrets without short-circuit, and do full-length constant-time
56
+ // compare against every candidate to avoid leaking which secret matched (or
57
+ // whether a length mismatch occurred) via timing.
58
+ let ok = false;
55
59
  for (const secret of secrets) {
56
60
  const expected = await sign(data, secret);
57
- if (expected.length === sig.length) {
58
- let diff = 0;
59
- for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
60
- if (diff === 0) return true;
61
+ const len = Math.max(expected.length, sig.length);
62
+ let diff = expected.length ^ sig.length;
63
+ for (let i = 0; i < len; i++) {
64
+ diff |= (expected.charCodeAt(i) || 0) ^ (sig.charCodeAt(i) || 0);
61
65
  }
66
+ if (diff === 0) ok = true;
62
67
  }
63
- return false;
68
+ return ok;
64
69
  }
65
70
 
66
71
  function makeSession(data: SessionData): InternalSession {
@@ -77,6 +82,12 @@ function makeSession(data: SessionData): InternalSession {
77
82
 
78
83
  export function createCookieSession(options: CookieSessionOptions): SessionStorage {
79
84
  const { name, secrets, maxAge, secure = true, sameSite = "Lax" } = options;
85
+ if (!Array.isArray(secrets) || secrets.length === 0) {
86
+ throw new Error("createCookieSession: secrets must be a non-empty array");
87
+ }
88
+ if (!secrets.every((s) => typeof s === "string" && s.length >= 16)) {
89
+ throw new Error("createCookieSession: each secret must be a string of length >= 16");
90
+ }
80
91
 
81
92
  return {
82
93
  async getSession(cookie?: string | null): Promise<Session> {