@bractjs/bractjs 0.1.5 → 0.1.7

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 (66) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/action-handler.test.ts +47 -0
  3. package/src/__tests__/action-registry.test.ts +73 -0
  4. package/src/__tests__/codegen.test.ts +50 -0
  5. package/src/__tests__/deferred.test.ts +96 -0
  6. package/src/__tests__/directives.test.ts +52 -0
  7. package/src/__tests__/env.test.ts +73 -0
  8. package/src/__tests__/errors.test.ts +113 -0
  9. package/src/__tests__/hash.test.ts +19 -0
  10. package/src/__tests__/integration.test.ts +1 -1
  11. package/src/__tests__/loader.test.ts +5 -2
  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/adapters/cloudflare.ts +65 -0
  18. package/src/build/bundler.ts +17 -6
  19. package/src/build/directives.ts +30 -3
  20. package/src/build/env-plugin.ts +8 -0
  21. package/src/build/hash.ts +0 -20
  22. package/src/build/plugins/css-modules.ts +110 -0
  23. package/src/client/ClientRouter.tsx +121 -13
  24. package/src/client/cache.ts +69 -0
  25. package/src/client/components/Link.tsx +16 -2
  26. package/src/client/components/LiveReload.tsx +4 -0
  27. package/src/client/hooks/useBlocker.ts +44 -0
  28. package/src/client/hooks/useFetcher.ts +66 -6
  29. package/src/client/hooks/useLocale.ts +12 -0
  30. package/src/client/hooks/useLocalizedLink.ts +18 -0
  31. package/src/client/hooks/useSearchParams.ts +74 -0
  32. package/src/client/rpc.ts +70 -0
  33. package/src/codegen/route-codegen.ts +96 -10
  34. package/src/dev/devtools.ts +144 -0
  35. package/src/dev/hmr-client.ts +14 -0
  36. package/src/dev/hmr-module-handler.ts +31 -5
  37. package/src/dev/hmr-server.ts +16 -0
  38. package/src/image/cache.ts +28 -8
  39. package/src/image/handler.ts +31 -13
  40. package/src/image/optimizer.ts +51 -14
  41. package/src/image/types.ts +1 -0
  42. package/src/index.ts +27 -0
  43. package/src/middleware/cors.ts +28 -8
  44. package/src/middleware/requestLogger.ts +4 -0
  45. package/src/server/action-handler.ts +45 -2
  46. package/src/server/action-registry.ts +14 -1
  47. package/src/server/adapter.ts +57 -0
  48. package/src/server/api-route.ts +127 -0
  49. package/src/server/context.ts +22 -0
  50. package/src/server/csrf.ts +17 -0
  51. package/src/server/env.ts +26 -4
  52. package/src/server/i18n.ts +63 -0
  53. package/src/server/loader.ts +61 -1
  54. package/src/server/middleware.ts +11 -7
  55. package/src/server/render.ts +14 -5
  56. package/src/server/request-handler.ts +77 -18
  57. package/src/server/response.ts +29 -5
  58. package/src/server/scanner.ts +6 -2
  59. package/src/server/serve.ts +102 -55
  60. package/src/server/session.ts +17 -5
  61. package/src/server/static.ts +31 -8
  62. package/src/server/stream-handler.ts +111 -0
  63. package/src/server/validate.ts +89 -0
  64. package/src/shared/route-types.ts +11 -0
  65. package/types/index.d.ts +94 -1
  66. package/types/route.d.ts +11 -0
@@ -0,0 +1,127 @@
1
+ import { isExplicitDev } from "./env.ts";
2
+
3
+ // ── Types ──────────────────────────────────────────────────────────────────
4
+
5
+ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
6
+
7
+ // SECURITY(high): cap request bodies for typed API routes so a single client
8
+ // cannot exhaust memory. Same 1 MiB ceiling used by /_action JSON.
9
+ const MAX_BODY_BYTES = 1_048_576;
10
+
11
+ export interface ApiRouteDefinition<
12
+ TMethod extends HttpMethod,
13
+ TPath extends string,
14
+ TInput,
15
+ TOutput,
16
+ > {
17
+ method: TMethod;
18
+ path: TPath;
19
+ handler: (input: TInput, request: Request) => TOutput | Promise<TOutput>;
20
+ _types: { input: TInput; output: TOutput };
21
+ }
22
+
23
+ // Collect all registered routes into a union type.
24
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+ const routeRegistry: ApiRouteDefinition<HttpMethod, string, any, any>[] = [];
26
+
27
+ /**
28
+ * Define a typed API route.
29
+ *
30
+ * Usage in app/api/users.ts:
31
+ * export const getUsers = bract.route("GET", "/api/users", async () => db.users.findAll());
32
+ */
33
+ export function route<
34
+ TMethod extends HttpMethod,
35
+ TPath extends string,
36
+ TInput,
37
+ TOutput,
38
+ >(
39
+ method: TMethod,
40
+ path: TPath,
41
+ handler: (input: TInput, request: Request) => TOutput | Promise<TOutput>,
42
+ ): ApiRouteDefinition<TMethod, TPath, TInput, TOutput> {
43
+ const def: ApiRouteDefinition<TMethod, TPath, TInput, TOutput> = {
44
+ method,
45
+ path,
46
+ handler,
47
+ _types: {} as { input: TInput; output: TOutput },
48
+ };
49
+ routeRegistry.push(def);
50
+ return def;
51
+ }
52
+
53
+ // ── Runtime dispatch ───────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Attempt to handle the request by matching against registered API routes.
57
+ * Returns null if no route matches so the caller can fall through.
58
+ */
59
+ export async function handleApiRequest(request: Request): Promise<Response | null> {
60
+ const url = new URL(request.url);
61
+ for (const def of routeRegistry) {
62
+ if (def.method !== request.method) continue;
63
+ if (!pathMatches(def.path, url.pathname)) continue;
64
+
65
+ let input: unknown = undefined;
66
+ if (request.method !== "GET" && request.method !== "DELETE") {
67
+ // Trust an advertised Content-Length up front so oversized payloads
68
+ // are rejected before we buffer them.
69
+ const clRaw = request.headers.get("Content-Length");
70
+ if (clRaw) {
71
+ const cl = Number(clRaw);
72
+ if (Number.isFinite(cl) && cl > MAX_BODY_BYTES) {
73
+ return new Response("Payload Too Large", { status: 413 });
74
+ }
75
+ }
76
+
77
+ const ct = request.headers.get("Content-Type") ?? "";
78
+ if (ct.includes("application/json")) {
79
+ const text = await request.text();
80
+ // Defense in depth: clients can lie about Content-Length.
81
+ if (text.length > MAX_BODY_BYTES) {
82
+ return new Response("Payload Too Large", { status: 413 });
83
+ }
84
+ try {
85
+ input = text ? JSON.parse(text) : undefined;
86
+ } catch {
87
+ return new Response("Bad Request: invalid JSON", { status: 400 });
88
+ }
89
+ } else if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
90
+ input = await request.formData();
91
+ }
92
+ }
93
+
94
+ try {
95
+ const result = await def.handler(input, request);
96
+ return Response.json(result);
97
+ } catch (err) {
98
+ if (err instanceof Response) return err;
99
+ // SECURITY(high): never leak internal error details in production.
100
+ // Dev mode keeps the message for DX; prod returns a generic 500.
101
+ console.error("[bractjs] api route error:", err);
102
+ const msg = isExplicitDev()
103
+ ? (err instanceof Error ? err.message : String(err))
104
+ : "Internal Server Error";
105
+ return Response.json({ error: msg }, { status: 500 });
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+
111
+ function pathMatches(pattern: string, pathname: string): boolean {
112
+ const pSegs = pattern.split("/").filter(Boolean);
113
+ const rSegs = pathname.split("/").filter(Boolean);
114
+ if (pSegs.length !== rSegs.length) return false;
115
+ // SECURITY(medium): `:param` segments accept any non-empty string but are
116
+ // not currently passed to the handler — handlers must read params from
117
+ // `request.url` themselves and validate (especially against ".." or
118
+ // path-traversal-shaped values) before using them in file system or SQL ops.
119
+ return pSegs.every((seg, i) => seg.startsWith(":") || seg === rSegs[i]);
120
+ }
121
+
122
+ // ── AppRoutes type extraction ─────────────────────────────────────────────
123
+
124
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
125
+ export type AppApiRoutes = (typeof routeRegistry)[number] extends ApiRouteDefinition<infer M, infer P, infer I, infer O>
126
+ ? { method: M; path: P; input: I; output: O }
127
+ : never;
@@ -0,0 +1,22 @@
1
+ // ── ContextFactory ─────────────────────────────────────────────────────────
2
+
3
+ export interface ContextFactory<T> {
4
+ _factory: (args: { request: Request; params: Record<string, string> }) => T | Promise<T>;
5
+ }
6
+
7
+ /**
8
+ * Define a per-route context factory.
9
+ *
10
+ * Route files export `export const context = defineContext(async ({ request, params }) => { ... })`
11
+ * The result is merged into the `context` arg received by all loaders and actions on that route.
12
+ *
13
+ * Example:
14
+ * export const context = defineContext(async ({ request }) => ({
15
+ * user: await getUser(request),
16
+ * }));
17
+ */
18
+ export function defineContext<T>(
19
+ factory: (args: { request: Request; params: Record<string, string> }) => T | Promise<T>,
20
+ ): ContextFactory<T> {
21
+ return { _factory: factory };
22
+ }
@@ -0,0 +1,17 @@
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
+ // SECURITY(medium): X-BractJS-Action acts as a CSRF token by relying on CORS preflight blocking custom headers cross-origin. This is safe only while the server does NOT emit permissive Access-Control-Allow-Headers for this header. If CORS policy is ever loosened, add a cryptographic CSRF token instead.
8
+ export function isAllowedMutation(request: Request): boolean {
9
+ if (request.headers.get("X-BractJS-Action")) return true;
10
+ const origin = request.headers.get("Origin");
11
+ if (!origin) return false;
12
+ try {
13
+ return new URL(origin).origin === new URL(request.url).origin;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
package/src/server/env.ts CHANGED
@@ -2,6 +2,22 @@ export function isDev(): boolean {
2
2
  return Bun.env.NODE_ENV !== "production";
3
3
  }
4
4
 
5
+ /**
6
+ * Strict "is development?" check used to gate sensitive output (error
7
+ * messages, stack traces) that would otherwise leak in production.
8
+ *
9
+ * Unlike isDev(), this returns true ONLY when NODE_ENV is explicitly set
10
+ * to "development". An unset/empty NODE_ENV is treated as production so an
11
+ * operator who forgets to set it never leaks internals.
12
+ *
13
+ * SECURITY(high): always use this for guarding info-disclosure code paths
14
+ * (server errors → response bodies) rather than isDev().
15
+ */
16
+ export function isExplicitDev(): boolean {
17
+ const v = Bun.env.NODE_ENV;
18
+ return v === "development" || v === "dev";
19
+ }
20
+
5
21
  export function requireEnv(key: string): string {
6
22
  const value = Bun.env[key];
7
23
  if (!value) {
@@ -10,6 +26,11 @@ export function requireEnv(key: string): string {
10
26
  return value;
11
27
  }
12
28
 
29
+ // Build LS/PS at runtime so the source contains no raw U+2028/U+2029
30
+ // (which would break JS parsing as LineTerminators).
31
+ const LS = String.fromCharCode(0x2028);
32
+ const PS = String.fromCharCode(0x2029);
33
+
13
34
  export function safeStringify(data: unknown): string {
14
35
  const seen = new WeakSet();
15
36
  const json = JSON.stringify(data, (_key, value) => {
@@ -19,11 +40,12 @@ export function safeStringify(data: unknown): string {
19
40
  }
20
41
  return value;
21
42
  });
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.
43
+ // Escape HTML-sensitive chars + JS LineTerminators (U+2028 / U+2029) so this
44
+ // JSON is safe to embed inside a <script> tag.
25
45
  return json
26
46
  .replace(/</g, "\\u003c")
27
47
  .replace(/>/g, "\\u003e")
28
- .replace(/&/g, "\\u0026");
48
+ .replace(/&/g, "\\u0026")
49
+ .replaceAll(LS, "\\u2028")
50
+ .replaceAll(PS, "\\u2029");
29
51
  }
@@ -0,0 +1,63 @@
1
+ import type { RouteFile } from "./scanner.ts";
2
+
3
+ export interface I18nConfig {
4
+ locales: string[];
5
+ defaultLocale: string;
6
+ }
7
+
8
+ /**
9
+ * Given a list of route files, return augmented copies that include a
10
+ * `/:locale` prefix in their URL pattern.
11
+ *
12
+ * The original routes are preserved so the framework still works without
13
+ * a locale prefix (SSR with the default locale).
14
+ */
15
+ export function wrapRoutesWithLocale(routes: RouteFile[], i18n: I18nConfig): RouteFile[] {
16
+ const prefix = i18n.locales.map((l) => l.replace(/[^A-Za-z0-9_-]/g, "")).join("|");
17
+ if (!prefix) return routes;
18
+
19
+ const localized: RouteFile[] = [];
20
+ for (const route of routes) {
21
+ // Prepend the locale param segment to the URL pattern.
22
+ const localizedPattern = route.urlPattern === "" || route.urlPattern === "/"
23
+ ? `[locale]`
24
+ : `[locale]/${route.urlPattern}`;
25
+
26
+ localized.push({
27
+ ...route,
28
+ urlPattern: localizedPattern,
29
+ segments: [{ param: "locale" }, ...route.segments],
30
+ });
31
+ }
32
+
33
+ // Return both original (for default locale without prefix) and localized routes.
34
+ return [...routes, ...localized];
35
+ }
36
+
37
+ /**
38
+ * Strip a locale prefix from the beginning of a pathname.
39
+ * Returns `{ locale, strippedPathname }` — locale is null when not present.
40
+ */
41
+ export function stripLocale(
42
+ pathname: string,
43
+ locales: string[],
44
+ ): { locale: string | null; strippedPathname: string } {
45
+ const segs = pathname.replace(/^\//, "").split("/");
46
+ const first = segs[0];
47
+ if (first && locales.includes(first)) {
48
+ return {
49
+ locale: first,
50
+ strippedPathname: "/" + segs.slice(1).join("/") || "/",
51
+ };
52
+ }
53
+ return { locale: null, strippedPathname: pathname };
54
+ }
55
+
56
+ /**
57
+ * Build a locale-aware variant of the `/_data` path query string.
58
+ * Injects the locale into params so loaders can read it from context.
59
+ */
60
+ export function localizedDataPath(pathname: string, locale: string | null): string {
61
+ if (!locale) return pathname;
62
+ return `/${locale}${pathname === "/" ? "" : pathname}`;
63
+ }
@@ -1,6 +1,8 @@
1
1
  import type { LoaderArgs, ActionArgs, RouteModule } from "../shared/route-types.ts";
2
2
  import type { LayoutChain } from "./layout.ts";
3
3
  import { isRedirect, isHttpError } from "../shared/errors.ts";
4
+ import { isExplicitDev } from "./env.ts";
5
+ import type { ContextFactory } from "./context.ts";
4
6
 
5
7
  // ── Types ──────────────────────────────────────────────────────────────────
6
8
 
@@ -25,10 +27,49 @@ export async function safeRun<T>(
25
27
  } catch (err) {
26
28
  // Re-throw redirects and HTTP errors — caller handles them
27
29
  if (isRedirect(err) || isHttpError(err)) throw err;
28
- return { __error: err };
30
+ // SECURITY(high): `__error` is serialized into the SSR HTML via
31
+ // safeStringify and reaches the browser. A custom Error subclass with
32
+ // public fields (db query text, file paths, internal IDs, raw user data)
33
+ // would leak them. In production we expose only a generic message; in
34
+ // dev we surface the real message + stack for DX. Routes wanting to
35
+ // surface structured user-facing errors should throw an HttpError, not
36
+ // a custom Error subclass.
37
+ console.error("[bractjs] loader error:", err);
38
+ const safe = isExplicitDev()
39
+ ? {
40
+ message: err instanceof Error ? err.message : String(err),
41
+ stack: err instanceof Error ? err.stack : undefined,
42
+ }
43
+ : { message: "Internal Server Error" };
44
+ return { __error: safe };
29
45
  }
30
46
  }
31
47
 
48
+ // ── runBeforeLoad ──────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Run the route module's optional `beforeLoad()` export.
52
+ * Returns a Response if beforeLoad wants to short-circuit (redirect / 403),
53
+ * or null to continue normally.
54
+ */
55
+ export async function runBeforeLoad(
56
+ routeModule: RouteModule,
57
+ args: LoaderArgs,
58
+ ): Promise<Response | null> {
59
+ const fn = routeModule.beforeLoad as
60
+ | ((a: { params: Record<string,string>; context: Record<string,unknown>; location: { pathname: string; search: string } }) => unknown)
61
+ | undefined;
62
+ if (!fn) return null;
63
+ const url = new URL(args.request.url);
64
+ const result = await fn({
65
+ params: args.params,
66
+ context: args.context,
67
+ location: { pathname: url.pathname, search: url.search },
68
+ });
69
+ if (result instanceof Response) return result;
70
+ return null;
71
+ }
72
+
32
73
  // ── runLoaders ─────────────────────────────────────────────────────────────
33
74
 
34
75
  export async function runLoaders(
@@ -78,3 +119,22 @@ export function buildLoaderArgs(
78
119
  ): LoaderArgs {
79
120
  return { request, params, context };
80
121
  }
122
+
123
+ // ── runRouteContext ────────────────────────────────────────────────────────
124
+
125
+ /**
126
+ * If the route module exports a `context` ContextFactory, run its factory and
127
+ * merge the result into a new context object. Returns the base context as-is
128
+ * if no factory is present.
129
+ */
130
+ export async function runRouteContext(
131
+ routeModule: RouteModule & { context?: ContextFactory<unknown> },
132
+ request: Request,
133
+ params: Record<string, string>,
134
+ baseContext: Record<string, unknown>,
135
+ ): Promise<Record<string, unknown>> {
136
+ const factory = routeModule.context;
137
+ if (!factory || typeof factory._factory !== "function") return baseContext;
138
+ const extra = await factory._factory({ request, params });
139
+ return { ...baseContext, ...(extra as Record<string, unknown>) };
140
+ }
@@ -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
 
@@ -60,6 +62,13 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
60
62
  headers: {
61
63
  "Content-Type": "text/html; charset=utf-8",
62
64
  "Transfer-Encoding": "chunked",
65
+ // SECURITY(medium): baseline hardening headers. Apps that need a tighter
66
+ // CSP (e.g. with nonces for the inline bootstrap script) can override
67
+ // via middleware. We omit CSP here because the inline bootstrap script
68
+ // injected by safeStringify would require nonce wiring throughout.
69
+ "X-Content-Type-Options": "nosniff",
70
+ "X-Frame-Options": "SAMEORIGIN",
71
+ "Referrer-Policy": "strict-origin-when-cross-origin",
63
72
  },
64
73
  });
65
74
  }
@@ -1,15 +1,16 @@
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";
5
4
  import { resolveRouteChain } from "./layout.ts";
6
- import { runLoaders, runAction, buildLoaderArgs } from "./loader.ts";
5
+ import { runLoaders, runAction, buildLoaderArgs, runRouteContext, runBeforeLoad } 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 { isExplicitDev } 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;
@@ -19,6 +20,11 @@ export interface HandlerConfig {
19
20
 
20
21
  const MUTATING_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
21
22
 
23
+ // SECURITY(medium): cap form/multipart bodies for route mutations so a
24
+ // single client cannot exhaust memory. Multipart uploads of legitimate
25
+ // large files should use a dedicated upload endpoint configured separately.
26
+ const MAX_FORM_BYTES = 10 * 1_048_576; // 10 MiB
27
+
22
28
  export async function handleRequest(
23
29
  request: Request,
24
30
  trie: TrieNode,
@@ -39,29 +45,56 @@ async function route(
39
45
  config: HandlerConfig,
40
46
  context: Record<string, unknown>,
41
47
  ): Promise<Response> {
42
- const { appDir, publicDir, manifest } = config;
48
+ const { appDir, manifest } = config;
43
49
  const url = new URL(request.url);
44
50
  const { pathname, searchParams } = url;
45
51
 
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
52
  // ── /_data soft-nav JSON endpoint ─────────────────────────────────────
54
- if (pathname.startsWith("/_data")) {
53
+ // Exact-match: "/_data" only. "/_dataXYZ" must not reach here.
54
+ if (pathname === "/_data") {
55
+ // SECURITY(high): /_data must be GET-only. It runs loaders for the
56
+ // target path; allowing POST/PUT/DELETE would bypass the CSRF gate that
57
+ // protects route mutations and could trigger non-idempotent loader code.
58
+ if (request.method !== "GET" && request.method !== "HEAD") {
59
+ return error("Method Not Allowed", 405);
60
+ }
61
+ // SECURITY(medium): `path` param is user-controlled and used to reconstruct a URL. matchRoute only matches registered routes (trie), so unmapped paths return 404 rather than accidentally proxying. Ensure the trie stays the single source of truth for what paths are reachable.
55
62
  const targetPath = searchParams.get("path") ?? "/";
56
- const match = matchRoute(targetPath, trie);
63
+ // Reject pathologically long path params to bound trie matching + URL parsing cost.
64
+ if (targetPath.length > 2048) return json({ error: "Bad Request" }, { status: 400 });
65
+ // Strip query string from path param so matching works on the pathname only.
66
+ const [targetPathname, targetSearch] = targetPath.split("?");
67
+ const match = matchRoute(targetPathname, trie);
57
68
  if (!match) return json({ error: "Not Found" }, { status: 404 });
58
69
 
59
70
  try {
60
71
  const chain = await resolveRouteChain(match.routeFile, appDir);
61
- const args = buildLoaderArgs(request, match.params, {});
72
+ // Reconstruct a Request that carries the original search params so loaders
73
+ // can access them via request.url / new URL(request.url).searchParams.
74
+ const targetUrl = new URL(request.url);
75
+ targetUrl.pathname = targetPathname;
76
+ targetUrl.search = targetSearch ? "?" + targetSearch : "";
77
+ const loaderRequest = new Request(targetUrl.toString(), {
78
+ headers: request.headers,
79
+ method: "GET",
80
+ });
81
+ // SECURITY(high): /_data must run the same auth/redirect gates as a full
82
+ // page request — otherwise a SPA-style soft navigation to a protected
83
+ // route would bypass beforeLoad() / defineContext() and leak loader data.
84
+ const routeContext = await runRouteContext(
85
+ chain.route as Parameters<typeof runRouteContext>[0],
86
+ loaderRequest,
87
+ match.params,
88
+ context,
89
+ );
90
+ const args = buildLoaderArgs(loaderRequest, match.params, routeContext);
91
+ const beforeLoadResponse = await runBeforeLoad(chain.route, args);
92
+ if (beforeLoadResponse) return beforeLoadResponse;
62
93
  const results = await runLoaders(chain, args);
63
94
  return json({ root: results.root, layouts: results.layouts, route: results.route, params: match.params });
64
95
  } catch (err) {
96
+ if (isRedirect(err)) return err as Response;
97
+ if (isHttpError(err)) return json({ error: err.message }, { status: err.status });
65
98
  console.error("[bractjs] /_data error:", err);
66
99
  return json({ error: "Internal Server Error" }, { status: 500 });
67
100
  }
@@ -72,17 +105,41 @@ async function route(
72
105
  if (!match) return error("Not Found", 404);
73
106
 
74
107
  const chain = await resolveRouteChain(match.routeFile, appDir);
75
- const args = buildLoaderArgs(request, match.params, context);
108
+ // Run per-route context factory (defineContext export) before loaders.
109
+ const routeContext = await runRouteContext(
110
+ chain.route as Parameters<typeof runRouteContext>[0],
111
+ request,
112
+ match.params,
113
+ context,
114
+ );
115
+ const args = buildLoaderArgs(request, match.params, routeContext);
116
+
117
+ // ── beforeLoad ────────────────────────────────────────────────────────
118
+ const beforeLoadResponse = await runBeforeLoad(chain.route, args);
119
+ if (beforeLoadResponse) return beforeLoadResponse;
76
120
 
77
121
  // ── Action (mutating methods) ─────────────────────────────────────────
78
122
  let actionData: unknown = null;
79
123
  if (MUTATING_METHODS.has(request.method)) {
124
+ if (!isAllowedMutation(request)) return error("Forbidden", 403);
125
+ // Reject up front if the client advertises an oversized body.
126
+ const clRaw = request.headers.get("Content-Length");
127
+ if (clRaw) {
128
+ const cl = Number(clRaw);
129
+ if (Number.isFinite(cl) && cl > MAX_FORM_BYTES) {
130
+ return error("Payload Too Large", 413);
131
+ }
132
+ }
80
133
  try {
81
- const formData = await request.formData();
134
+ const ct = request.headers.get("Content-Type") ?? "";
135
+ const isFormLike = ct.includes("multipart/form-data") || ct.includes("application/x-www-form-urlencoded");
136
+ const formData = isFormLike ? await request.formData() : new FormData();
82
137
  actionData = await runAction(chain.route, { ...args, formData });
83
138
  } catch (err) {
84
139
  if (isRedirect(err)) return err as Response;
85
- throw err;
140
+ if (isHttpError(err)) return error(err.message, err.status);
141
+ if (isExplicitDev()) return error(err instanceof Error ? err.message : String(err), 500);
142
+ return error("Internal Server Error", 500);
86
143
  }
87
144
 
88
145
  // Client-side Form submits with this header — return JSON, not HTML.
@@ -97,7 +154,9 @@ async function route(
97
154
  loaderResults = await runLoaders(chain, args);
98
155
  } catch (err) {
99
156
  if (isRedirect(err)) return err as Response;
100
- throw err;
157
+ if (isHttpError(err)) return error(err.message, err.status);
158
+ if (isExplicitDev()) return error(err instanceof Error ? err.message : String(err), 500);
159
+ return error("Internal Server Error", 500);
101
160
  }
102
161
 
103
162
  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