@bractjs/bractjs 0.1.27 → 0.1.28
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 +242 -36
- package/bin/cli.ts +18 -1
- package/package.json +1 -1
- 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/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__/integration.test.ts +56 -0
- package/src/__tests__/loader.test.ts +32 -1
- 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 +74 -0
- package/src/__tests__/route-table.test.ts +33 -0
- package/src/__tests__/safe-validate.test.ts +96 -0
- 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.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/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 +239 -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/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 +28 -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/route-codegen.ts +141 -8
- package/src/config/load.ts +21 -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 +27 -3
- package/src/server/action-handler.ts +12 -3
- package/src/server/action-registry.ts +35 -0
- package/src/server/csp.ts +10 -1
- package/src/server/csrf.ts +26 -0
- package/src/server/env.ts +26 -5
- package/src/server/layout.ts +31 -1
- package/src/server/loader.ts +14 -8
- package/src/server/render.ts +18 -3
- package/src/server/request-handler.ts +50 -8
- package/src/server/search.ts +43 -0
- package/src/server/serve.ts +88 -1
- package/src/server/spa.ts +62 -0
- package/src/server/stream-handler.ts +10 -1
- package/src/server/validate.ts +85 -13
- package/src/shared/context.ts +5 -0
- package/src/shared/define-actions.ts +39 -0
- package/src/shared/form-data.ts +34 -0
- package/src/shared/route-types.ts +83 -2
- package/templates/new-app/app/root.tsx +2 -1
- package/templates/new-app/bractjs.config.ts +7 -12
- package/types/config.d.ts +21 -0
- package/types/index.d.ts +165 -9
- package/types/route.d.ts +62 -2
|
@@ -6,6 +6,37 @@ import { join, relative, resolve, isAbsolute } from "node:path";
|
|
|
6
6
|
const SERVER_RE = /^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*["']use server["']/;
|
|
7
7
|
const registry = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
|
8
8
|
|
|
9
|
+
// SECURITY(high): exporting a function from a `"use server"` module publishes
|
|
10
|
+
// it as an unauthenticated RPC endpoint reachable via POST /_action and
|
|
11
|
+
// GET /_stream. For files under `routes/`, these reserved exports are framework
|
|
12
|
+
// lifecycle hooks / components — NOT intended as callable actions — so we never
|
|
13
|
+
// register them. Without this filter a route's `loader`/`action`/`default`
|
|
14
|
+
// could be invoked directly over the wire, bypassing search/param validation
|
|
15
|
+
// and (for /_stream) with zero arguments. Authors who genuinely want an action
|
|
16
|
+
// must export it under a different name.
|
|
17
|
+
const RESERVED_ROUTE_EXPORTS = new Set([
|
|
18
|
+
"default",
|
|
19
|
+
"loader",
|
|
20
|
+
"action",
|
|
21
|
+
"meta",
|
|
22
|
+
"beforeLoad",
|
|
23
|
+
"context",
|
|
24
|
+
"ErrorBoundary",
|
|
25
|
+
"Fallback",
|
|
26
|
+
"config",
|
|
27
|
+
"searchSchema",
|
|
28
|
+
"ssr",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
function isRouteFile(rel: string): boolean {
|
|
32
|
+
return rel.startsWith("routes/") || rel.startsWith("routes\\");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function shouldRegisterExport(name: string, fromRouteFile: boolean): boolean {
|
|
36
|
+
if (fromRouteFile && RESERVED_ROUTE_EXPORTS.has(name)) return false;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
9
40
|
/**
|
|
10
41
|
* Hash key for an action — must use the same string the client-side proxy
|
|
11
42
|
* plugin hashes (`pathKey + "#" + name`). Mismatch → `/_action?id=...` 404.
|
|
@@ -61,8 +92,10 @@ export async function loadServerActions(appDir: string): Promise<void> {
|
|
|
61
92
|
continue;
|
|
62
93
|
}
|
|
63
94
|
|
|
95
|
+
const fromRouteFile = isRouteFile(rel);
|
|
64
96
|
for (const [name, val] of Object.entries(mod)) {
|
|
65
97
|
if (typeof val !== "function") continue;
|
|
98
|
+
if (!shouldRegisterExport(name, fromRouteFile)) continue;
|
|
66
99
|
const id = await computeId(pathKeyForAction(filePath, appDir), name);
|
|
67
100
|
registry.set(id, val as (...args: unknown[]) => Promise<unknown>);
|
|
68
101
|
}
|
|
@@ -82,8 +115,10 @@ export async function loadServerActionsFromRegistry(
|
|
|
82
115
|
entries: Array<{ relPath: string; mod: Record<string, unknown> }>,
|
|
83
116
|
): Promise<void> {
|
|
84
117
|
for (const { relPath, mod } of entries) {
|
|
118
|
+
const fromRouteFile = isRouteFile(relPath);
|
|
85
119
|
for (const [name, val] of Object.entries(mod)) {
|
|
86
120
|
if (typeof val !== "function") continue;
|
|
121
|
+
if (!shouldRegisterExport(name, fromRouteFile)) continue;
|
|
87
122
|
const id = await computeId(relPath, name);
|
|
88
123
|
registry.set(id, val as (...args: unknown[]) => Promise<unknown>);
|
|
89
124
|
}
|
package/src/server/csp.ts
CHANGED
|
@@ -21,6 +21,15 @@ export interface CspOptions {
|
|
|
21
21
|
* Useful for staging a policy before turning it on. Default: false.
|
|
22
22
|
*/
|
|
23
23
|
reportOnly?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Drop `'unsafe-inline'` from the default `style-src`. The baseline policy
|
|
26
|
+
* allows inline styles for ergonomics (React inline styles, CSS-in-JS), which
|
|
27
|
+
* leaves inline-style injection (CSS exfiltration / UI redress) possible.
|
|
28
|
+
* Set `strict: true` for `style-src 'self'` only — you must then serve all
|
|
29
|
+
* styles from same-origin stylesheets (or override `style-src` yourself with
|
|
30
|
+
* a nonce/hash via `directives`). Default: false.
|
|
31
|
+
*/
|
|
32
|
+
strict?: boolean;
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
/**
|
|
@@ -70,7 +79,7 @@ export function csp(options: CspOptions = {}): MiddlewareFn {
|
|
|
70
79
|
// imports without each chunk needing its own nonce. Falls back to 'self'
|
|
71
80
|
// in browsers that don't support it.
|
|
72
81
|
"script-src": `'self' 'nonce-${nonce}' 'strict-dynamic'`,
|
|
73
|
-
"style-src": "'self' 'unsafe-inline'",
|
|
82
|
+
"style-src": options.strict ? "'self'" : "'self' 'unsafe-inline'",
|
|
74
83
|
"img-src": "'self' data: blob:",
|
|
75
84
|
"connect-src": "'self'",
|
|
76
85
|
"base-uri": "'self'",
|
package/src/server/csrf.ts
CHANGED
|
@@ -29,6 +29,32 @@
|
|
|
29
29
|
// header. If CORS policy is ever loosened, Sec-Fetch-Site (1) remains as the
|
|
30
30
|
// browser-enforced backstop, and apps that loosen CORS should add a
|
|
31
31
|
// cryptographic double-submit token.
|
|
32
|
+
import { isExplicitDev } from "./env.ts";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The developer-facing explanation of a CSRF rejection. In dev it spells out
|
|
36
|
+
* the accepted signals and the usual fix; in prod it stays terse so the 403
|
|
37
|
+
* leaks nothing. Used for the plain route/action 403 bodies — the stream
|
|
38
|
+
* handler embeds {@link csrfHint} in its SSE error event instead.
|
|
39
|
+
*/
|
|
40
|
+
export function csrfHint(): string {
|
|
41
|
+
return (
|
|
42
|
+
"Blocked a cross-site or unattributed mutation (CSRF protection). " +
|
|
43
|
+
"Same-origin browser requests are allowed automatically; a manual fetch() " +
|
|
44
|
+
'must send the header `X-BractJS-Action: 1` (BractJS\'s <Form> and ' +
|
|
45
|
+
"useFetcher do this for you)."
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A 403 Response for a rejected mutation: explanatory in dev, terse in prod. */
|
|
50
|
+
export function csrfForbiddenResponse(): Response {
|
|
51
|
+
if (isExplicitDev()) {
|
|
52
|
+
console.warn("[bractjs] 403 (CSRF): " + csrfHint());
|
|
53
|
+
return new Response("Forbidden — " + csrfHint(), { status: 403 });
|
|
54
|
+
}
|
|
55
|
+
return new Response("Forbidden", { status: 403 });
|
|
56
|
+
}
|
|
57
|
+
|
|
32
58
|
export function isAllowedMutation(request: Request): boolean {
|
|
33
59
|
// (1) Browser-enforced signal. If present, it vetoes cross-origin requests
|
|
34
60
|
// regardless of what the Origin/custom headers claim.
|
package/src/server/env.ts
CHANGED
|
@@ -20,6 +20,19 @@ export function isDevRuntime(): boolean {
|
|
|
20
20
|
return _runtimeMode === "dev";
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The dev HMR WebSocket port, set by createDevServer and read when rendering
|
|
25
|
+
* the dev bootstrap so the injected HMR client connects to the right port
|
|
26
|
+
* (the config's `hmrPort`, not a hardcoded 3001). 0 = default.
|
|
27
|
+
*/
|
|
28
|
+
let _devHmrPort = 0;
|
|
29
|
+
export function setDevHmrPort(port: number): void {
|
|
30
|
+
_devHmrPort = port;
|
|
31
|
+
}
|
|
32
|
+
export function getDevHmrPort(): number {
|
|
33
|
+
return _devHmrPort;
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
/**
|
|
24
37
|
* Strict "is development?" check used to gate sensitive output (error
|
|
25
38
|
* messages, stack traces) that would otherwise leak in production.
|
|
@@ -50,12 +63,20 @@ const LS = String.fromCharCode(0x2028);
|
|
|
50
63
|
const PS = String.fromCharCode(0x2029);
|
|
51
64
|
|
|
52
65
|
export function safeStringify(data: unknown): string {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
66
|
+
// Cycle detection must track ANCESTORS, not every visited node — a WeakSet
|
|
67
|
+
// of all seen objects flags legitimate shared references as "[Circular]"
|
|
68
|
+
// (e.g. a loader echoing `args.search` while the payload also carries
|
|
69
|
+
// `search` at the top level). MDN's replacer pattern: `this` is the holder
|
|
70
|
+
// object, so popping the stack until the top is the holder leaves exactly
|
|
71
|
+
// the current ancestor chain.
|
|
72
|
+
const ancestors: object[] = [];
|
|
73
|
+
const json = JSON.stringify(data, function (_key, value: unknown) {
|
|
74
|
+
if (typeof value !== "object" || value === null) return value;
|
|
75
|
+
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {
|
|
76
|
+
ancestors.pop();
|
|
58
77
|
}
|
|
78
|
+
if (ancestors.includes(value)) return "[Circular]";
|
|
79
|
+
ancestors.push(value);
|
|
59
80
|
return value;
|
|
60
81
|
});
|
|
61
82
|
// Escape HTML-sensitive chars + JS LineTerminators (U+2028 / U+2029) so this
|
package/src/server/layout.ts
CHANGED
|
@@ -8,6 +8,12 @@ export interface LayoutChain {
|
|
|
8
8
|
root: RouteModule;
|
|
9
9
|
layouts: RouteModule[];
|
|
10
10
|
route: RouteModule;
|
|
11
|
+
/**
|
|
12
|
+
* appDir-relative source paths for each module in the chain, for error
|
|
13
|
+
* messages ("loader error in routes/blog/[id].tsx"). Optional so hand-built
|
|
14
|
+
* chains in tests don't have to supply it.
|
|
15
|
+
*/
|
|
16
|
+
files?: { root?: string; layouts: string[]; route?: string };
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
export interface ResolvedRoute extends RouteFile {
|
|
@@ -102,6 +108,14 @@ export async function importRouteModule(filePath: string): Promise<RouteModule>
|
|
|
102
108
|
// full-page GET, POST actions, and the /_data soft-nav endpoint alike.
|
|
103
109
|
beforeLoad: mod.beforeLoad,
|
|
104
110
|
context: mod.context,
|
|
111
|
+
// searchSchema gates loader input — dropping it silently skips search
|
|
112
|
+
// validation, so loaders would see raw strings where they expect coerced data.
|
|
113
|
+
searchSchema: mod.searchSchema,
|
|
114
|
+
// Selective-SSR surface: dropping `ssr` would silently restore full SSR
|
|
115
|
+
// (running loaders the route opted out of); dropping `Fallback` would
|
|
116
|
+
// SSR an empty outlet and guarantee a hydration mismatch.
|
|
117
|
+
ssr: mod.ssr,
|
|
118
|
+
Fallback: mod.Fallback,
|
|
105
119
|
handle: mod.handle,
|
|
106
120
|
ErrorBoundary: mod.ErrorBoundary,
|
|
107
121
|
default: mod.default,
|
|
@@ -124,6 +138,11 @@ function pickRouteModule(mod: Record<string, unknown> | RouteModule | undefined)
|
|
|
124
138
|
// note in importRouteModule. The compiled-binary path goes through here.
|
|
125
139
|
beforeLoad: m.beforeLoad as RouteModule["beforeLoad"],
|
|
126
140
|
context: m.context as unknown,
|
|
141
|
+
// Keep searchSchema too — see importRouteModule. Missing it here would
|
|
142
|
+
// skip search validation only in compiled binaries, the worst kind of skew.
|
|
143
|
+
searchSchema: m.searchSchema,
|
|
144
|
+
ssr: m.ssr as RouteModule["ssr"],
|
|
145
|
+
Fallback: m.Fallback as RouteModule["Fallback"],
|
|
127
146
|
handle: m.handle as RouteModule["handle"],
|
|
128
147
|
ErrorBoundary: m.ErrorBoundary as RouteModule["ErrorBoundary"],
|
|
129
148
|
default: m.default as RouteModule["default"],
|
|
@@ -155,7 +174,12 @@ export async function resolveRouteChain(
|
|
|
155
174
|
const layoutMods = layoutKeys.map((k) => pickRouteModule(registry[k]));
|
|
156
175
|
const routeKey = routeFile.filePath.split("\\").join("/");
|
|
157
176
|
const routeMod = pickRouteModule(registry[routeKey]);
|
|
158
|
-
return {
|
|
177
|
+
return {
|
|
178
|
+
root: rootMod,
|
|
179
|
+
layouts: layoutMods,
|
|
180
|
+
route: routeMod,
|
|
181
|
+
files: { root: rootKey, layouts: layoutKeys, route: routeKey },
|
|
182
|
+
};
|
|
159
183
|
}
|
|
160
184
|
|
|
161
185
|
const resolved = await resolveLayoutChain(routeFile, appDir);
|
|
@@ -167,9 +191,15 @@ export async function resolveRouteChain(
|
|
|
167
191
|
resolve(join(appDir, routeFile.filePath))
|
|
168
192
|
);
|
|
169
193
|
|
|
194
|
+
// Relativize the absolute layout paths back to appDir-relative for messages.
|
|
195
|
+
const appRoot = resolve(appDir);
|
|
196
|
+
const rel = (abs: string) => abs.startsWith(appRoot + "/") ? abs.slice(appRoot.length + 1) : abs;
|
|
197
|
+
const [rootFile, ...layoutFiles] = resolved.layoutFiles.map(rel);
|
|
198
|
+
|
|
170
199
|
return {
|
|
171
200
|
root: rootMod ?? {},
|
|
172
201
|
layouts: layoutMods,
|
|
173
202
|
route: routeMod,
|
|
203
|
+
files: { root: rootFile, layouts: layoutFiles, route: routeFile.filePath },
|
|
174
204
|
};
|
|
175
205
|
}
|
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/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
3
|
import type { MetaDescriptor } from "../shared/route-types.ts";
|
|
4
|
-
import { safeStringify, isDevRuntime } from "./env.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,6 +18,8 @@ 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[];
|
|
23
25
|
status?: number;
|
|
@@ -25,6 +27,13 @@ export interface RenderOptions {
|
|
|
25
27
|
routeFile?: string;
|
|
26
28
|
/** Per-request CSP nonce (set by the opt-in `csp()` middleware). Applied to the inline bootstrap script + client entry module tags. */
|
|
27
29
|
nonce?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Set when the document did NOT SSR the route component: the client renders
|
|
32
|
+
* the Fallback during hydration, then swaps in the real component
|
|
33
|
+
* ("data-only": data already present; "client-only": after a /_data fetch;
|
|
34
|
+
* "spa": static shell, everything resolved client-side).
|
|
35
|
+
*/
|
|
36
|
+
ssrMode?: "client-only" | "data-only" | "spa";
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
export async function renderRoute(options: RenderOptions): Promise<Response> {
|
|
@@ -38,13 +47,19 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
|
|
|
38
47
|
status = 200,
|
|
39
48
|
} = options;
|
|
40
49
|
|
|
41
|
-
|
|
50
|
+
// In dev, publish the HMR port so the injected client connects to the
|
|
51
|
+
// configured `hmrPort` rather than a hardcoded 3001. 0 → omit (client
|
|
52
|
+
// defaults to 3001).
|
|
53
|
+
const hmrPort = isDevRuntime() ? getDevHmrPort() : 0;
|
|
54
|
+
const devFlag = isDevRuntime()
|
|
55
|
+
? "window.__BRACT_DEV__=true;" + (hmrPort ? `window.__BRACTJS_HMR_PORT__=${hmrPort};` : "")
|
|
56
|
+
: "";
|
|
42
57
|
const devOverlay = isDevRuntime() ? devFlag + errorOverlayScript + "\n" : "";
|
|
43
58
|
const mergedMeta = mergeMeta(options.meta ?? []);
|
|
44
59
|
// The merged descriptor array is what the client reads to keep the document
|
|
45
60
|
// head in sync on soft navigation — keep it shaped, not stringified HTML.
|
|
46
61
|
const bootstrapScriptContent =
|
|
47
|
-
devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, manifest, routeFile: options.routeFile, meta: mergedMeta })};`;
|
|
62
|
+
devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, search: options.search, manifest, routeFile: options.routeFile, meta: mergedMeta, ssrMode: options.ssrMode })};`;
|
|
48
63
|
|
|
49
64
|
// Render <title>/<meta> elements alongside the app shell. React 19 hoists
|
|
50
65
|
// document-metadata elements into <head> during streaming SSR, so crawlers
|
|
@@ -3,14 +3,15 @@ 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";
|
|
8
9
|
import { json, error, sanitizeRedirect } from "./response.ts";
|
|
9
10
|
import { isRedirect, isHttpError } from "../shared/errors.ts";
|
|
10
11
|
import { isExplicitDev } from "./env.ts";
|
|
11
12
|
import { pipeline, type MiddlewareContext } from "./middleware.ts";
|
|
12
13
|
import { BractJSProvider } from "../shared/context.ts";
|
|
13
|
-
import { isAllowedMutation } from "./csrf.ts";
|
|
14
|
+
import { isAllowedMutation, csrfForbiddenResponse } from "./csrf.ts";
|
|
14
15
|
import { getCspNonce } from "./csp.ts";
|
|
15
16
|
import { fireOnError, type OnErrorHook } from "./lifecycle.ts";
|
|
16
17
|
|
|
@@ -87,6 +88,10 @@ async function route(
|
|
|
87
88
|
headers: request.headers,
|
|
88
89
|
method: "GET",
|
|
89
90
|
});
|
|
91
|
+
// Validate search params before any route work runs — loaders must
|
|
92
|
+
// never see unvalidated input, and a 400 here is cheaper than a wasted
|
|
93
|
+
// context-factory/loader run. The thrown 400 Response propagates below.
|
|
94
|
+
const search = await validateSearch(chain.route.searchSchema, targetUrl);
|
|
90
95
|
// SECURITY(high): /_data must run the same auth/redirect gates as a full
|
|
91
96
|
// page request — otherwise a SPA-style soft navigation to a protected
|
|
92
97
|
// route would bypass beforeLoad() / defineContext() and leak loader data.
|
|
@@ -96,13 +101,20 @@ async function route(
|
|
|
96
101
|
match.params,
|
|
97
102
|
context,
|
|
98
103
|
);
|
|
99
|
-
const args = buildLoaderArgs(loaderRequest, match.params, routeContext);
|
|
104
|
+
const args = buildLoaderArgs(loaderRequest, match.params, routeContext, search);
|
|
100
105
|
const beforeLoadResponse = await runBeforeLoad(chain.route, args);
|
|
101
106
|
if (beforeLoadResponse) return beforeLoadResponse;
|
|
102
107
|
const results = await runLoaders(chain, args, onError);
|
|
103
|
-
|
|
108
|
+
// Merged meta must ride along: ClientRouter re-renders the document head
|
|
109
|
+
// from this payload on soft navigation, and the initial __BRACTJS_DATA__
|
|
110
|
+
// already carries the merged shape.
|
|
111
|
+
const meta = mergeMeta(resolveMeta(chain, results, match.params));
|
|
112
|
+
return json({ root: results.root, layouts: results.layouts, route: results.route, params: match.params, meta, search });
|
|
104
113
|
} catch (err) {
|
|
105
114
|
if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
|
|
115
|
+
// A non-redirect Response (e.g. the 400 thrown by search validation)
|
|
116
|
+
// is the intended reply — pass it through verbatim.
|
|
117
|
+
if (err instanceof Response) return err;
|
|
106
118
|
if (isHttpError(err)) return json({ error: err.message }, { status: err.status });
|
|
107
119
|
console.error("[bractjs] /_data error:", err);
|
|
108
120
|
await fireOnError(onError, err, request);
|
|
@@ -115,6 +127,17 @@ async function route(
|
|
|
115
127
|
if (!match) return error("Not Found", 404);
|
|
116
128
|
|
|
117
129
|
const chain = await resolveRouteChain(match.routeFile, appDir, moduleRegistry);
|
|
130
|
+
|
|
131
|
+
// Validate search params before any route work (context factory, beforeLoad,
|
|
132
|
+
// action, loaders) — they all receive the validated object.
|
|
133
|
+
let search: Record<string, unknown>;
|
|
134
|
+
try {
|
|
135
|
+
search = await validateSearch(chain.route.searchSchema, url);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (err instanceof Response) return err;
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
|
|
118
141
|
// Run per-route context factory (defineContext export) before loaders.
|
|
119
142
|
const routeContext = await runRouteContext(
|
|
120
143
|
chain.route as Parameters<typeof runRouteContext>[0],
|
|
@@ -122,7 +145,7 @@ async function route(
|
|
|
122
145
|
match.params,
|
|
123
146
|
context,
|
|
124
147
|
);
|
|
125
|
-
const args = buildLoaderArgs(request, match.params, routeContext);
|
|
148
|
+
const args = buildLoaderArgs(request, match.params, routeContext, search);
|
|
126
149
|
|
|
127
150
|
// ── beforeLoad ────────────────────────────────────────────────────────
|
|
128
151
|
const beforeLoadResponse = await runBeforeLoad(chain.route, args);
|
|
@@ -131,7 +154,7 @@ async function route(
|
|
|
131
154
|
// ── Action (mutating methods) ─────────────────────────────────────────
|
|
132
155
|
let actionData: unknown = null;
|
|
133
156
|
if (MUTATING_METHODS.has(request.method)) {
|
|
134
|
-
if (!isAllowedMutation(request)) return
|
|
157
|
+
if (!isAllowedMutation(request)) return csrfForbiddenResponse();
|
|
135
158
|
// Reject up front if the client advertises an oversized body.
|
|
136
159
|
const clRaw = request.headers.get("Content-Length");
|
|
137
160
|
if (clRaw) {
|
|
@@ -148,6 +171,8 @@ async function route(
|
|
|
148
171
|
} catch (err) {
|
|
149
172
|
if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
|
|
150
173
|
if (isHttpError(err)) return error(err.message, err.status);
|
|
174
|
+
// Name the failing route so the log points at the right file.
|
|
175
|
+
console.error(`[bractjs] action error in ${chain.files?.route ?? match.routeFile.filePath}:`, err);
|
|
151
176
|
await fireOnError(onError, err, request);
|
|
152
177
|
if (isExplicitDev()) return error(err instanceof Error ? err.message : String(err), 500);
|
|
153
178
|
return error("Internal Server Error", 500);
|
|
@@ -167,10 +192,20 @@ async function route(
|
|
|
167
192
|
}
|
|
168
193
|
}
|
|
169
194
|
|
|
195
|
+
// ── Selective SSR ─────────────────────────────────────────────────────
|
|
196
|
+
// `ssr: false` skips the ROUTE loader during document SSR (root/layout
|
|
197
|
+
// loaders still run — they render the shell). beforeLoad already ran above:
|
|
198
|
+
// it is the auth gate and must hold for every mode. The client completes
|
|
199
|
+
// the render via /_data after hydration, where the loader DOES run.
|
|
200
|
+
const routeSsr = chain.route.ssr ?? true;
|
|
201
|
+
const loaderChain = routeSsr === false
|
|
202
|
+
? { ...chain, route: { ...chain.route, loader: undefined } }
|
|
203
|
+
: chain;
|
|
204
|
+
|
|
170
205
|
// ── Loaders ───────────────────────────────────────────────────────────
|
|
171
206
|
let loaderResults;
|
|
172
207
|
try {
|
|
173
|
-
loaderResults = await runLoaders(
|
|
208
|
+
loaderResults = await runLoaders(loaderChain, args, onError);
|
|
174
209
|
} catch (err) {
|
|
175
210
|
if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
|
|
176
211
|
if (isHttpError(err)) return error(err.message, err.status);
|
|
@@ -187,7 +222,10 @@ async function route(
|
|
|
187
222
|
|
|
188
223
|
// ── SSR render ────────────────────────────────────────────────────────
|
|
189
224
|
const RootComponent = chain.root.default ?? (() => null);
|
|
190
|
-
|
|
225
|
+
// Non-default SSR modes render the Fallback (or nothing) in the component's
|
|
226
|
+
// place; the client swaps in the real component after hydration.
|
|
227
|
+
const RouteComponent = routeSsr === true ? chain.route.default : chain.route.Fallback;
|
|
228
|
+
const ssrMode = routeSsr === true ? undefined : routeSsr === false ? "client-only" as const : "data-only" as const;
|
|
191
229
|
|
|
192
230
|
// Wrap root in BractJSProvider so <Outlet> can render the route component
|
|
193
231
|
// server-side without needing a ClientRouter.
|
|
@@ -201,6 +239,8 @@ async function route(
|
|
|
201
239
|
pathname,
|
|
202
240
|
manifest: manifest as unknown as import("../shared/context.ts").RouteManifest,
|
|
203
241
|
RouteComponent,
|
|
242
|
+
location: { pathname, search: url.search, hash: "", state: null, key: "default" },
|
|
243
|
+
search,
|
|
204
244
|
},
|
|
205
245
|
children: createElement(RootComponent),
|
|
206
246
|
},
|
|
@@ -214,10 +254,12 @@ async function route(
|
|
|
214
254
|
actionData,
|
|
215
255
|
params: match.params,
|
|
216
256
|
pathname,
|
|
257
|
+
search,
|
|
217
258
|
manifest,
|
|
218
259
|
meta,
|
|
219
260
|
routeFile: match.routeFile.filePath,
|
|
220
261
|
// Set by the opt-in csp() middleware; undefined otherwise.
|
|
221
262
|
nonce: getCspNonce(context),
|
|
263
|
+
ssrMode,
|
|
222
264
|
});
|
|
223
265
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { runSchema, type Schema } from "./validate.ts";
|
|
2
|
+
|
|
3
|
+
// ── Raw extraction ─────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* URLSearchParams → plain object. Repeated keys collapse into arrays
|
|
7
|
+
* (`?tag=a&tag=b` → `{ tag: ["a", "b"] }`), mirroring how `validate()`
|
|
8
|
+
* flattens FormData.
|
|
9
|
+
*/
|
|
10
|
+
export function searchParamsToObject(sp: URLSearchParams): Record<string, string | string[]> {
|
|
11
|
+
const out: Record<string, string | string[]> = {};
|
|
12
|
+
for (const [key, value] of sp.entries()) {
|
|
13
|
+
const existing = out[key];
|
|
14
|
+
if (existing === undefined) out[key] = value;
|
|
15
|
+
else if (Array.isArray(existing)) existing.push(value);
|
|
16
|
+
else out[key] = [existing, value];
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Validation ─────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Validate a URL's search params against a route's optional `searchSchema`
|
|
25
|
+
* export (Zod/Valibot/standard-schema compatible — same duck typing as
|
|
26
|
+
* `validate()`).
|
|
27
|
+
*
|
|
28
|
+
* - No schema → the raw string record (back-compat: routes that never opted in
|
|
29
|
+
* see exactly what `request.url` would give them).
|
|
30
|
+
* - Schema failure → throws the 400 `Response` from the validate machinery.
|
|
31
|
+
* Loaders must never run on unvalidated input; leniency belongs in the
|
|
32
|
+
* schema itself (`z.coerce.number().catch(1)` is the documented idiom for
|
|
33
|
+
* URLs that must tolerate junk).
|
|
34
|
+
* - Success → the parsed, coerced object (numbers/booleans/arrays/defaults).
|
|
35
|
+
*/
|
|
36
|
+
export async function validateSearch(
|
|
37
|
+
schema: unknown,
|
|
38
|
+
url: URL,
|
|
39
|
+
): Promise<Record<string, unknown>> {
|
|
40
|
+
const raw = searchParamsToObject(url.searchParams);
|
|
41
|
+
if (!schema) return raw;
|
|
42
|
+
return await runSchema(schema as Schema<Record<string, unknown>>, raw);
|
|
43
|
+
}
|