@bractjs/bractjs 0.1.28 → 0.2.0
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/README.md +98 -17
- package/package.json +8 -7
- package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
- package/src/__tests__/headers.test.ts +111 -0
- package/src/__tests__/integration.test.ts +34 -0
- package/src/__tests__/layout-registry.test.ts +7 -3
- package/src/__tests__/matcher.test.ts +29 -0
- package/src/__tests__/module-registry.test.ts +2 -3
- package/src/__tests__/route-lint.test.ts +5 -0
- package/src/__tests__/route-middleware.test.ts +84 -0
- package/src/__tests__/scanner.test.ts +46 -1
- package/src/__tests__/security-fixes.test.ts +201 -0
- package/src/__tests__/use-matches.test.ts +54 -0
- package/src/build/route-lint.ts +3 -3
- package/src/client/ClientRouter.tsx +118 -18
- package/src/client/hooks/useMatches.ts +32 -0
- package/src/client/router.tsx +7 -1
- package/src/client/rpc.ts +11 -1
- package/src/codegen/module-registry.ts +13 -21
- package/src/codegen/route-codegen.ts +8 -3
- package/src/config/load.ts +1 -0
- package/src/index.ts +11 -3
- package/src/server/action-handler.ts +1 -20
- package/src/server/adapter.ts +16 -0
- package/src/server/api-route.ts +47 -0
- package/src/server/csp.ts +9 -3
- package/src/server/csrf.ts +10 -3
- package/src/server/headers.ts +49 -0
- package/src/server/layout.ts +12 -19
- package/src/server/matcher.ts +29 -2
- package/src/server/matches.ts +50 -0
- package/src/server/middleware.ts +66 -0
- package/src/server/proto-guard.ts +56 -0
- package/src/server/render.ts +34 -16
- package/src/server/request-handler.ts +67 -27
- package/src/server/scanner.ts +45 -3
- package/src/server/search.ts +5 -1
- package/src/server/serve.ts +28 -3
- package/src/server/session.ts +12 -1
- package/src/server/validate.ts +4 -1
- package/src/shared/context.ts +3 -1
- package/src/shared/route-types.ts +108 -0
- package/types/config.d.ts +3 -0
- package/types/index.d.ts +17 -0
- package/types/route.d.ts +76 -1
package/src/server/middleware.ts
CHANGED
|
@@ -22,6 +22,13 @@ export class MiddlewarePipeline {
|
|
|
22
22
|
return this;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** Remove all registered middleware. Useful for tests and for embedders that
|
|
26
|
+
* rebuild the pipeline (e.g. on a hot reload). */
|
|
27
|
+
clear(): this {
|
|
28
|
+
this.fns = [];
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
/**
|
|
26
33
|
* Compose all registered middleware into a single chain and execute it.
|
|
27
34
|
* Each fn calls `next()` to invoke the next fn; the last `next()` calls `handler`.
|
|
@@ -49,3 +56,62 @@ export class MiddlewarePipeline {
|
|
|
49
56
|
|
|
50
57
|
/** Module-level default pipeline — attach middleware here via pipeline.use(). */
|
|
51
58
|
export const pipeline = new MiddlewarePipeline();
|
|
59
|
+
|
|
60
|
+
// ── Per-route (nested) middleware ────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A route/layout/root module's `middleware` entry. Same shape as the global
|
|
64
|
+
* {@link MiddlewareFn}: call `next()` to continue the chain, or return a
|
|
65
|
+
* `Response` to short-circuit (auth gate, redirect). The `ctx.context` object
|
|
66
|
+
* is shared and mutable — set fields on it and downstream middleware, loaders,
|
|
67
|
+
* and actions see them.
|
|
68
|
+
*/
|
|
69
|
+
export type RouteMiddleware = MiddlewareFn;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compose a route's nested middleware chain (root → layouts → route, in that
|
|
73
|
+
* order) around `handler` and run it. Mirrors {@link MiddlewarePipeline.run}
|
|
74
|
+
* but for an ad-hoc, per-request list rather than the module-level pipeline.
|
|
75
|
+
* An empty list calls `handler` directly (zero overhead for routes that don't
|
|
76
|
+
* use middleware).
|
|
77
|
+
*/
|
|
78
|
+
export function runRouteMiddleware(
|
|
79
|
+
fns: RouteMiddleware[],
|
|
80
|
+
ctx: MiddlewareContext,
|
|
81
|
+
handler: () => Promise<Response>,
|
|
82
|
+
): Promise<Response> {
|
|
83
|
+
if (fns.length === 0) return handler();
|
|
84
|
+
let lastCalled = -1;
|
|
85
|
+
const dispatch = (i: number): Promise<Response> => {
|
|
86
|
+
if (i <= lastCalled) {
|
|
87
|
+
return Promise.reject(new Error("route middleware: next() called more than once"));
|
|
88
|
+
}
|
|
89
|
+
lastCalled = i;
|
|
90
|
+
if (i >= fns.length) return handler();
|
|
91
|
+
return fns[i](ctx, () => dispatch(i + 1));
|
|
92
|
+
};
|
|
93
|
+
return dispatch(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Flatten a route chain's `middleware` exports into a single ordered list:
|
|
98
|
+
* root first, then each layout outermost→innermost, then the leaf route. Each
|
|
99
|
+
* module may export `middleware` as a single fn or an array; both normalize
|
|
100
|
+
* here. Non-function entries are ignored defensively.
|
|
101
|
+
*/
|
|
102
|
+
export function collectRouteMiddleware(chain: {
|
|
103
|
+
root: { middleware?: unknown };
|
|
104
|
+
layouts: Array<{ middleware?: unknown }>;
|
|
105
|
+
route: { middleware?: unknown };
|
|
106
|
+
}): RouteMiddleware[] {
|
|
107
|
+
const out: RouteMiddleware[] = [];
|
|
108
|
+
const add = (m: unknown) => {
|
|
109
|
+
if (!m) return;
|
|
110
|
+
const list = Array.isArray(m) ? m : [m];
|
|
111
|
+
for (const fn of list) if (typeof fn === "function") out.push(fn as RouteMiddleware);
|
|
112
|
+
};
|
|
113
|
+
add(chain.root.middleware);
|
|
114
|
+
for (const layout of chain.layouts) add(layout.middleware);
|
|
115
|
+
add(chain.route.middleware);
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prototype-pollution guards, shared across every untrusted-object boundary
|
|
3
|
+
* (server actions, typed /api JSON, form/search → object conversions).
|
|
4
|
+
*
|
|
5
|
+
* Two strategies, used where each fits:
|
|
6
|
+
*
|
|
7
|
+
* 1. {@link hasForbiddenKey} — a deep scan that REJECTS a parsed JSON value
|
|
8
|
+
* carrying a dangerous key. Used on /_action and /api JSON bodies, where a
|
|
9
|
+
* 400 is the right UX and the payload shape is the app's contract.
|
|
10
|
+
*
|
|
11
|
+
* 2. {@link nullProtoFromEntries} — builds a null-prototype object from
|
|
12
|
+
* key/value pairs so a key literally named "__proto__" becomes an ordinary
|
|
13
|
+
* own property that can never reach Object.prototype. Used for the
|
|
14
|
+
* FormData / URLSearchParams → object conversions, which must accept
|
|
15
|
+
* arbitrary field names without erroring.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// `__proto__` is the actual pollution vector for own-keys produced by
|
|
19
|
+
// JSON.parse. `constructor`/`prototype` are included defensively: a recursive
|
|
20
|
+
// merge that walks `obj.constructor.prototype` can be steered by them too.
|
|
21
|
+
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
22
|
+
|
|
23
|
+
// Max nesting we will fully scan. Legitimate payloads are shallow; anything
|
|
24
|
+
// deeper is treated as hostile and rejected (fail closed) — see hasForbiddenKey.
|
|
25
|
+
const MAX_SCAN_DEPTH = 200;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Deep scan for a forbidden key anywhere in a parsed JSON value.
|
|
29
|
+
*
|
|
30
|
+
* SECURITY(high): this is a security filter, so it FAILS CLOSED. A value nested
|
|
31
|
+
* past MAX_SCAN_DEPTH returns `true` (rejected) rather than being passed
|
|
32
|
+
* through — otherwise an attacker could bury `__proto__` below the cap to evade
|
|
33
|
+
* the check and reach a recursive-merge sink in handler code.
|
|
34
|
+
*/
|
|
35
|
+
export function hasForbiddenKey(value: unknown, depth = 0): boolean {
|
|
36
|
+
if (!value || typeof value !== "object") return false;
|
|
37
|
+
if (depth > MAX_SCAN_DEPTH) return true;
|
|
38
|
+
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
39
|
+
if (FORBIDDEN_KEYS.has(key)) return true;
|
|
40
|
+
if (hasForbiddenKey((value as Record<string, unknown>)[key], depth + 1)) return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a null-prototype object from [key, value] entries. A key named
|
|
47
|
+
* "__proto__" lands as a plain own property instead of mutating the prototype,
|
|
48
|
+
* so downstream spreads/merges of the result are pollution-safe.
|
|
49
|
+
*/
|
|
50
|
+
export function nullProtoFromEntries<V>(
|
|
51
|
+
entries: Iterable<readonly [string, V]>,
|
|
52
|
+
): Record<string, V> {
|
|
53
|
+
const out = Object.create(null) as Record<string, V>;
|
|
54
|
+
for (const [k, v] of entries) out[k] = v;
|
|
55
|
+
return out;
|
|
56
|
+
}
|
package/src/server/render.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { renderToReadableStream } from "react-dom/server";
|
|
2
2
|
import { createElement, Fragment, type ReactNode } from "react";
|
|
3
|
-
import type { MetaDescriptor } from "../shared/route-types.ts";
|
|
3
|
+
import type { MetaDescriptor, RouteMatch } from "../shared/route-types.ts";
|
|
4
4
|
import { safeStringify, isDevRuntime, getDevHmrPort } from "./env.ts";
|
|
5
5
|
import { errorOverlayScript } from "../dev/error-overlay.ts";
|
|
6
6
|
import { mergeMeta } from "./meta.ts";
|
|
@@ -22,6 +22,8 @@ export interface RenderOptions {
|
|
|
22
22
|
search?: Record<string, unknown>;
|
|
23
23
|
manifest: ServerManifest;
|
|
24
24
|
meta: MetaDescriptor[];
|
|
25
|
+
/** The matched route chain (root → layouts → route) for `useMatches()`. */
|
|
26
|
+
matches?: RouteMatch[];
|
|
25
27
|
status?: number;
|
|
26
28
|
/** Path of the matched route file (e.g. "routes/_index.tsx"), used by the client to pre-import the module before hydration. */
|
|
27
29
|
routeFile?: string;
|
|
@@ -34,6 +36,11 @@ export interface RenderOptions {
|
|
|
34
36
|
* "spa": static shell, everything resolved client-side).
|
|
35
37
|
*/
|
|
36
38
|
ssrMode?: "client-only" | "data-only" | "spa";
|
|
39
|
+
/**
|
|
40
|
+
* Resolved route `headers()` output (root → layout → route merged). Applied
|
|
41
|
+
* on top of the baseline document headers, overriding any same-key default.
|
|
42
|
+
*/
|
|
43
|
+
headers?: Headers | null;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export async function renderRoute(options: RenderOptions): Promise<Response> {
|
|
@@ -59,7 +66,7 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
|
|
|
59
66
|
// The merged descriptor array is what the client reads to keep the document
|
|
60
67
|
// head in sync on soft navigation — keep it shaped, not stringified HTML.
|
|
61
68
|
const bootstrapScriptContent =
|
|
62
|
-
devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, search: options.search, manifest, routeFile: options.routeFile, meta: mergedMeta, ssrMode: options.ssrMode })};`;
|
|
69
|
+
devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, search: options.search, manifest, routeFile: options.routeFile, meta: mergedMeta, matches: options.matches, ssrMode: options.ssrMode })};`;
|
|
63
70
|
|
|
64
71
|
// Render <title>/<meta> elements alongside the app shell. React 19 hoists
|
|
65
72
|
// document-metadata elements into <head> during streaming SSR, so crawlers
|
|
@@ -90,19 +97,30 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
|
|
|
90
97
|
|
|
91
98
|
const responseStatus = renderError ? 500 : status;
|
|
92
99
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
"X-Frame-Options": "SAMEORIGIN",
|
|
105
|
-
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
106
|
-
},
|
|
100
|
+
const headers = new Headers({
|
|
101
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
102
|
+
"Transfer-Encoding": "chunked",
|
|
103
|
+
// SECURITY(medium): baseline hardening headers. For a Content-Security-
|
|
104
|
+
// Policy, opt into the nonce-based `csp()` middleware — it generates a
|
|
105
|
+
// per-request nonce, applies it to the inline bootstrap script + client
|
|
106
|
+
// entry module here (via renderToReadableStream's `nonce` option), and
|
|
107
|
+
// sets the CSP response header.
|
|
108
|
+
"X-Content-Type-Options": "nosniff",
|
|
109
|
+
"X-Frame-Options": "SAMEORIGIN",
|
|
110
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
107
111
|
});
|
|
112
|
+
|
|
113
|
+
// Route `headers()` output (root → layout → route) overrides the baseline.
|
|
114
|
+
// Content-Type / Transfer-Encoding stay framework-owned: a route shouldn't
|
|
115
|
+
// be able to corrupt the streamed document envelope. Don't apply on render
|
|
116
|
+
// errors — that path serves a generic 500, not the route's cached document.
|
|
117
|
+
if (options.headers && !renderError) {
|
|
118
|
+
options.headers.forEach((value, key) => {
|
|
119
|
+
const k = key.toLowerCase();
|
|
120
|
+
if (k === "content-type" || k === "transfer-encoding") return;
|
|
121
|
+
headers.set(key, value);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return new Response(stream, { status: responseStatus, headers });
|
|
108
126
|
}
|
|
@@ -6,10 +6,12 @@ import { runLoaders, runAction, buildLoaderArgs, runRouteContext, runBeforeLoad
|
|
|
6
6
|
import { validateSearch } from "./search.ts";
|
|
7
7
|
import { renderRoute, type ServerManifest } from "./render.ts";
|
|
8
8
|
import { resolveMeta, mergeMeta } from "./meta.ts";
|
|
9
|
+
import { resolveHeaders } from "./headers.ts";
|
|
10
|
+
import { buildMatches } from "./matches.ts";
|
|
9
11
|
import { json, error, sanitizeRedirect } from "./response.ts";
|
|
10
12
|
import { isRedirect, isHttpError } from "../shared/errors.ts";
|
|
11
13
|
import { isExplicitDev } from "./env.ts";
|
|
12
|
-
import {
|
|
14
|
+
import { runRouteMiddleware, collectRouteMiddleware, type MiddlewareContext } from "./middleware.ts";
|
|
13
15
|
import { BractJSProvider } from "../shared/context.ts";
|
|
14
16
|
import { isAllowedMutation, csrfForbiddenResponse } from "./csrf.ts";
|
|
15
17
|
import { getCspNonce } from "./csp.ts";
|
|
@@ -38,15 +40,15 @@ const MAX_FORM_BYTES = 10 * 1_048_576; // 10 MiB
|
|
|
38
40
|
export async function handleRequest(
|
|
39
41
|
request: Request,
|
|
40
42
|
trie: TrieNode,
|
|
41
|
-
config: HandlerConfig
|
|
43
|
+
config: HandlerConfig,
|
|
44
|
+
context: Record<string, unknown> = {},
|
|
42
45
|
): Promise<Response> {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return pipeline.run(ctx, () => route(request, trie, config, ctx.context));
|
|
46
|
+
// The global pipeline is run once by buildFetchHandler around the whole
|
|
47
|
+
// dispatch (so it also covers /api, /_action, /_stream, /_image, static).
|
|
48
|
+
// We receive the shared, already-running `context` here and only run the
|
|
49
|
+
// per-route (nested) middleware chain — running the global pipeline again
|
|
50
|
+
// would double-invoke cors()/csp()/etc. for SSR documents.
|
|
51
|
+
return route(request, trie, config, context);
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
async function route(
|
|
@@ -92,24 +94,42 @@ async function route(
|
|
|
92
94
|
// never see unvalidated input, and a 400 here is cheaper than a wasted
|
|
93
95
|
// context-factory/loader run. The thrown 400 Response propagates below.
|
|
94
96
|
const search = await validateSearch(chain.route.searchSchema, targetUrl);
|
|
97
|
+
|
|
95
98
|
// SECURITY(high): /_data must run the same auth/redirect gates as a full
|
|
96
99
|
// page request — otherwise a SPA-style soft navigation to a protected
|
|
97
|
-
// route would bypass beforeLoad() / defineContext()
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
100
|
+
// route would bypass nested middleware / beforeLoad() / defineContext()
|
|
101
|
+
// and leak loader data. Run the route middleware chain around the work,
|
|
102
|
+
// sharing the same mutable `context` so a gate can set/clear fields.
|
|
103
|
+
const mwCtx: MiddlewareContext = { request: loaderRequest, params: match.params, context };
|
|
104
|
+
return runRouteMiddleware(collectRouteMiddleware(chain), mwCtx, async () => {
|
|
105
|
+
const routeContext = await runRouteContext(
|
|
106
|
+
chain.route as Parameters<typeof runRouteContext>[0],
|
|
107
|
+
loaderRequest,
|
|
108
|
+
match.params,
|
|
109
|
+
mwCtx.context,
|
|
110
|
+
);
|
|
111
|
+
const args = buildLoaderArgs(loaderRequest, match.params, routeContext, search);
|
|
112
|
+
const beforeLoadResponse = await runBeforeLoad(chain.route, args);
|
|
113
|
+
if (beforeLoadResponse) return beforeLoadResponse;
|
|
114
|
+
const results = await runLoaders(chain, args, onError);
|
|
115
|
+
// Merged meta must ride along: ClientRouter re-renders the document head
|
|
116
|
+
// from this payload on soft navigation, and the initial __BRACTJS_DATA__
|
|
117
|
+
// already carries the merged shape.
|
|
118
|
+
const meta = mergeMeta(resolveMeta(chain, results, match.params));
|
|
119
|
+
const matches = buildMatches(chain, results, match.params, targetPathname);
|
|
120
|
+
const dataRes = json({ root: results.root, layouts: results.layouts, route: results.route, params: match.params, meta, search, matches });
|
|
121
|
+
// Apply the route `headers()` chain so a soft navigation gets the same
|
|
122
|
+
// Cache-Control/ETag/Vary as the full document load (renderRoute applies
|
|
123
|
+
// them there). Content-Type stays application/json.
|
|
124
|
+
const dataHeaders = resolveHeaders(chain, results, match.params, loaderRequest);
|
|
125
|
+
if (dataHeaders) {
|
|
126
|
+
dataHeaders.forEach((value, key) => {
|
|
127
|
+
if (key.toLowerCase() === "content-type") return;
|
|
128
|
+
dataRes.headers.set(key, value);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return dataRes;
|
|
132
|
+
});
|
|
113
133
|
} catch (err) {
|
|
114
134
|
if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
|
|
115
135
|
// A non-redirect Response (e.g. the 400 thrown by search validation)
|
|
@@ -138,12 +158,19 @@ async function route(
|
|
|
138
158
|
throw err;
|
|
139
159
|
}
|
|
140
160
|
|
|
161
|
+
// Nested route middleware (root → layout → route) wraps the action, loaders,
|
|
162
|
+
// and render. It shares the same mutable `context` object, runs *inside* the
|
|
163
|
+
// global pipeline, and can short-circuit (auth gate / redirect) by returning
|
|
164
|
+
// a Response. Empty chains call the work directly (no overhead).
|
|
165
|
+
const mwCtx: MiddlewareContext = { request, params: match.params, context };
|
|
166
|
+
return runRouteMiddleware(collectRouteMiddleware(chain), mwCtx, async () => {
|
|
167
|
+
|
|
141
168
|
// Run per-route context factory (defineContext export) before loaders.
|
|
142
169
|
const routeContext = await runRouteContext(
|
|
143
170
|
chain.route as Parameters<typeof runRouteContext>[0],
|
|
144
171
|
request,
|
|
145
172
|
match.params,
|
|
146
|
-
context,
|
|
173
|
+
mwCtx.context,
|
|
147
174
|
);
|
|
148
175
|
const args = buildLoaderArgs(request, match.params, routeContext, search);
|
|
149
176
|
|
|
@@ -227,6 +254,10 @@ async function route(
|
|
|
227
254
|
const RouteComponent = routeSsr === true ? chain.route.default : chain.route.Fallback;
|
|
228
255
|
const ssrMode = routeSsr === true ? undefined : routeSsr === false ? "client-only" as const : "data-only" as const;
|
|
229
256
|
|
|
257
|
+
// useMatches() payload — the chain's handle + data, for breadcrumbs etc.
|
|
258
|
+
// Built from loaderChain so the loader slices line up with what ran.
|
|
259
|
+
const matches = buildMatches(loaderChain, loaderResults, match.params, pathname);
|
|
260
|
+
|
|
230
261
|
// Wrap root in BractJSProvider so <Outlet> can render the route component
|
|
231
262
|
// server-side without needing a ClientRouter.
|
|
232
263
|
const shell = createElement(
|
|
@@ -241,12 +272,17 @@ async function route(
|
|
|
241
272
|
RouteComponent,
|
|
242
273
|
location: { pathname, search: url.search, hash: "", state: null, key: "default" },
|
|
243
274
|
search,
|
|
275
|
+
matches,
|
|
244
276
|
},
|
|
245
277
|
children: createElement(RootComponent),
|
|
246
278
|
},
|
|
247
279
|
);
|
|
248
280
|
|
|
249
281
|
const meta = resolveMeta(chain, loaderResults, match.params);
|
|
282
|
+
// Route `headers()` chain (Cache-Control/ETag/Vary/…), applied on top of the
|
|
283
|
+
// baseline document headers in renderRoute. Uses the loaders that actually
|
|
284
|
+
// ran (loaderChain) so a selective-SSR route's headers() sees the same data.
|
|
285
|
+
const routeHeaders = resolveHeaders(loaderChain, loaderResults, match.params, request);
|
|
250
286
|
|
|
251
287
|
return renderRoute({
|
|
252
288
|
shell,
|
|
@@ -257,9 +293,13 @@ async function route(
|
|
|
257
293
|
search,
|
|
258
294
|
manifest,
|
|
259
295
|
meta,
|
|
296
|
+
matches,
|
|
297
|
+
headers: routeHeaders,
|
|
260
298
|
routeFile: match.routeFile.filePath,
|
|
261
299
|
// Set by the opt-in csp() middleware; undefined otherwise.
|
|
262
|
-
nonce: getCspNonce(context),
|
|
300
|
+
nonce: getCspNonce(mwCtx.context),
|
|
263
301
|
ssrMode,
|
|
264
302
|
});
|
|
303
|
+
|
|
304
|
+
});
|
|
265
305
|
}
|
package/src/server/scanner.ts
CHANGED
|
@@ -2,7 +2,11 @@ import { basename } from "node:path";
|
|
|
2
2
|
|
|
3
3
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
4
4
|
|
|
5
|
-
export type Segment =
|
|
5
|
+
export type Segment =
|
|
6
|
+
| string
|
|
7
|
+
| { param: string }
|
|
8
|
+
| { optional: string }
|
|
9
|
+
| { catchAll: string };
|
|
6
10
|
|
|
7
11
|
export interface RouteFile {
|
|
8
12
|
filePath: string;
|
|
@@ -12,12 +16,22 @@ export interface RouteFile {
|
|
|
12
16
|
|
|
13
17
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
14
18
|
|
|
19
|
+
/** A path segment that is a route group: `(marketing)`. Contributes a layout
|
|
20
|
+
* folder but no URL segment. */
|
|
21
|
+
export function isRouteGroupSegment(seg: string): boolean {
|
|
22
|
+
return seg.startsWith("(") && seg.endsWith(")") && seg.length > 2;
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
export function pathToSegments(pattern: string): Segment[] {
|
|
16
26
|
if (pattern === "") return [];
|
|
17
27
|
return pattern.split("/").map((seg) => {
|
|
18
28
|
if (seg.startsWith("[...") && seg.endsWith("]")) {
|
|
19
29
|
return { catchAll: seg.slice(4, -1) };
|
|
20
30
|
}
|
|
31
|
+
// Optional param: [[id]] → matches with or without the segment present.
|
|
32
|
+
if (seg.startsWith("[[") && seg.endsWith("]]")) {
|
|
33
|
+
return { optional: seg.slice(2, -2) };
|
|
34
|
+
}
|
|
21
35
|
if (seg.startsWith("[") && seg.endsWith("]")) {
|
|
22
36
|
return { param: seg.slice(1, -1) };
|
|
23
37
|
}
|
|
@@ -29,20 +43,48 @@ export function filePathToPattern(filePath: string): string {
|
|
|
29
43
|
// Strip "routes/" prefix and file extension
|
|
30
44
|
let path = filePath.replace(/^routes\//, "").replace(/\.(tsx|ts)$/, "");
|
|
31
45
|
|
|
46
|
+
// Drop route-group segments — `(marketing)/about` → `about`. They group
|
|
47
|
+
// files (and their layout.tsx) without adding a URL segment.
|
|
48
|
+
path = path
|
|
49
|
+
.split("/")
|
|
50
|
+
.filter((seg) => !isRouteGroupSegment(seg))
|
|
51
|
+
.join("/");
|
|
52
|
+
|
|
32
53
|
// Handle nested _index (e.g. blog/_index → blog)
|
|
33
54
|
path = path.replace(/\/_index$/, "");
|
|
34
55
|
|
|
35
56
|
// Handle root _index
|
|
36
57
|
if (path === "_index" || path === "") return "";
|
|
37
58
|
|
|
38
|
-
// Convert [param] and [...catchAll] segments — keep as-is for
|
|
59
|
+
// Convert [param], [[optional]], and [...catchAll] segments — keep as-is for
|
|
60
|
+
// the pattern string.
|
|
39
61
|
return path;
|
|
40
62
|
}
|
|
41
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Ancestor directory chain (relative to `routes/`) for a route file, outermost
|
|
66
|
+
* → innermost, used to locate nesting `layout.tsx` files. Derived from the FILE
|
|
67
|
+
* path (not the URL pattern) so route-group folders like `(marketing)` are
|
|
68
|
+
* included — their layout wraps children even though they add no URL segment.
|
|
69
|
+
*
|
|
70
|
+
* `routes/(marketing)/blog/[id].tsx` → `["(marketing)", "(marketing)/blog"]`.
|
|
71
|
+
*/
|
|
72
|
+
export function layoutDirsFromFilePath(filePath: string): string[] {
|
|
73
|
+
const rel = filePath.replace(/^routes\//, "").replace(/\.(tsx|ts)$/, "");
|
|
74
|
+
const parts = rel.split("/");
|
|
75
|
+
parts.pop(); // drop the file's own basename — only ancestor dirs hold layouts
|
|
76
|
+
const dirs: string[] = [];
|
|
77
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
78
|
+
dirs.push(parts.slice(0, i).join("/"));
|
|
79
|
+
}
|
|
80
|
+
return dirs;
|
|
81
|
+
}
|
|
82
|
+
|
|
42
83
|
function segmentScore(seg: Segment): number {
|
|
43
84
|
if (typeof seg === "string") return 0; // static
|
|
44
85
|
if ("param" in seg) return 1; // dynamic
|
|
45
|
-
return 2;
|
|
86
|
+
if ("optional" in seg) return 2; // optional dynamic
|
|
87
|
+
return 3; // catch-all
|
|
46
88
|
}
|
|
47
89
|
|
|
48
90
|
function routeScore(route: RouteFile): number {
|
package/src/server/search.ts
CHANGED
|
@@ -8,7 +8,11 @@ import { runSchema, type Schema } from "./validate.ts";
|
|
|
8
8
|
* flattens FormData.
|
|
9
9
|
*/
|
|
10
10
|
export function searchParamsToObject(sp: URLSearchParams): Record<string, string | string[]> {
|
|
11
|
-
|
|
11
|
+
// Null-prototype so a query param named "__proto__" (?__proto__=x) can't
|
|
12
|
+
// pollute Object.prototype when the result is later spread/merged. Using a
|
|
13
|
+
// plain {} here would make `out["__proto__"] = …` a no-op AND, for nested
|
|
14
|
+
// merges downstream, a pollution vector. SECURITY: see proto-guard.ts.
|
|
15
|
+
const out = Object.create(null) as Record<string, string | string[]>;
|
|
12
16
|
for (const [key, value] of sp.entries()) {
|
|
13
17
|
const existing = out[key];
|
|
14
18
|
if (existing === undefined) out[key] = value;
|
package/src/server/serve.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { scanRoutes, type RouteFile } from "./scanner.ts";
|
|
2
2
|
import { buildTrie, matchRoute } from "./matcher.ts";
|
|
3
3
|
import { handleRequest, type HandlerConfig } from "./request-handler.ts";
|
|
4
|
+
import { pipeline, type MiddlewareContext } from "./middleware.ts";
|
|
4
5
|
import { renderSpaShell } from "./spa.ts";
|
|
5
6
|
import { type ServerManifest } from "./render.ts";
|
|
6
7
|
import { isDevRuntime, isExplicitDev } from "./env.ts";
|
|
@@ -52,6 +53,14 @@ export interface BractJSConfig {
|
|
|
52
53
|
buildDir?: string;
|
|
53
54
|
/** Directory for transformed image cache. Defaults to .bract-image-cache */
|
|
54
55
|
imageCacheDir?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Hard ceiling (bytes) on the size of any incoming request body, enforced by
|
|
58
|
+
* the Bun adapter regardless of the advertised Content-Length. Defaults to
|
|
59
|
+
* 16 MiB — above the 10 MiB route-form cap so normal requests pass while a
|
|
60
|
+
* single client can't stream an unbounded body into memory. Raise it for a
|
|
61
|
+
* dedicated large-upload endpoint. Only applies to the default Bun adapter.
|
|
62
|
+
*/
|
|
63
|
+
maxRequestBodySize?: number;
|
|
55
64
|
/** Called once after the server starts listening. Use to open DB connections, warm caches, etc. */
|
|
56
65
|
onStart?: () => Promise<void> | void;
|
|
57
66
|
/** Called before the process exits (any signal or uncaught error). Use to close DB connections, flush queues, etc. */
|
|
@@ -169,7 +178,13 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
|
|
|
169
178
|
return Bun.file(join(buildDir, "client", "_prerender", relHtmlOrJson));
|
|
170
179
|
}
|
|
171
180
|
|
|
172
|
-
|
|
181
|
+
// The full per-request dispatch: special endpoints (API, actions, stream,
|
|
182
|
+
// image, static, prerender) first, then the SSR route handler. Runs INSIDE
|
|
183
|
+
// the global middleware pipeline (see the returned `fetch` below), so
|
|
184
|
+
// `pipeline.use(cors()/csp()/auth/…)` governs every response — not just SSR
|
|
185
|
+
// documents. `context` is the shared mutable object threaded through the
|
|
186
|
+
// pipeline; route-level middleware and getCspNonce() read the same object.
|
|
187
|
+
async function dispatch(request: Request, context: Record<string, unknown>): Promise<Response> {
|
|
173
188
|
const url = new URL(request.url);
|
|
174
189
|
const { pathname } = url;
|
|
175
190
|
|
|
@@ -285,7 +300,17 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
|
|
|
285
300
|
|
|
286
301
|
const manifest = isDevRuntime() ? await readDevManifest(buildDir) : await manifestReady;
|
|
287
302
|
const handlerConfig: HandlerConfig = { appDir, publicDir, manifest, onError, moduleRegistry };
|
|
288
|
-
return handleRequest(request, trie, handlerConfig);
|
|
303
|
+
return handleRequest(request, trie, handlerConfig, context);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return async function fetch(request: Request): Promise<Response> {
|
|
307
|
+
// Run the global middleware pipeline around the ENTIRE dispatch so
|
|
308
|
+
// cors()/csp()/logging/auth attached via `pipeline.use(...)` apply to
|
|
309
|
+
// API routes, server actions, /_stream, /_image and static assets — not
|
|
310
|
+
// only SSR documents. The per-route (nested) middleware chain still runs
|
|
311
|
+
// inside handleRequest for SSR/_data, sharing this same `context` object.
|
|
312
|
+
const ctx: MiddlewareContext = { request, params: {}, context: {} };
|
|
313
|
+
return pipeline.run(ctx, () => dispatch(request, ctx.context));
|
|
289
314
|
};
|
|
290
315
|
}
|
|
291
316
|
|
|
@@ -330,7 +355,7 @@ export function createServer(config?: Partial<BractJSConfig>): {
|
|
|
330
355
|
const fetchHandler = buildFetchHandler(config ?? {});
|
|
331
356
|
|
|
332
357
|
// Use provided adapter or fall back to the default Bun adapter.
|
|
333
|
-
const adapter = config?.adapter ?? new BunAdapter();
|
|
358
|
+
const adapter = config?.adapter ?? new BunAdapter(config?.maxRequestBodySize);
|
|
334
359
|
|
|
335
360
|
if (adapter instanceof BunAdapter) {
|
|
336
361
|
adapter.setHandler(fetchHandler);
|
package/src/server/session.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { hasForbiddenKey } from "./proto-guard.ts";
|
|
2
|
+
|
|
1
3
|
export type SessionData = Record<string, unknown>;
|
|
2
4
|
|
|
3
5
|
export interface Session {
|
|
@@ -37,7 +39,16 @@ function encode(data: SessionData): string {
|
|
|
37
39
|
|
|
38
40
|
function decode(encoded: string): SessionData {
|
|
39
41
|
const pad = "=".repeat((4 - (encoded.length % 4)) % 4);
|
|
40
|
-
|
|
42
|
+
const parsed = JSON.parse(
|
|
43
|
+
atob(encoded.replace(/-/g, "+").replace(/_/g, "/") + pad),
|
|
44
|
+
) as SessionData;
|
|
45
|
+
// Defense-in-depth: the payload is HMAC-verified before we get here, so this
|
|
46
|
+
// only matters if a signing secret leaks — but a session blob carrying a
|
|
47
|
+
// "__proto__" key must never pollute Object.prototype when read/spread.
|
|
48
|
+
if (hasForbiddenKey(parsed)) {
|
|
49
|
+
throw new Error("session: forbidden key in payload");
|
|
50
|
+
}
|
|
51
|
+
return parsed;
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
async function sign(data: string, secret: string): Promise<string> {
|
package/src/server/validate.ts
CHANGED
|
@@ -33,7 +33,10 @@ export class ValidationError extends Error {
|
|
|
33
33
|
|
|
34
34
|
function toPlainObject(input: FormData | Record<string, unknown>): Record<string, unknown> {
|
|
35
35
|
if (input instanceof FormData) {
|
|
36
|
-
|
|
36
|
+
// Null-prototype: a form field literally named "__proto__" becomes a plain
|
|
37
|
+
// own key here instead of mutating Object.prototype when the result is
|
|
38
|
+
// later spread/merged. SECURITY: see src/server/proto-guard.ts.
|
|
39
|
+
const out = Object.create(null) as Record<string, unknown>;
|
|
37
40
|
for (const [key, value] of input.entries()) {
|
|
38
41
|
if (key in out) {
|
|
39
42
|
const existing = out[key];
|
package/src/shared/context.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createContext, useContext, createElement, type ComponentType, type ReactNode } from "react";
|
|
2
|
-
import type { RouterLocation } from "./route-types.ts";
|
|
2
|
+
import type { RouterLocation, RouteMatch } from "./route-types.ts";
|
|
3
3
|
|
|
4
4
|
export interface RouteManifest {
|
|
5
5
|
[routeId: string]: {
|
|
@@ -20,6 +20,8 @@ export interface BractJSContextValue {
|
|
|
20
20
|
location?: RouterLocation;
|
|
21
21
|
/** Validated search params (route `searchSchema` output), so `useSearch()` works during SSR. */
|
|
22
22
|
search?: Record<string, unknown>;
|
|
23
|
+
/** The matched route chain (root → layouts → route) for `useMatches()`. */
|
|
24
|
+
matches?: RouteMatch[];
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export const BractJSContext = createContext<BractJSContextValue>(null!);
|