@bractjs/bractjs 0.1.27 → 0.1.29
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/bin/cli.ts +18 -1
- package/package.json +3 -2
- package/src/__tests__/codegen-write.test.ts +67 -0
- package/src/__tests__/codegen.test.ts +29 -2
- package/src/__tests__/compile-safety.test.ts +4 -0
- package/src/__tests__/csp.test.ts +10 -0
- package/src/__tests__/define-actions.test.ts +69 -0
- package/src/__tests__/env.test.ts +18 -0
- package/src/__tests__/fetcher-store.test.ts +67 -0
- package/src/__tests__/fixtures/app/root.tsx +7 -2
- package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
- package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
- package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
- package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
- package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
- package/src/__tests__/form-data-helpers.test.ts +43 -0
- package/src/__tests__/headers.test.ts +111 -0
- package/src/__tests__/integration.test.ts +90 -0
- package/src/__tests__/layout-registry.test.ts +7 -3
- package/src/__tests__/loader.test.ts +32 -1
- package/src/__tests__/matcher.test.ts +29 -0
- package/src/__tests__/module-registry.test.ts +2 -3
- package/src/__tests__/nav-utils.test.ts +46 -0
- package/src/__tests__/prerender.test.ts +102 -0
- package/src/__tests__/programmatic-api.test.ts +20 -1
- package/src/__tests__/revalidation.test.ts +65 -0
- package/src/__tests__/route-lint.test.ts +79 -0
- package/src/__tests__/route-middleware.test.ts +84 -0
- package/src/__tests__/route-table.test.ts +33 -0
- package/src/__tests__/safe-validate.test.ts +96 -0
- package/src/__tests__/scanner.test.ts +46 -1
- package/src/__tests__/scroll-restoration.test.ts +66 -0
- package/src/__tests__/search-serializer.test.ts +42 -0
- package/src/__tests__/search-validation.test.ts +125 -0
- package/src/__tests__/security-fixes.test.ts +201 -0
- package/src/__tests__/security.test.ts +110 -1
- package/src/__tests__/selective-ssr.test.ts +85 -0
- package/src/__tests__/spa-mode.test.ts +77 -0
- package/src/__tests__/typed-routing.test.ts +51 -1
- package/src/__tests__/use-matches.test.ts +54 -0
- package/src/build/bundler.ts +33 -0
- package/src/build/prerender.ts +88 -0
- package/src/build/route-lint.ts +49 -0
- package/src/client/ClientRouter.tsx +339 -47
- package/src/client/cache.ts +8 -0
- package/src/client/components/Await.tsx +9 -2
- package/src/client/components/Form.tsx +23 -34
- package/src/client/components/Link.tsx +80 -9
- package/src/client/components/Outlet.tsx +8 -2
- package/src/client/components/ScrollRestoration.tsx +125 -0
- package/src/client/entry.tsx +39 -2
- package/src/client/fetcher-store.ts +61 -0
- package/src/client/form-utils.ts +3 -0
- package/src/client/hooks/useActionData.ts +7 -3
- package/src/client/hooks/useFetcher.ts +116 -33
- package/src/client/hooks/useFetchers.ts +23 -0
- package/src/client/hooks/useLoaderData.ts +8 -4
- package/src/client/hooks/useLocation.ts +27 -0
- package/src/client/hooks/useMatches.ts +32 -0
- package/src/client/hooks/useNavigate.ts +11 -6
- package/src/client/hooks/useRevalidator.ts +26 -0
- package/src/client/hooks/useSearch.ts +73 -0
- package/src/client/hooks/useSearchParams.ts +7 -2
- package/src/client/nav-utils.ts +26 -0
- package/src/client/prefetch.ts +110 -15
- package/src/client/registry.ts +24 -0
- package/src/client/revalidation.ts +25 -0
- package/src/client/router.tsx +34 -1
- package/src/client/rpc.ts +11 -1
- package/src/client/scroll-restoration.ts +48 -0
- package/src/client/search-serializer.ts +40 -0
- package/src/client/types.ts +6 -0
- package/src/codegen/module-registry.ts +13 -21
- package/src/codegen/route-codegen.ts +148 -10
- package/src/config/load.ts +22 -0
- package/src/dev/hmr-client.ts +3 -1
- package/src/dev/route-table.ts +27 -0
- package/src/dev/server.ts +106 -8
- package/src/dev/watcher.ts +25 -3
- package/src/index.ts +38 -6
- package/src/server/action-handler.ts +3 -13
- package/src/server/action-registry.ts +35 -0
- package/src/server/adapter.ts +16 -0
- package/src/server/api-route.ts +47 -0
- package/src/server/csp.ts +19 -4
- package/src/server/csrf.ts +36 -3
- package/src/server/env.ts +26 -5
- package/src/server/headers.ts +49 -0
- package/src/server/layout.ts +43 -20
- package/src/server/loader.ts +14 -8
- 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 +51 -18
- package/src/server/request-handler.ts +111 -29
- package/src/server/scanner.ts +45 -3
- package/src/server/search.ts +47 -0
- package/src/server/serve.ts +116 -4
- package/src/server/session.ts +12 -1
- package/src/server/spa.ts +62 -0
- package/src/server/stream-handler.ts +10 -1
- package/src/server/validate.ts +89 -14
- package/src/shared/context.ts +7 -0
- package/src/shared/define-actions.ts +39 -0
- package/src/shared/form-data.ts +34 -0
- package/src/shared/route-types.ts +191 -2
- package/templates/new-app/app/root.tsx +2 -1
- package/templates/new-app/bractjs.config.ts +7 -12
- package/types/config.d.ts +24 -0
- package/types/index.d.ts +182 -9
- package/types/route.d.ts +138 -3
- package/LICENSE +0 -21
- package/README.md +0 -1125
package/src/server/loader.ts
CHANGED
|
@@ -21,6 +21,7 @@ export async function safeRun<T>(
|
|
|
21
21
|
fn: ((args: LoaderArgs) => Promise<T> | T) | undefined,
|
|
22
22
|
args: LoaderArgs,
|
|
23
23
|
onError?: OnErrorHook,
|
|
24
|
+
where?: string,
|
|
24
25
|
): Promise<T | { __error: unknown } | null> {
|
|
25
26
|
if (!fn) return null;
|
|
26
27
|
|
|
@@ -36,12 +37,14 @@ export async function safeRun<T>(
|
|
|
36
37
|
// dev we surface the real message + stack for DX. Routes wanting to
|
|
37
38
|
// surface structured user-facing errors should throw an HttpError, not
|
|
38
39
|
// a custom Error subclass.
|
|
39
|
-
|
|
40
|
+
// Name the failing module so the log/overlay points at the right file.
|
|
41
|
+
console.error(`[bractjs] loader error${where ? ` in ${where}` : ""}:`, err);
|
|
40
42
|
await fireOnError(onError, err, args.request);
|
|
41
43
|
const safe = isExplicitDev()
|
|
42
44
|
? {
|
|
43
45
|
message: err instanceof Error ? err.message : String(err),
|
|
44
46
|
stack: err instanceof Error ? err.stack : undefined,
|
|
47
|
+
routeFile: where,
|
|
45
48
|
}
|
|
46
49
|
: { message: "Internal Server Error" };
|
|
47
50
|
return { __error: safe };
|
|
@@ -60,7 +63,7 @@ export async function runBeforeLoad(
|
|
|
60
63
|
args: LoaderArgs,
|
|
61
64
|
): Promise<Response | null> {
|
|
62
65
|
const fn = routeModule.beforeLoad as
|
|
63
|
-
| ((a: { params: Record<string,string>; context: Record<string,unknown>; location: { pathname: string; search: string } }) => unknown)
|
|
66
|
+
| ((a: { params: Record<string,string>; context: Record<string,unknown>; location: { pathname: string; search: string }; search?: Record<string, unknown> }) => unknown)
|
|
64
67
|
| undefined;
|
|
65
68
|
if (!fn) return null;
|
|
66
69
|
const url = new URL(args.request.url);
|
|
@@ -68,6 +71,7 @@ export async function runBeforeLoad(
|
|
|
68
71
|
params: args.params,
|
|
69
72
|
context: args.context,
|
|
70
73
|
location: { pathname: url.pathname, search: url.search },
|
|
74
|
+
search: args.search,
|
|
71
75
|
});
|
|
72
76
|
if (result instanceof Response) return result;
|
|
73
77
|
return null;
|
|
@@ -83,13 +87,14 @@ export async function runLoaders(
|
|
|
83
87
|
// Run every loader in the chain concurrently — root, all layouts, and the
|
|
84
88
|
// route loader. The route loader is usually the slowest and most important
|
|
85
89
|
// one, so it must not be serialized behind the layout wave.
|
|
86
|
-
const
|
|
87
|
-
|
|
90
|
+
const files = chain.files;
|
|
91
|
+
const layoutLoaders = chain.layouts.map((mod, i) =>
|
|
92
|
+
safeRun(mod.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError, files?.layouts[i])
|
|
88
93
|
);
|
|
89
94
|
|
|
90
95
|
const [root, route, ...layoutResults] = await Promise.all([
|
|
91
|
-
safeRun(chain.root.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError),
|
|
92
|
-
safeRun(chain.route.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError),
|
|
96
|
+
safeRun(chain.root.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError, files?.root),
|
|
97
|
+
safeRun(chain.route.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError, files?.route),
|
|
93
98
|
...layoutLoaders,
|
|
94
99
|
]);
|
|
95
100
|
|
|
@@ -118,9 +123,10 @@ export async function runAction(
|
|
|
118
123
|
export function buildLoaderArgs(
|
|
119
124
|
request: Request,
|
|
120
125
|
params: Record<string, string>,
|
|
121
|
-
context: Record<string, unknown
|
|
126
|
+
context: Record<string, unknown>,
|
|
127
|
+
search: Record<string, unknown> = {},
|
|
122
128
|
): LoaderArgs {
|
|
123
|
-
return { request, params, context };
|
|
129
|
+
return { request, params, context, search };
|
|
124
130
|
}
|
|
125
131
|
|
|
126
132
|
// ── runRouteContext ────────────────────────────────────────────────────────
|
package/src/server/matcher.ts
CHANGED
|
@@ -5,6 +5,13 @@ import type { RouteFile, Segment } from "./scanner.ts";
|
|
|
5
5
|
export interface TrieNode {
|
|
6
6
|
children: Map<string, TrieNode>;
|
|
7
7
|
paramChild?: { name: string; node: TrieNode };
|
|
8
|
+
/**
|
|
9
|
+
* An optional param segment (`[[id]]`). When present it behaves like a param
|
|
10
|
+
* child (binds `name` to the consumed part); the matcher additionally tries
|
|
11
|
+
* skipping it entirely, so the route at `node` matches with the segment
|
|
12
|
+
* absent too (the param is then simply not set).
|
|
13
|
+
*/
|
|
14
|
+
optionalChild?: { name: string; node: TrieNode };
|
|
8
15
|
catchAllChild?: { name: string; node: TrieNode };
|
|
9
16
|
routeFile?: RouteFile;
|
|
10
17
|
}
|
|
@@ -33,6 +40,9 @@ export function buildTrie(routes: RouteFile[]): TrieNode {
|
|
|
33
40
|
} else if ("param" in seg) {
|
|
34
41
|
if (!node.paramChild) node.paramChild = { name: seg.param, node: makeNode() };
|
|
35
42
|
node = node.paramChild.node;
|
|
43
|
+
} else if ("optional" in seg) {
|
|
44
|
+
if (!node.optionalChild) node.optionalChild = { name: seg.optional, node: makeNode() };
|
|
45
|
+
node = node.optionalChild.node;
|
|
36
46
|
} else {
|
|
37
47
|
// catchAll — terminal, store and stop
|
|
38
48
|
if (!node.catchAllChild) node.catchAllChild = { name: seg.catchAll, node: makeNode() };
|
|
@@ -62,7 +72,13 @@ function walk(
|
|
|
62
72
|
): MatchResult {
|
|
63
73
|
// All parts consumed — check for route at this node
|
|
64
74
|
if (idx === parts.length) {
|
|
65
|
-
|
|
75
|
+
if (node.routeFile) return { routeFile: node.routeFile, params };
|
|
76
|
+
// An optional param's segment was omitted (e.g. /users for [[id]]). The
|
|
77
|
+
// route lives one node deeper; the param is simply left unset.
|
|
78
|
+
if (node.optionalChild?.node.routeFile) {
|
|
79
|
+
return { routeFile: node.optionalChild.node.routeFile, params };
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
const part = parts[idx];
|
|
@@ -83,7 +99,18 @@ function walk(
|
|
|
83
99
|
if (result) return result;
|
|
84
100
|
}
|
|
85
101
|
|
|
86
|
-
// 3. Try
|
|
102
|
+
// 3. Try optional param — consume this part as the param (the "present"
|
|
103
|
+
// case). The "absent" case is handled at the all-parts-consumed branch
|
|
104
|
+
// above. Param-before-catch-all keeps optional more specific than splat.
|
|
105
|
+
if (node.optionalChild) {
|
|
106
|
+
const result = walk(node.optionalChild.node, parts, idx + 1, {
|
|
107
|
+
...params,
|
|
108
|
+
[node.optionalChild.name]: part,
|
|
109
|
+
});
|
|
110
|
+
if (result) return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 4. Try catch-all — consumes remaining segments
|
|
87
114
|
if (node.catchAllChild) {
|
|
88
115
|
const remaining = parts.slice(idx).join("/");
|
|
89
116
|
const catchNode = node.catchAllChild.node;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { LayoutChain } from "./layout.ts";
|
|
2
|
+
import type { LoaderResults } from "./loader.ts";
|
|
3
|
+
import type { RouteMatch } from "../shared/route-types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build the `useMatches()` payload: one entry per module in the chain
|
|
7
|
+
* (root → layouts → route), pairing each module's static `handle` export with
|
|
8
|
+
* its loader-data slice. Serialized into the SSR bootstrap and `/_data` so the
|
|
9
|
+
* client can read it without re-importing every module.
|
|
10
|
+
*
|
|
11
|
+
* `handle` must be JSON-serializable to survive the SSR/soft-nav transport —
|
|
12
|
+
* the same constraint loader data already has.
|
|
13
|
+
*/
|
|
14
|
+
export function buildMatches(
|
|
15
|
+
chain: LayoutChain,
|
|
16
|
+
loaderData: LoaderResults,
|
|
17
|
+
params: Record<string, string>,
|
|
18
|
+
pathname: string,
|
|
19
|
+
): RouteMatch[] {
|
|
20
|
+
const matches: RouteMatch[] = [];
|
|
21
|
+
const files = chain.files;
|
|
22
|
+
|
|
23
|
+
matches.push({
|
|
24
|
+
id: files?.root ?? "root",
|
|
25
|
+
pathname,
|
|
26
|
+
params,
|
|
27
|
+
data: loaderData.root,
|
|
28
|
+
handle: chain.root.handle,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
chain.layouts.forEach((mod, i) => {
|
|
32
|
+
matches.push({
|
|
33
|
+
id: files?.layouts?.[i] ?? `layout:${i}`,
|
|
34
|
+
pathname,
|
|
35
|
+
params,
|
|
36
|
+
data: loaderData.layouts[i] ?? null,
|
|
37
|
+
handle: mod.handle,
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
matches.push({
|
|
42
|
+
id: files?.route ?? "route",
|
|
43
|
+
pathname,
|
|
44
|
+
params,
|
|
45
|
+
data: loaderData.route,
|
|
46
|
+
handle: chain.route.handle,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return matches;
|
|
50
|
+
}
|
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,7 +1,7 @@
|
|
|
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";
|
|
4
|
-
import { safeStringify, isDevRuntime } from "./env.ts";
|
|
3
|
+
import type { MetaDescriptor, RouteMatch } from "../shared/route-types.ts";
|
|
4
|
+
import { safeStringify, isDevRuntime, getDevHmrPort } from "./env.ts";
|
|
5
5
|
import { errorOverlayScript } from "../dev/error-overlay.ts";
|
|
6
6
|
import { mergeMeta } from "./meta.ts";
|
|
7
7
|
import { MetaTags } from "../shared/meta-tags.tsx";
|
|
@@ -18,13 +18,29 @@ export interface RenderOptions {
|
|
|
18
18
|
actionData: unknown;
|
|
19
19
|
params: Record<string, string>;
|
|
20
20
|
pathname: string;
|
|
21
|
+
/** Validated search params — hydrates `useSearch()` so the client never re-validates. */
|
|
22
|
+
search?: Record<string, unknown>;
|
|
21
23
|
manifest: ServerManifest;
|
|
22
24
|
meta: MetaDescriptor[];
|
|
25
|
+
/** The matched route chain (root → layouts → route) for `useMatches()`. */
|
|
26
|
+
matches?: RouteMatch[];
|
|
23
27
|
status?: number;
|
|
24
28
|
/** Path of the matched route file (e.g. "routes/_index.tsx"), used by the client to pre-import the module before hydration. */
|
|
25
29
|
routeFile?: string;
|
|
26
30
|
/** Per-request CSP nonce (set by the opt-in `csp()` middleware). Applied to the inline bootstrap script + client entry module tags. */
|
|
27
31
|
nonce?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Set when the document did NOT SSR the route component: the client renders
|
|
34
|
+
* the Fallback during hydration, then swaps in the real component
|
|
35
|
+
* ("data-only": data already present; "client-only": after a /_data fetch;
|
|
36
|
+
* "spa": static shell, everything resolved client-side).
|
|
37
|
+
*/
|
|
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;
|
|
28
44
|
}
|
|
29
45
|
|
|
30
46
|
export async function renderRoute(options: RenderOptions): Promise<Response> {
|
|
@@ -38,13 +54,19 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
|
|
|
38
54
|
status = 200,
|
|
39
55
|
} = options;
|
|
40
56
|
|
|
41
|
-
|
|
57
|
+
// In dev, publish the HMR port so the injected client connects to the
|
|
58
|
+
// configured `hmrPort` rather than a hardcoded 3001. 0 → omit (client
|
|
59
|
+
// defaults to 3001).
|
|
60
|
+
const hmrPort = isDevRuntime() ? getDevHmrPort() : 0;
|
|
61
|
+
const devFlag = isDevRuntime()
|
|
62
|
+
? "window.__BRACT_DEV__=true;" + (hmrPort ? `window.__BRACTJS_HMR_PORT__=${hmrPort};` : "")
|
|
63
|
+
: "";
|
|
42
64
|
const devOverlay = isDevRuntime() ? devFlag + errorOverlayScript + "\n" : "";
|
|
43
65
|
const mergedMeta = mergeMeta(options.meta ?? []);
|
|
44
66
|
// The merged descriptor array is what the client reads to keep the document
|
|
45
67
|
// head in sync on soft navigation — keep it shaped, not stringified HTML.
|
|
46
68
|
const bootstrapScriptContent =
|
|
47
|
-
devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, manifest, routeFile: options.routeFile, meta: mergedMeta })};`;
|
|
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 })};`;
|
|
48
70
|
|
|
49
71
|
// Render <title>/<meta> elements alongside the app shell. React 19 hoists
|
|
50
72
|
// document-metadata elements into <head> during streaming SSR, so crawlers
|
|
@@ -75,19 +97,30 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
|
|
|
75
97
|
|
|
76
98
|
const responseStatus = renderError ? 500 : status;
|
|
77
99
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
"X-Frame-Options": "SAMEORIGIN",
|
|
90
|
-
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
91
|
-
},
|
|
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",
|
|
92
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 });
|
|
93
126
|
}
|
|
@@ -3,14 +3,17 @@ import type { TrieNode } from "./matcher.ts";
|
|
|
3
3
|
import { matchRoute } from "./matcher.ts";
|
|
4
4
|
import { resolveRouteChain, type ModuleRegistry } from "./layout.ts";
|
|
5
5
|
import { runLoaders, runAction, buildLoaderArgs, runRouteContext, runBeforeLoad } from "./loader.ts";
|
|
6
|
+
import { validateSearch } from "./search.ts";
|
|
6
7
|
import { renderRoute, type ServerManifest } from "./render.ts";
|
|
7
|
-
import { resolveMeta } from "./meta.ts";
|
|
8
|
+
import { resolveMeta, mergeMeta } from "./meta.ts";
|
|
9
|
+
import { resolveHeaders } from "./headers.ts";
|
|
10
|
+
import { buildMatches } from "./matches.ts";
|
|
8
11
|
import { json, error, sanitizeRedirect } from "./response.ts";
|
|
9
12
|
import { isRedirect, isHttpError } from "../shared/errors.ts";
|
|
10
13
|
import { isExplicitDev } from "./env.ts";
|
|
11
|
-
import {
|
|
14
|
+
import { runRouteMiddleware, collectRouteMiddleware, type MiddlewareContext } from "./middleware.ts";
|
|
12
15
|
import { BractJSProvider } from "../shared/context.ts";
|
|
13
|
-
import { isAllowedMutation } from "./csrf.ts";
|
|
16
|
+
import { isAllowedMutation, csrfForbiddenResponse } from "./csrf.ts";
|
|
14
17
|
import { getCspNonce } from "./csp.ts";
|
|
15
18
|
import { fireOnError, type OnErrorHook } from "./lifecycle.ts";
|
|
16
19
|
|
|
@@ -37,15 +40,15 @@ const MAX_FORM_BYTES = 10 * 1_048_576; // 10 MiB
|
|
|
37
40
|
export async function handleRequest(
|
|
38
41
|
request: Request,
|
|
39
42
|
trie: TrieNode,
|
|
40
|
-
config: HandlerConfig
|
|
43
|
+
config: HandlerConfig,
|
|
44
|
+
context: Record<string, unknown> = {},
|
|
41
45
|
): Promise<Response> {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
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);
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
async function route(
|
|
@@ -87,22 +90,51 @@ async function route(
|
|
|
87
90
|
headers: request.headers,
|
|
88
91
|
method: "GET",
|
|
89
92
|
});
|
|
93
|
+
// Validate search params before any route work runs — loaders must
|
|
94
|
+
// never see unvalidated input, and a 400 here is cheaper than a wasted
|
|
95
|
+
// context-factory/loader run. The thrown 400 Response propagates below.
|
|
96
|
+
const search = await validateSearch(chain.route.searchSchema, targetUrl);
|
|
97
|
+
|
|
90
98
|
// SECURITY(high): /_data must run the same auth/redirect gates as a full
|
|
91
99
|
// page request — otherwise a SPA-style soft navigation to a protected
|
|
92
|
-
// route would bypass beforeLoad() / defineContext()
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
+
});
|
|
104
133
|
} catch (err) {
|
|
105
134
|
if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
|
|
135
|
+
// A non-redirect Response (e.g. the 400 thrown by search validation)
|
|
136
|
+
// is the intended reply — pass it through verbatim.
|
|
137
|
+
if (err instanceof Response) return err;
|
|
106
138
|
if (isHttpError(err)) return json({ error: err.message }, { status: err.status });
|
|
107
139
|
console.error("[bractjs] /_data error:", err);
|
|
108
140
|
await fireOnError(onError, err, request);
|
|
@@ -115,14 +147,32 @@ async function route(
|
|
|
115
147
|
if (!match) return error("Not Found", 404);
|
|
116
148
|
|
|
117
149
|
const chain = await resolveRouteChain(match.routeFile, appDir, moduleRegistry);
|
|
150
|
+
|
|
151
|
+
// Validate search params before any route work (context factory, beforeLoad,
|
|
152
|
+
// action, loaders) — they all receive the validated object.
|
|
153
|
+
let search: Record<string, unknown>;
|
|
154
|
+
try {
|
|
155
|
+
search = await validateSearch(chain.route.searchSchema, url);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err instanceof Response) return err;
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
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
|
+
|
|
118
168
|
// Run per-route context factory (defineContext export) before loaders.
|
|
119
169
|
const routeContext = await runRouteContext(
|
|
120
170
|
chain.route as Parameters<typeof runRouteContext>[0],
|
|
121
171
|
request,
|
|
122
172
|
match.params,
|
|
123
|
-
context,
|
|
173
|
+
mwCtx.context,
|
|
124
174
|
);
|
|
125
|
-
const args = buildLoaderArgs(request, match.params, routeContext);
|
|
175
|
+
const args = buildLoaderArgs(request, match.params, routeContext, search);
|
|
126
176
|
|
|
127
177
|
// ── beforeLoad ────────────────────────────────────────────────────────
|
|
128
178
|
const beforeLoadResponse = await runBeforeLoad(chain.route, args);
|
|
@@ -131,7 +181,7 @@ async function route(
|
|
|
131
181
|
// ── Action (mutating methods) ─────────────────────────────────────────
|
|
132
182
|
let actionData: unknown = null;
|
|
133
183
|
if (MUTATING_METHODS.has(request.method)) {
|
|
134
|
-
if (!isAllowedMutation(request)) return
|
|
184
|
+
if (!isAllowedMutation(request)) return csrfForbiddenResponse();
|
|
135
185
|
// Reject up front if the client advertises an oversized body.
|
|
136
186
|
const clRaw = request.headers.get("Content-Length");
|
|
137
187
|
if (clRaw) {
|
|
@@ -148,6 +198,8 @@ async function route(
|
|
|
148
198
|
} catch (err) {
|
|
149
199
|
if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
|
|
150
200
|
if (isHttpError(err)) return error(err.message, err.status);
|
|
201
|
+
// Name the failing route so the log points at the right file.
|
|
202
|
+
console.error(`[bractjs] action error in ${chain.files?.route ?? match.routeFile.filePath}:`, err);
|
|
151
203
|
await fireOnError(onError, err, request);
|
|
152
204
|
if (isExplicitDev()) return error(err instanceof Error ? err.message : String(err), 500);
|
|
153
205
|
return error("Internal Server Error", 500);
|
|
@@ -167,10 +219,20 @@ async function route(
|
|
|
167
219
|
}
|
|
168
220
|
}
|
|
169
221
|
|
|
222
|
+
// ── Selective SSR ─────────────────────────────────────────────────────
|
|
223
|
+
// `ssr: false` skips the ROUTE loader during document SSR (root/layout
|
|
224
|
+
// loaders still run — they render the shell). beforeLoad already ran above:
|
|
225
|
+
// it is the auth gate and must hold for every mode. The client completes
|
|
226
|
+
// the render via /_data after hydration, where the loader DOES run.
|
|
227
|
+
const routeSsr = chain.route.ssr ?? true;
|
|
228
|
+
const loaderChain = routeSsr === false
|
|
229
|
+
? { ...chain, route: { ...chain.route, loader: undefined } }
|
|
230
|
+
: chain;
|
|
231
|
+
|
|
170
232
|
// ── Loaders ───────────────────────────────────────────────────────────
|
|
171
233
|
let loaderResults;
|
|
172
234
|
try {
|
|
173
|
-
loaderResults = await runLoaders(
|
|
235
|
+
loaderResults = await runLoaders(loaderChain, args, onError);
|
|
174
236
|
} catch (err) {
|
|
175
237
|
if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
|
|
176
238
|
if (isHttpError(err)) return error(err.message, err.status);
|
|
@@ -187,7 +249,14 @@ async function route(
|
|
|
187
249
|
|
|
188
250
|
// ── SSR render ────────────────────────────────────────────────────────
|
|
189
251
|
const RootComponent = chain.root.default ?? (() => null);
|
|
190
|
-
|
|
252
|
+
// Non-default SSR modes render the Fallback (or nothing) in the component's
|
|
253
|
+
// place; the client swaps in the real component after hydration.
|
|
254
|
+
const RouteComponent = routeSsr === true ? chain.route.default : chain.route.Fallback;
|
|
255
|
+
const ssrMode = routeSsr === true ? undefined : routeSsr === false ? "client-only" as const : "data-only" as const;
|
|
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);
|
|
191
260
|
|
|
192
261
|
// Wrap root in BractJSProvider so <Outlet> can render the route component
|
|
193
262
|
// server-side without needing a ClientRouter.
|
|
@@ -201,12 +270,19 @@ async function route(
|
|
|
201
270
|
pathname,
|
|
202
271
|
manifest: manifest as unknown as import("../shared/context.ts").RouteManifest,
|
|
203
272
|
RouteComponent,
|
|
273
|
+
location: { pathname, search: url.search, hash: "", state: null, key: "default" },
|
|
274
|
+
search,
|
|
275
|
+
matches,
|
|
204
276
|
},
|
|
205
277
|
children: createElement(RootComponent),
|
|
206
278
|
},
|
|
207
279
|
);
|
|
208
280
|
|
|
209
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);
|
|
210
286
|
|
|
211
287
|
return renderRoute({
|
|
212
288
|
shell,
|
|
@@ -214,10 +290,16 @@ async function route(
|
|
|
214
290
|
actionData,
|
|
215
291
|
params: match.params,
|
|
216
292
|
pathname,
|
|
293
|
+
search,
|
|
217
294
|
manifest,
|
|
218
295
|
meta,
|
|
296
|
+
matches,
|
|
297
|
+
headers: routeHeaders,
|
|
219
298
|
routeFile: match.routeFile.filePath,
|
|
220
299
|
// Set by the opt-in csp() middleware; undefined otherwise.
|
|
221
|
-
nonce: getCspNonce(context),
|
|
300
|
+
nonce: getCspNonce(mwCtx.context),
|
|
301
|
+
ssrMode,
|
|
302
|
+
});
|
|
303
|
+
|
|
222
304
|
});
|
|
223
305
|
}
|