@bractjs/bractjs 0.1.6 → 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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/loader.test.ts +5 -2
  3. package/src/adapters/cloudflare.ts +65 -0
  4. package/src/build/bundler.ts +2 -1
  5. package/src/build/env-plugin.ts +7 -0
  6. package/src/build/plugins/css-modules.ts +110 -0
  7. package/src/client/ClientRouter.tsx +113 -9
  8. package/src/client/cache.ts +69 -0
  9. package/src/client/components/Link.tsx +16 -2
  10. package/src/client/components/LiveReload.tsx +4 -0
  11. package/src/client/hooks/useBlocker.ts +44 -0
  12. package/src/client/hooks/useFetcher.ts +66 -6
  13. package/src/client/hooks/useLocale.ts +12 -0
  14. package/src/client/hooks/useLocalizedLink.ts +18 -0
  15. package/src/client/hooks/useSearchParams.ts +74 -0
  16. package/src/client/rpc.ts +70 -0
  17. package/src/codegen/route-codegen.ts +63 -1
  18. package/src/dev/devtools.ts +144 -0
  19. package/src/dev/hmr-client.ts +14 -0
  20. package/src/dev/hmr-module-handler.ts +17 -1
  21. package/src/dev/hmr-server.ts +16 -0
  22. package/src/image/handler.ts +5 -2
  23. package/src/image/optimizer.ts +6 -1
  24. package/src/index.ts +27 -0
  25. package/src/middleware/cors.ts +4 -0
  26. package/src/middleware/requestLogger.ts +4 -0
  27. package/src/server/action-handler.ts +8 -4
  28. package/src/server/adapter.ts +57 -0
  29. package/src/server/api-route.ts +127 -0
  30. package/src/server/context.ts +22 -0
  31. package/src/server/csrf.ts +1 -0
  32. package/src/server/env.ts +16 -0
  33. package/src/server/i18n.ts +63 -0
  34. package/src/server/loader.ts +61 -1
  35. package/src/server/render.ts +7 -0
  36. package/src/server/request-handler.ts +66 -8
  37. package/src/server/serve.ts +102 -55
  38. package/src/server/session.ts +1 -0
  39. package/src/server/static.ts +8 -1
  40. package/src/server/stream-handler.ts +111 -0
  41. package/src/server/validate.ts +89 -0
  42. package/src/shared/route-types.ts +11 -0
  43. package/types/index.d.ts +94 -1
  44. package/types/route.d.ts +11 -0
@@ -0,0 +1,57 @@
1
+ // ── BractAdapter ──────────────────────────────────────────────────────────
2
+
3
+ /**
4
+ * Minimal interface that adapters must implement.
5
+ *
6
+ * `fetch` is the standard WinterCG-compatible fetch handler — it receives a
7
+ * Request and returns a Response. The server core calls this for every
8
+ * incoming HTTP request after routing special endpoints.
9
+ *
10
+ * `listen` starts the adapter's underlying server on the given port.
11
+ * It is optional for environments that do not control port binding (e.g.
12
+ * Cloudflare Workers).
13
+ */
14
+ export interface BractAdapter {
15
+ fetch(request: Request): Promise<Response>;
16
+ listen?(port: number): void;
17
+ }
18
+
19
+ // ── BunAdapter ────────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Default adapter — wraps `Bun.serve()`.
23
+ * Created internally by `createServer()` when no adapter is provided.
24
+ */
25
+ export class BunAdapter implements BractAdapter {
26
+ private server: ReturnType<typeof Bun.serve> | null = null;
27
+ private handler: ((request: Request) => Promise<Response>) | null = null;
28
+
29
+ setHandler(handler: (request: Request) => Promise<Response>): void {
30
+ this.handler = handler;
31
+ }
32
+
33
+ async fetch(request: Request): Promise<Response> {
34
+ if (!this.handler) throw new Error("BunAdapter: handler not set");
35
+ return this.handler(request);
36
+ }
37
+
38
+ listen(port: number): void {
39
+ if (!this.handler) throw new Error("BunAdapter: handler not set before listen()");
40
+ const handler = this.handler;
41
+ this.server = Bun.serve({
42
+ port,
43
+ fetch: handler,
44
+ error(err: Error) {
45
+ console.error("[bractjs] unhandled server error:", err);
46
+ return new Response(JSON.stringify({ error: err.message }), {
47
+ status: 500,
48
+ headers: { "Content-Type": "application/json; charset=utf-8" },
49
+ });
50
+ },
51
+ });
52
+ }
53
+
54
+ stop(): void {
55
+ this.server?.stop();
56
+ }
57
+ }
@@ -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
+ }
@@ -4,6 +4,7 @@
4
4
  * cross-origin by CORS for non-simple requests), OR the Origin header matches
5
5
  * the request URL's origin.
6
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.
7
8
  export function isAllowedMutation(request: Request): boolean {
8
9
  if (request.headers.get("X-BractJS-Action")) return true;
9
10
  const origin = request.headers.get("Origin");
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) {
@@ -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
+ }
@@ -62,6 +62,13 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
62
62
  headers: {
63
63
  "Content-Type": "text/html; charset=utf-8",
64
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",
65
72
  },
66
73
  });
67
74
  }
@@ -2,12 +2,12 @@ import { createElement } from "react";
2
2
  import type { TrieNode } from "./matcher.ts";
3
3
  import { matchRoute } from "./matcher.ts";
4
4
  import { resolveRouteChain } from "./layout.ts";
5
- import { runLoaders, runAction, buildLoaderArgs } from "./loader.ts";
5
+ import { runLoaders, runAction, buildLoaderArgs, runRouteContext, runBeforeLoad } from "./loader.ts";
6
6
  import { renderRoute, type ServerManifest } from "./render.ts";
7
7
  import { resolveMeta } from "./meta.ts";
8
8
  import { json, error } from "./response.ts";
9
9
  import { isRedirect, isHttpError } from "../shared/errors.ts";
10
- import { isDev } from "./env.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
13
  import { isAllowedMutation } from "./csrf.ts";
@@ -20,6 +20,11 @@ export interface HandlerConfig {
20
20
 
21
21
  const MUTATING_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
22
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
+
23
28
  export async function handleRequest(
24
29
  request: Request,
25
30
  trie: TrieNode,
@@ -45,17 +50,51 @@ async function route(
45
50
  const { pathname, searchParams } = url;
46
51
 
47
52
  // ── /_data soft-nav JSON endpoint ─────────────────────────────────────
48
- 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.
49
62
  const targetPath = searchParams.get("path") ?? "/";
50
- 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);
51
68
  if (!match) return json({ error: "Not Found" }, { status: 404 });
52
69
 
53
70
  try {
54
71
  const chain = await resolveRouteChain(match.routeFile, appDir);
55
- 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;
56
93
  const results = await runLoaders(chain, args);
57
94
  return json({ root: results.root, layouts: results.layouts, route: results.route, params: match.params });
58
95
  } catch (err) {
96
+ if (isRedirect(err)) return err as Response;
97
+ if (isHttpError(err)) return json({ error: err.message }, { status: err.status });
59
98
  console.error("[bractjs] /_data error:", err);
60
99
  return json({ error: "Internal Server Error" }, { status: 500 });
61
100
  }
@@ -66,12 +105,31 @@ async function route(
66
105
  if (!match) return error("Not Found", 404);
67
106
 
68
107
  const chain = await resolveRouteChain(match.routeFile, appDir);
69
- 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;
70
120
 
71
121
  // ── Action (mutating methods) ─────────────────────────────────────────
72
122
  let actionData: unknown = null;
73
123
  if (MUTATING_METHODS.has(request.method)) {
74
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
+ }
75
133
  try {
76
134
  const ct = request.headers.get("Content-Type") ?? "";
77
135
  const isFormLike = ct.includes("multipart/form-data") || ct.includes("application/x-www-form-urlencoded");
@@ -80,7 +138,7 @@ async function route(
80
138
  } catch (err) {
81
139
  if (isRedirect(err)) return err as Response;
82
140
  if (isHttpError(err)) return error(err.message, err.status);
83
- if (isDev()) return error(err instanceof Error ? err.message : String(err), 500);
141
+ if (isExplicitDev()) return error(err instanceof Error ? err.message : String(err), 500);
84
142
  return error("Internal Server Error", 500);
85
143
  }
86
144
 
@@ -97,7 +155,7 @@ async function route(
97
155
  } catch (err) {
98
156
  if (isRedirect(err)) return err as Response;
99
157
  if (isHttpError(err)) return error(err.message, err.status);
100
- if (isDev()) return error(err instanceof Error ? err.message : String(err), 500);
158
+ if (isExplicitDev()) return error(err instanceof Error ? err.message : String(err), 500);
101
159
  return error("Internal Server Error", 500);
102
160
  }
103
161