@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/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 {
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
// 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[]>;
|
|
16
|
+
for (const [key, value] of sp.entries()) {
|
|
17
|
+
const existing = out[key];
|
|
18
|
+
if (existing === undefined) out[key] = value;
|
|
19
|
+
else if (Array.isArray(existing)) existing.push(value);
|
|
20
|
+
else out[key] = [existing, value];
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Validation ─────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate a URL's search params against a route's optional `searchSchema`
|
|
29
|
+
* export (Zod/Valibot/standard-schema compatible — same duck typing as
|
|
30
|
+
* `validate()`).
|
|
31
|
+
*
|
|
32
|
+
* - No schema → the raw string record (back-compat: routes that never opted in
|
|
33
|
+
* see exactly what `request.url` would give them).
|
|
34
|
+
* - Schema failure → throws the 400 `Response` from the validate machinery.
|
|
35
|
+
* Loaders must never run on unvalidated input; leniency belongs in the
|
|
36
|
+
* schema itself (`z.coerce.number().catch(1)` is the documented idiom for
|
|
37
|
+
* URLs that must tolerate junk).
|
|
38
|
+
* - Success → the parsed, coerced object (numbers/booleans/arrays/defaults).
|
|
39
|
+
*/
|
|
40
|
+
export async function validateSearch(
|
|
41
|
+
schema: unknown,
|
|
42
|
+
url: URL,
|
|
43
|
+
): Promise<Record<string, unknown>> {
|
|
44
|
+
const raw = searchParamsToObject(url.searchParams);
|
|
45
|
+
if (!schema) return raw;
|
|
46
|
+
return await runSchema(schema as Schema<Record<string, unknown>>, raw);
|
|
47
|
+
}
|
package/src/server/serve.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { scanRoutes, type RouteFile } from "./scanner.ts";
|
|
2
|
-
import { buildTrie } from "./matcher.ts";
|
|
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";
|
|
5
|
+
import { renderSpaShell } from "./spa.ts";
|
|
4
6
|
import { type ServerManifest } from "./render.ts";
|
|
5
7
|
import { isDevRuntime, isExplicitDev } from "./env.ts";
|
|
6
8
|
import { loadManifest } from "../build/manifest.ts";
|
|
@@ -24,10 +26,24 @@ export interface BractJSConfig {
|
|
|
24
26
|
appDir: string;
|
|
25
27
|
publicDir: string;
|
|
26
28
|
manifest: ServerManifest;
|
|
29
|
+
/** WebSocket port for dev HMR (used by `bractjs dev` only). Default 3001. */
|
|
30
|
+
hmrPort?: number;
|
|
27
31
|
/** Optional custom adapter (Cloudflare Workers, Deno, Node, etc.). Defaults to Bun.serve(). */
|
|
28
32
|
adapter?: BractAdapter;
|
|
29
33
|
/** i18n locale prefix routing (E2). */
|
|
30
34
|
i18n?: I18nConfig;
|
|
35
|
+
/**
|
|
36
|
+
* SPA mode: `false` serves one static shell for every document GET instead
|
|
37
|
+
* of SSR. The server keeps running — /_data, actions, /_image, API routes
|
|
38
|
+
* and static assets behave exactly as in SSR mode ("no document SSR", not
|
|
39
|
+
* "no server"). Default `true`.
|
|
40
|
+
*/
|
|
41
|
+
ssr?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Paths to prerender at build time (SSG). Served from disk before dynamic
|
|
44
|
+
* SSR in production; requests with a query string stay dynamic.
|
|
45
|
+
*/
|
|
46
|
+
prerender?: string[] | (() => string[] | Promise<string[]>);
|
|
31
47
|
// Build options (used by src/build/bundler.ts)
|
|
32
48
|
sourcemap?: "none" | "linked" | "inline" | "external";
|
|
33
49
|
minify?: boolean;
|
|
@@ -37,6 +53,14 @@ export interface BractJSConfig {
|
|
|
37
53
|
buildDir?: string;
|
|
38
54
|
/** Directory for transformed image cache. Defaults to .bract-image-cache */
|
|
39
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;
|
|
40
64
|
/** Called once after the server starts listening. Use to open DB connections, warm caches, etc. */
|
|
41
65
|
onStart?: () => Promise<void> | void;
|
|
42
66
|
/** Called before the process exits (any signal or uncaught error). Use to close DB connections, flush queues, etc. */
|
|
@@ -129,8 +153,38 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
|
|
|
129
153
|
: loadServerActions(appDir);
|
|
130
154
|
const moduleRegistry = config.moduleRegistry;
|
|
131
155
|
const onError = config.onError;
|
|
156
|
+
const ssrEnabled = config.ssr !== false;
|
|
157
|
+
|
|
158
|
+
// SPA shell: production prefers the file `bractjs build` wrote; dev (or a
|
|
159
|
+
// missing file) renders it on demand so root.tsx edits show up. Cached per
|
|
160
|
+
// manifest in prod-without-file; never cached in dev.
|
|
161
|
+
let spaShellCache: { key: string; html: string } | null = null;
|
|
162
|
+
async function getSpaShell(manifest: ServerManifest): Promise<string> {
|
|
163
|
+
if (!isDevRuntime()) {
|
|
164
|
+
const file = Bun.file(join(buildDir, "client", "__spa.html"));
|
|
165
|
+
if (await file.exists()) return file.text();
|
|
166
|
+
const key = manifest.clientEntry;
|
|
167
|
+
if (spaShellCache?.key === key) return spaShellCache.html;
|
|
168
|
+
const html = await renderSpaShell(appDir, manifest, moduleRegistry);
|
|
169
|
+
spaShellCache = { key, html };
|
|
170
|
+
return html;
|
|
171
|
+
}
|
|
172
|
+
return renderSpaShell(appDir, manifest, moduleRegistry);
|
|
173
|
+
}
|
|
132
174
|
|
|
133
|
-
|
|
175
|
+
/** Prerendered file for a clean (query-free, dot-free) document path, or null. */
|
|
176
|
+
function prerenderFile(relHtmlOrJson: string): ReturnType<typeof Bun.file> | null {
|
|
177
|
+
if (relHtmlOrJson.split("/").some((s) => s === ".." || s === ".")) return null;
|
|
178
|
+
return Bun.file(join(buildDir, "client", "_prerender", relHtmlOrJson));
|
|
179
|
+
}
|
|
180
|
+
|
|
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> {
|
|
134
188
|
const url = new URL(request.url);
|
|
135
189
|
const { pathname } = url;
|
|
136
190
|
|
|
@@ -196,9 +250,67 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
|
|
|
196
250
|
if (staticRes) return staticRes;
|
|
197
251
|
|
|
198
252
|
const trie = await trieReady;
|
|
253
|
+
const isDocGet = request.method === "GET" || request.method === "HEAD";
|
|
254
|
+
|
|
255
|
+
// SPA mode: every document GET that matches a route gets the static
|
|
256
|
+
// shell. /_data (no trie match) and mutations fall through to the normal
|
|
257
|
+
// handler, so loaders/actions/CSRF behave exactly as in SSR mode.
|
|
258
|
+
if (!ssrEnabled && isDocGet && matchRoute(pathname, trie)) {
|
|
259
|
+
const manifest = isDevRuntime() ? await readDevManifest(buildDir) : await manifestReady;
|
|
260
|
+
return new Response(await getSpaShell(manifest), {
|
|
261
|
+
headers: {
|
|
262
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
263
|
+
"Cache-Control": "no-cache",
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Prerendered output (production): serve the build-time HTML / _data
|
|
269
|
+
// payload for clean URLs. A query string opts the request back into
|
|
270
|
+
// dynamic SSR — the static file was rendered without one.
|
|
271
|
+
if (!isDevRuntime() && isDocGet) {
|
|
272
|
+
if (pathname === "/_data") {
|
|
273
|
+
const target = url.searchParams.get("path") ?? "/";
|
|
274
|
+
const [targetPathname, targetSearch] = target.split("?");
|
|
275
|
+
if (!targetSearch) {
|
|
276
|
+
const rel = targetPathname === "/" ? "_data.json" : targetPathname.slice(1) + "/_data.json";
|
|
277
|
+
const f = prerenderFile(rel);
|
|
278
|
+
if (f && (await f.exists())) {
|
|
279
|
+
return new Response(f, {
|
|
280
|
+
headers: {
|
|
281
|
+
"Content-Type": "application/json",
|
|
282
|
+
"Cache-Control": "public, max-age=0, must-revalidate",
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} else if (!url.search) {
|
|
288
|
+
const rel = pathname === "/" ? "index.html" : pathname.slice(1) + "/index.html";
|
|
289
|
+
const f = prerenderFile(rel);
|
|
290
|
+
if (f && (await f.exists())) {
|
|
291
|
+
return new Response(f, {
|
|
292
|
+
headers: {
|
|
293
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
294
|
+
"Cache-Control": "public, max-age=0, must-revalidate",
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
199
301
|
const manifest = isDevRuntime() ? await readDevManifest(buildDir) : await manifestReady;
|
|
200
302
|
const handlerConfig: HandlerConfig = { appDir, publicDir, manifest, onError, moduleRegistry };
|
|
201
|
-
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));
|
|
202
314
|
};
|
|
203
315
|
}
|
|
204
316
|
|
|
@@ -243,7 +355,7 @@ export function createServer(config?: Partial<BractJSConfig>): {
|
|
|
243
355
|
const fetchHandler = buildFetchHandler(config ?? {});
|
|
244
356
|
|
|
245
357
|
// Use provided adapter or fall back to the default Bun adapter.
|
|
246
|
-
const adapter = config?.adapter ?? new BunAdapter();
|
|
358
|
+
const adapter = config?.adapter ?? new BunAdapter(config?.maxRequestBodySize);
|
|
247
359
|
|
|
248
360
|
if (adapter instanceof BunAdapter) {
|
|
249
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> {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createElement, type ComponentType } from "react";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { renderRoute, type ServerManifest } from "./render.ts";
|
|
4
|
+
import { BractJSProvider, type RouteManifest } from "../shared/context.ts";
|
|
5
|
+
import type { ModuleRegistry } from "./layout.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Render the SPA-mode document shell: the app's root component around an
|
|
9
|
+
* empty outlet, with `ssrMode: "spa"` in the bootstrap payload. Served for
|
|
10
|
+
* every document GET when the config sets `ssr: false`; the client router
|
|
11
|
+
* resolves the actual route (module + /_data) after hydration.
|
|
12
|
+
*
|
|
13
|
+
* The root renders with NO loader data (its loader does not run for the
|
|
14
|
+
* shell) and a "/" location — roots that render loader- or location-dependent
|
|
15
|
+
* markup are not compatible with SPA mode. Loaders/actions stay fully
|
|
16
|
+
* functional at runtime: SPA mode means "no document SSR", not "no server".
|
|
17
|
+
*/
|
|
18
|
+
export async function renderSpaShell(
|
|
19
|
+
appDir: string,
|
|
20
|
+
manifest: ServerManifest,
|
|
21
|
+
registry?: ModuleRegistry,
|
|
22
|
+
): Promise<string> {
|
|
23
|
+
let RootComponent: ComponentType = () => null;
|
|
24
|
+
if (registry) {
|
|
25
|
+
const rootMod = (registry["root.tsx"] ?? registry["root.ts"]) as { default?: ComponentType } | undefined;
|
|
26
|
+
if (rootMod?.default) RootComponent = rootMod.default;
|
|
27
|
+
} else {
|
|
28
|
+
const rootPath = resolve(join(appDir, "root.tsx"));
|
|
29
|
+
if (await Bun.file(rootPath).exists()) {
|
|
30
|
+
const mod = (await import(rootPath)) as { default?: ComponentType };
|
|
31
|
+
if (mod.default) RootComponent = mod.default;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const loaderData = { root: null, layouts: [], route: null };
|
|
36
|
+
const shell = createElement(BractJSProvider, {
|
|
37
|
+
value: {
|
|
38
|
+
loaderData: loaderData as unknown as Record<string, unknown>,
|
|
39
|
+
actionData: null,
|
|
40
|
+
params: {},
|
|
41
|
+
pathname: "/",
|
|
42
|
+
manifest: manifest as unknown as RouteManifest,
|
|
43
|
+
RouteComponent: undefined,
|
|
44
|
+
location: { pathname: "/", search: "", hash: "", state: null, key: "default" },
|
|
45
|
+
search: {},
|
|
46
|
+
},
|
|
47
|
+
children: createElement(RootComponent),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const res = await renderRoute({
|
|
51
|
+
shell,
|
|
52
|
+
loaderData: loaderData as unknown as Record<string, unknown>,
|
|
53
|
+
actionData: null,
|
|
54
|
+
params: {},
|
|
55
|
+
pathname: "/",
|
|
56
|
+
search: {},
|
|
57
|
+
manifest,
|
|
58
|
+
meta: [],
|
|
59
|
+
ssrMode: "spa",
|
|
60
|
+
});
|
|
61
|
+
return await res.text();
|
|
62
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolveAction } from "./action-registry.ts";
|
|
2
2
|
import { isExplicitDev } from "./env.ts";
|
|
3
|
+
import { csrfHint } from "./csrf.ts";
|
|
3
4
|
|
|
4
5
|
// ── SSE helpers ────────────────────────────────────────────────────────────
|
|
5
6
|
|
|
@@ -31,7 +32,7 @@ export async function handleStreamRequest(request: Request): Promise<Response |
|
|
|
31
32
|
// it cross-origin without a CORS preflight, and the real client (useFetcher)
|
|
32
33
|
// always sends it. This is strictly tighter than the /_action gate.
|
|
33
34
|
if (!request.headers.get("X-BractJS-Action")) {
|
|
34
|
-
return new Response(sseChunk("error", { message: "Forbidden" }), {
|
|
35
|
+
return new Response(sseChunk("error", { message: isExplicitDev() ? csrfHint() : "Forbidden" }), {
|
|
35
36
|
status: 403,
|
|
36
37
|
headers: {
|
|
37
38
|
"Content-Type": "text/event-stream",
|
|
@@ -70,6 +71,14 @@ export async function handleStreamRequest(request: Request): Promise<Response |
|
|
|
70
71
|
async start(controller) {
|
|
71
72
|
const encoder = new TextEncoder();
|
|
72
73
|
try {
|
|
74
|
+
// SECURITY(medium): /_stream invokes the resolved action with NO
|
|
75
|
+
// caller-supplied arguments (GET carries no body, and we deliberately
|
|
76
|
+
// pass none). Any function reachable here therefore runs purely on
|
|
77
|
+
// server-side state. The X-BractJS-Action gate above blocks browser
|
|
78
|
+
// cross-origin abuse; the action-registry's RESERVED_ROUTE_EXPORTS
|
|
79
|
+
// filter keeps route lifecycle exports (loader/action/…) from ever
|
|
80
|
+
// being resolvable. Authors must still ensure stream actions are safe
|
|
81
|
+
// to call with no input and perform their own authorization.
|
|
73
82
|
const result = await action();
|
|
74
83
|
// If the action is an async generator, stream each value.
|
|
75
84
|
if (result && typeof (result as AsyncIterable<unknown>)[Symbol.asyncIterator] === "function") {
|
package/src/server/validate.ts
CHANGED
|
@@ -14,7 +14,7 @@ interface SchemaWithSafeParse<T> {
|
|
|
14
14
|
safeParse(input: unknown): SafeParseResult<T> | Promise<SafeParseResult<T>>;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
type Schema<T> = SchemaWithParse<T> | SchemaWithSafeParse<T>;
|
|
17
|
+
export type Schema<T> = SchemaWithParse<T> | SchemaWithSafeParse<T>;
|
|
18
18
|
|
|
19
19
|
// ── Field error shape ─────────────────────────────────────────────────────
|
|
20
20
|
|
|
@@ -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];
|
|
@@ -48,21 +51,15 @@ function toPlainObject(input: FormData | Record<string, unknown>): Record<string
|
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* - If the schema only has `.parse()`: wraps it and re-throws the error as a
|
|
56
|
-
* `ValidationError` with a single `_` field containing the error message.
|
|
57
|
-
*
|
|
58
|
-
* Returns the parsed (coerced) data on success.
|
|
54
|
+
* Run a plain object through a Zod/Valibot-compatible schema. The shared core
|
|
55
|
+
* of `validate()` (action/form bodies) and `validateSearch()` (URL search
|
|
56
|
+
* params). Throws a 400 `Response` with `{ errors }` field errors on failure;
|
|
57
|
+
* returns the parsed (coerced) data on success.
|
|
59
58
|
*/
|
|
60
|
-
export async function
|
|
59
|
+
export async function runSchema<T>(
|
|
61
60
|
schema: Schema<T>,
|
|
62
|
-
|
|
61
|
+
plain: Record<string, unknown>,
|
|
63
62
|
): Promise<T> {
|
|
64
|
-
const plain = toPlainObject(input);
|
|
65
|
-
|
|
66
63
|
if ("safeParse" in schema && typeof schema.safeParse === "function") {
|
|
67
64
|
const result = await schema.safeParse(plain);
|
|
68
65
|
if ((result as SafeParseResult<T>).success) {
|
|
@@ -87,3 +84,81 @@ export async function validate<T>(
|
|
|
87
84
|
throw Response.json({ errors: fieldErrors }, { status: 400, statusText: "Validation failed" });
|
|
88
85
|
}
|
|
89
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate `input` against a Zod-compatible or Valibot-compatible schema.
|
|
90
|
+
*
|
|
91
|
+
* - If the schema has `.safeParse()`: uses it to collect field errors and throws
|
|
92
|
+
* a typed `ValidationError` on failure (which the framework converts to a 400).
|
|
93
|
+
* - If the schema only has `.parse()`: wraps it and re-throws the error as a
|
|
94
|
+
* `ValidationError` with a single `_` field containing the error message.
|
|
95
|
+
*
|
|
96
|
+
* Returns the parsed (coerced) data on success.
|
|
97
|
+
*/
|
|
98
|
+
export async function validate<T>(
|
|
99
|
+
schema: Schema<T>,
|
|
100
|
+
input: FormData | Record<string, unknown>,
|
|
101
|
+
): Promise<T> {
|
|
102
|
+
return runSchema(schema, toPlainObject(input));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Non-throwing validation (ergonomic action idiom) ───────────────────────
|
|
106
|
+
|
|
107
|
+
export type SafeValidateResult<T> =
|
|
108
|
+
| { ok: true; data: T }
|
|
109
|
+
| { ok: false; fieldErrors: FieldErrors; firstError: string };
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Like {@link validate}, but returns a result instead of throwing — the
|
|
113
|
+
* ergonomic shape for actions that want to render field errors:
|
|
114
|
+
*
|
|
115
|
+
* ```ts
|
|
116
|
+
* const r = await safeValidate(PostSchema, formData);
|
|
117
|
+
* if (!r.ok) return { error: r.firstError, fieldErrors: r.fieldErrors };
|
|
118
|
+
* usePost(r.data);
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* `firstError` is the first message across all fields, or a generic fallback.
|
|
122
|
+
*/
|
|
123
|
+
export async function safeValidate<T>(
|
|
124
|
+
schema: Schema<T>,
|
|
125
|
+
input: FormData | Record<string, unknown>,
|
|
126
|
+
): Promise<SafeValidateResult<T>> {
|
|
127
|
+
try {
|
|
128
|
+
const data = await runSchema(schema, toPlainObject(input));
|
|
129
|
+
return { ok: true, data };
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (isValidationResponse(err)) {
|
|
132
|
+
const { fieldErrors, firstError } = await readValidationError(err);
|
|
133
|
+
return { ok: false, fieldErrors, firstError };
|
|
134
|
+
}
|
|
135
|
+
throw err; // not a validation failure — let it propagate
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* True for the 400 `Response` thrown by {@link validate} / `searchSchema`
|
|
141
|
+
* validation (identified by status 400 + `statusText "Validation failed"`).
|
|
142
|
+
* Use it in the try/catch idiom when you keep calling `validate()` directly.
|
|
143
|
+
*/
|
|
144
|
+
export function isValidationResponse(value: unknown): value is Response {
|
|
145
|
+
return value instanceof Response && value.status === 400 && value.statusText === "Validation failed";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Parse the `{ errors }` body of a validation 400 `Response` into field errors
|
|
150
|
+
* plus the first message. Tolerant of a non-JSON / unexpected body.
|
|
151
|
+
*/
|
|
152
|
+
export async function readValidationError(
|
|
153
|
+
res: Response,
|
|
154
|
+
): Promise<{ fieldErrors: FieldErrors; firstError: string }> {
|
|
155
|
+
const fallback = "Please check your input.";
|
|
156
|
+
try {
|
|
157
|
+
const body = (await res.clone().json()) as { errors?: FieldErrors };
|
|
158
|
+
const fieldErrors = body.errors ?? {};
|
|
159
|
+
const firstError = Object.values(fieldErrors)[0]?.[0] ?? fallback;
|
|
160
|
+
return { fieldErrors, firstError };
|
|
161
|
+
} catch {
|
|
162
|
+
return { fieldErrors: {}, firstError: fallback };
|
|
163
|
+
}
|
|
164
|
+
}
|
package/src/shared/context.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createContext, useContext, createElement, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import type { RouterLocation, RouteMatch } from "./route-types.ts";
|
|
2
3
|
|
|
3
4
|
export interface RouteManifest {
|
|
4
5
|
[routeId: string]: {
|
|
@@ -15,6 +16,12 @@ export interface BractJSContextValue {
|
|
|
15
16
|
manifest: RouteManifest;
|
|
16
17
|
/** SSR-only: the matched route's default export so <Outlet> can render it without ClientRouter */
|
|
17
18
|
RouteComponent?: ComponentType;
|
|
19
|
+
/** The request's location, so `useLocation()` works during SSR (hash is always ""). */
|
|
20
|
+
location?: RouterLocation;
|
|
21
|
+
/** Validated search params (route `searchSchema` output), so `useSearch()` works during SSR. */
|
|
22
|
+
search?: Record<string, unknown>;
|
|
23
|
+
/** The matched route chain (root → layouts → route) for `useMatches()`. */
|
|
24
|
+
matches?: RouteMatch[];
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export const BractJSContext = createContext<BractJSContextValue>(null!);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ActionArgs } from "./route-types.ts";
|
|
2
|
+
import { isExplicitDev } from "../server/env.ts";
|
|
3
|
+
|
|
4
|
+
type IntentHandler = (args: ActionArgs) => unknown;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compose a single route `action` from per-intent handlers, dispatching on the
|
|
8
|
+
* form's `intent` field. Pairs with `<Form intent="...">` / `<fetcher.Form
|
|
9
|
+
* intent="...">`, which render the matching hidden input:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* export const action = defineActions({
|
|
13
|
+
* add: ({ formData }) => addTodo(formText(formData, "title")),
|
|
14
|
+
* delete: ({ formData }) => deleteTodo(formText(formData, "id")),
|
|
15
|
+
* });
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* A missing or unknown intent returns a 400 `Response` (dev lists the known
|
|
19
|
+
* intents; prod is terse). Each handler receives the full {@link ActionArgs}.
|
|
20
|
+
*/
|
|
21
|
+
export function defineActions<M extends Record<string, IntentHandler>>(
|
|
22
|
+
handlers: M,
|
|
23
|
+
): (args: ActionArgs) => Promise<Awaited<ReturnType<M[keyof M]>> | Response> {
|
|
24
|
+
type Out = Awaited<ReturnType<M[keyof M]>> | Response;
|
|
25
|
+
const dispatch = async (args: ActionArgs): Promise<Out> => {
|
|
26
|
+
const raw = args.formData.get("intent");
|
|
27
|
+
const intent = typeof raw === "string" ? raw : "";
|
|
28
|
+
const handler = handlers[intent];
|
|
29
|
+
if (!handler) {
|
|
30
|
+
const known = Object.keys(handlers);
|
|
31
|
+
const message = isExplicitDev()
|
|
32
|
+
? `Unknown action intent ${JSON.stringify(intent)}. Known intents: ${known.join(", ") || "(none)"}.`
|
|
33
|
+
: "Unknown action intent.";
|
|
34
|
+
return Response.json({ error: message }, { status: 400 });
|
|
35
|
+
}
|
|
36
|
+
return (await handler(args)) as Out;
|
|
37
|
+
};
|
|
38
|
+
return dispatch;
|
|
39
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Small ergonomics for reading FormData in actions, where `.get()` returns
|
|
2
|
+
// `string | File | null` and almost every call site coerces to a string.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read a string field from FormData. Returns `""` when the field is missing or
|
|
6
|
+
* is a File (upload) — never `null`/`File`, so it drops straight into code that
|
|
7
|
+
* expects a string. Replaces the `String(formData.get("x") ?? "")` dance.
|
|
8
|
+
*/
|
|
9
|
+
export function formText(formData: FormData, key: string): string {
|
|
10
|
+
const value = formData.get(key);
|
|
11
|
+
return typeof value === "string" ? value : "";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Collect string fields from FormData into a plain object. With no `keys`, every
|
|
16
|
+
* string entry is included (Files are skipped, first occurrence wins per key);
|
|
17
|
+
* with `keys`, only those fields (each defaulting to `""`). Handy for passing a
|
|
18
|
+
* typed subset of a form to a model function.
|
|
19
|
+
*/
|
|
20
|
+
export function formValues(
|
|
21
|
+
formData: FormData,
|
|
22
|
+
keys?: string[],
|
|
23
|
+
): Record<string, string> {
|
|
24
|
+
const out: Record<string, string> = {};
|
|
25
|
+
if (keys) {
|
|
26
|
+
for (const key of keys) out[key] = formText(formData, key);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
for (const [key, value] of formData.entries()) {
|
|
30
|
+
if (key in out) continue; // first occurrence wins (mirrors FormData.get)
|
|
31
|
+
if (typeof value === "string") out[key] = value;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|