@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.
- package/package.json +1 -1
- package/src/__tests__/loader.test.ts +5 -2
- package/src/adapters/cloudflare.ts +65 -0
- package/src/build/bundler.ts +2 -1
- package/src/build/env-plugin.ts +7 -0
- package/src/build/plugins/css-modules.ts +110 -0
- package/src/client/ClientRouter.tsx +113 -9
- package/src/client/cache.ts +69 -0
- package/src/client/components/Link.tsx +16 -2
- package/src/client/components/LiveReload.tsx +4 -0
- package/src/client/hooks/useBlocker.ts +44 -0
- package/src/client/hooks/useFetcher.ts +66 -6
- package/src/client/hooks/useLocale.ts +12 -0
- package/src/client/hooks/useLocalizedLink.ts +18 -0
- package/src/client/hooks/useSearchParams.ts +74 -0
- package/src/client/rpc.ts +70 -0
- package/src/codegen/route-codegen.ts +63 -1
- package/src/dev/devtools.ts +144 -0
- package/src/dev/hmr-client.ts +14 -0
- package/src/dev/hmr-module-handler.ts +17 -1
- package/src/dev/hmr-server.ts +16 -0
- package/src/image/handler.ts +5 -2
- package/src/image/optimizer.ts +6 -1
- package/src/index.ts +27 -0
- package/src/middleware/cors.ts +4 -0
- package/src/middleware/requestLogger.ts +4 -0
- package/src/server/action-handler.ts +8 -4
- package/src/server/adapter.ts +57 -0
- package/src/server/api-route.ts +127 -0
- package/src/server/context.ts +22 -0
- package/src/server/csrf.ts +1 -0
- package/src/server/env.ts +16 -0
- package/src/server/i18n.ts +63 -0
- package/src/server/loader.ts +61 -1
- package/src/server/render.ts +7 -0
- package/src/server/request-handler.ts +66 -8
- package/src/server/serve.ts +102 -55
- package/src/server/session.ts +1 -0
- package/src/server/static.ts +8 -1
- package/src/server/stream-handler.ts +111 -0
- package/src/server/validate.ts +89 -0
- package/src/shared/route-types.ts +11 -0
- package/types/index.d.ts +94 -1
- 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
|
+
}
|
package/src/server/csrf.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/server/loader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/server/render.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|