@bractjs/bractjs 0.1.26 → 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 +283 -58
- package/bin/cli.ts +18 -1
- package/package.json +1 -1
- package/src/__tests__/build-path.test.ts +29 -0
- package/src/__tests__/codegen-write.test.ts +67 -0
- package/src/__tests__/codegen.test.ts +64 -1
- 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 +239 -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 +239 -47
- package/src/client/build-path.ts +24 -0
- 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 +105 -11
- 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 +51 -0
- package/src/client/hooks/useParams.ts +15 -4
- package/src/client/hooks/useRevalidator.ts +26 -0
- package/src/client/hooks/useSearch.ts +73 -0
- package/src/client/hooks/useSearchParams.ts +21 -6
- package/src/client/nav-utils.ts +26 -0
- package/src/client/prefetch.ts +110 -15
- package/src/client/registry.ts +131 -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 +201 -29
- 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 +44 -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 +210 -10
- package/types/route.d.ts +62 -2
|
@@ -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
|
+
}
|
package/src/server/serve.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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 { renderSpaShell } from "./spa.ts";
|
|
4
5
|
import { type ServerManifest } from "./render.ts";
|
|
5
6
|
import { isDevRuntime, isExplicitDev } from "./env.ts";
|
|
6
7
|
import { loadManifest } from "../build/manifest.ts";
|
|
@@ -24,10 +25,24 @@ export interface BractJSConfig {
|
|
|
24
25
|
appDir: string;
|
|
25
26
|
publicDir: string;
|
|
26
27
|
manifest: ServerManifest;
|
|
28
|
+
/** WebSocket port for dev HMR (used by `bractjs dev` only). Default 3001. */
|
|
29
|
+
hmrPort?: number;
|
|
27
30
|
/** Optional custom adapter (Cloudflare Workers, Deno, Node, etc.). Defaults to Bun.serve(). */
|
|
28
31
|
adapter?: BractAdapter;
|
|
29
32
|
/** i18n locale prefix routing (E2). */
|
|
30
33
|
i18n?: I18nConfig;
|
|
34
|
+
/**
|
|
35
|
+
* SPA mode: `false` serves one static shell for every document GET instead
|
|
36
|
+
* of SSR. The server keeps running — /_data, actions, /_image, API routes
|
|
37
|
+
* and static assets behave exactly as in SSR mode ("no document SSR", not
|
|
38
|
+
* "no server"). Default `true`.
|
|
39
|
+
*/
|
|
40
|
+
ssr?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Paths to prerender at build time (SSG). Served from disk before dynamic
|
|
43
|
+
* SSR in production; requests with a query string stay dynamic.
|
|
44
|
+
*/
|
|
45
|
+
prerender?: string[] | (() => string[] | Promise<string[]>);
|
|
31
46
|
// Build options (used by src/build/bundler.ts)
|
|
32
47
|
sourcemap?: "none" | "linked" | "inline" | "external";
|
|
33
48
|
minify?: boolean;
|
|
@@ -129,6 +144,30 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
|
|
|
129
144
|
: loadServerActions(appDir);
|
|
130
145
|
const moduleRegistry = config.moduleRegistry;
|
|
131
146
|
const onError = config.onError;
|
|
147
|
+
const ssrEnabled = config.ssr !== false;
|
|
148
|
+
|
|
149
|
+
// SPA shell: production prefers the file `bractjs build` wrote; dev (or a
|
|
150
|
+
// missing file) renders it on demand so root.tsx edits show up. Cached per
|
|
151
|
+
// manifest in prod-without-file; never cached in dev.
|
|
152
|
+
let spaShellCache: { key: string; html: string } | null = null;
|
|
153
|
+
async function getSpaShell(manifest: ServerManifest): Promise<string> {
|
|
154
|
+
if (!isDevRuntime()) {
|
|
155
|
+
const file = Bun.file(join(buildDir, "client", "__spa.html"));
|
|
156
|
+
if (await file.exists()) return file.text();
|
|
157
|
+
const key = manifest.clientEntry;
|
|
158
|
+
if (spaShellCache?.key === key) return spaShellCache.html;
|
|
159
|
+
const html = await renderSpaShell(appDir, manifest, moduleRegistry);
|
|
160
|
+
spaShellCache = { key, html };
|
|
161
|
+
return html;
|
|
162
|
+
}
|
|
163
|
+
return renderSpaShell(appDir, manifest, moduleRegistry);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Prerendered file for a clean (query-free, dot-free) document path, or null. */
|
|
167
|
+
function prerenderFile(relHtmlOrJson: string): ReturnType<typeof Bun.file> | null {
|
|
168
|
+
if (relHtmlOrJson.split("/").some((s) => s === ".." || s === ".")) return null;
|
|
169
|
+
return Bun.file(join(buildDir, "client", "_prerender", relHtmlOrJson));
|
|
170
|
+
}
|
|
132
171
|
|
|
133
172
|
return async function fetch(request: Request): Promise<Response> {
|
|
134
173
|
const url = new URL(request.url);
|
|
@@ -196,6 +235,54 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
|
|
|
196
235
|
if (staticRes) return staticRes;
|
|
197
236
|
|
|
198
237
|
const trie = await trieReady;
|
|
238
|
+
const isDocGet = request.method === "GET" || request.method === "HEAD";
|
|
239
|
+
|
|
240
|
+
// SPA mode: every document GET that matches a route gets the static
|
|
241
|
+
// shell. /_data (no trie match) and mutations fall through to the normal
|
|
242
|
+
// handler, so loaders/actions/CSRF behave exactly as in SSR mode.
|
|
243
|
+
if (!ssrEnabled && isDocGet && matchRoute(pathname, trie)) {
|
|
244
|
+
const manifest = isDevRuntime() ? await readDevManifest(buildDir) : await manifestReady;
|
|
245
|
+
return new Response(await getSpaShell(manifest), {
|
|
246
|
+
headers: {
|
|
247
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
248
|
+
"Cache-Control": "no-cache",
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Prerendered output (production): serve the build-time HTML / _data
|
|
254
|
+
// payload for clean URLs. A query string opts the request back into
|
|
255
|
+
// dynamic SSR — the static file was rendered without one.
|
|
256
|
+
if (!isDevRuntime() && isDocGet) {
|
|
257
|
+
if (pathname === "/_data") {
|
|
258
|
+
const target = url.searchParams.get("path") ?? "/";
|
|
259
|
+
const [targetPathname, targetSearch] = target.split("?");
|
|
260
|
+
if (!targetSearch) {
|
|
261
|
+
const rel = targetPathname === "/" ? "_data.json" : targetPathname.slice(1) + "/_data.json";
|
|
262
|
+
const f = prerenderFile(rel);
|
|
263
|
+
if (f && (await f.exists())) {
|
|
264
|
+
return new Response(f, {
|
|
265
|
+
headers: {
|
|
266
|
+
"Content-Type": "application/json",
|
|
267
|
+
"Cache-Control": "public, max-age=0, must-revalidate",
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} else if (!url.search) {
|
|
273
|
+
const rel = pathname === "/" ? "index.html" : pathname.slice(1) + "/index.html";
|
|
274
|
+
const f = prerenderFile(rel);
|
|
275
|
+
if (f && (await f.exists())) {
|
|
276
|
+
return new Response(f, {
|
|
277
|
+
headers: {
|
|
278
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
279
|
+
"Cache-Control": "public, max-age=0, must-revalidate",
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
199
286
|
const manifest = isDevRuntime() ? await readDevManifest(buildDir) : await manifestReady;
|
|
200
287
|
const handlerConfig: HandlerConfig = { appDir, publicDir, manifest, onError, moduleRegistry };
|
|
201
288
|
return handleRequest(request, trie, handlerConfig);
|
|
@@ -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
|
|
|
@@ -48,21 +48,15 @@ function toPlainObject(input: FormData | Record<string, unknown>): Record<string
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
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.
|
|
51
|
+
* Run a plain object through a Zod/Valibot-compatible schema. The shared core
|
|
52
|
+
* of `validate()` (action/form bodies) and `validateSearch()` (URL search
|
|
53
|
+
* params). Throws a 400 `Response` with `{ errors }` field errors on failure;
|
|
54
|
+
* returns the parsed (coerced) data on success.
|
|
59
55
|
*/
|
|
60
|
-
export async function
|
|
56
|
+
export async function runSchema<T>(
|
|
61
57
|
schema: Schema<T>,
|
|
62
|
-
|
|
58
|
+
plain: Record<string, unknown>,
|
|
63
59
|
): Promise<T> {
|
|
64
|
-
const plain = toPlainObject(input);
|
|
65
|
-
|
|
66
60
|
if ("safeParse" in schema && typeof schema.safeParse === "function") {
|
|
67
61
|
const result = await schema.safeParse(plain);
|
|
68
62
|
if ((result as SafeParseResult<T>).success) {
|
|
@@ -87,3 +81,81 @@ export async function validate<T>(
|
|
|
87
81
|
throw Response.json({ errors: fieldErrors }, { status: 400, statusText: "Validation failed" });
|
|
88
82
|
}
|
|
89
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validate `input` against a Zod-compatible or Valibot-compatible schema.
|
|
87
|
+
*
|
|
88
|
+
* - If the schema has `.safeParse()`: uses it to collect field errors and throws
|
|
89
|
+
* a typed `ValidationError` on failure (which the framework converts to a 400).
|
|
90
|
+
* - If the schema only has `.parse()`: wraps it and re-throws the error as a
|
|
91
|
+
* `ValidationError` with a single `_` field containing the error message.
|
|
92
|
+
*
|
|
93
|
+
* Returns the parsed (coerced) data on success.
|
|
94
|
+
*/
|
|
95
|
+
export async function validate<T>(
|
|
96
|
+
schema: Schema<T>,
|
|
97
|
+
input: FormData | Record<string, unknown>,
|
|
98
|
+
): Promise<T> {
|
|
99
|
+
return runSchema(schema, toPlainObject(input));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Non-throwing validation (ergonomic action idiom) ───────────────────────
|
|
103
|
+
|
|
104
|
+
export type SafeValidateResult<T> =
|
|
105
|
+
| { ok: true; data: T }
|
|
106
|
+
| { ok: false; fieldErrors: FieldErrors; firstError: string };
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Like {@link validate}, but returns a result instead of throwing — the
|
|
110
|
+
* ergonomic shape for actions that want to render field errors:
|
|
111
|
+
*
|
|
112
|
+
* ```ts
|
|
113
|
+
* const r = await safeValidate(PostSchema, formData);
|
|
114
|
+
* if (!r.ok) return { error: r.firstError, fieldErrors: r.fieldErrors };
|
|
115
|
+
* usePost(r.data);
|
|
116
|
+
* ```
|
|
117
|
+
*
|
|
118
|
+
* `firstError` is the first message across all fields, or a generic fallback.
|
|
119
|
+
*/
|
|
120
|
+
export async function safeValidate<T>(
|
|
121
|
+
schema: Schema<T>,
|
|
122
|
+
input: FormData | Record<string, unknown>,
|
|
123
|
+
): Promise<SafeValidateResult<T>> {
|
|
124
|
+
try {
|
|
125
|
+
const data = await runSchema(schema, toPlainObject(input));
|
|
126
|
+
return { ok: true, data };
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (isValidationResponse(err)) {
|
|
129
|
+
const { fieldErrors, firstError } = await readValidationError(err);
|
|
130
|
+
return { ok: false, fieldErrors, firstError };
|
|
131
|
+
}
|
|
132
|
+
throw err; // not a validation failure — let it propagate
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* True for the 400 `Response` thrown by {@link validate} / `searchSchema`
|
|
138
|
+
* validation (identified by status 400 + `statusText "Validation failed"`).
|
|
139
|
+
* Use it in the try/catch idiom when you keep calling `validate()` directly.
|
|
140
|
+
*/
|
|
141
|
+
export function isValidationResponse(value: unknown): value is Response {
|
|
142
|
+
return value instanceof Response && value.status === 400 && value.statusText === "Validation failed";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parse the `{ errors }` body of a validation 400 `Response` into field errors
|
|
147
|
+
* plus the first message. Tolerant of a non-JSON / unexpected body.
|
|
148
|
+
*/
|
|
149
|
+
export async function readValidationError(
|
|
150
|
+
res: Response,
|
|
151
|
+
): Promise<{ fieldErrors: FieldErrors; firstError: string }> {
|
|
152
|
+
const fallback = "Please check your input.";
|
|
153
|
+
try {
|
|
154
|
+
const body = (await res.clone().json()) as { errors?: FieldErrors };
|
|
155
|
+
const fieldErrors = body.errors ?? {};
|
|
156
|
+
const firstError = Object.values(fieldErrors)[0]?.[0] ?? fallback;
|
|
157
|
+
return { fieldErrors, firstError };
|
|
158
|
+
} catch {
|
|
159
|
+
return { fieldErrors: {}, firstError: fallback };
|
|
160
|
+
}
|
|
161
|
+
}
|
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 } from "./route-types.ts";
|
|
2
3
|
|
|
3
4
|
export interface RouteManifest {
|
|
4
5
|
[routeId: string]: {
|
|
@@ -15,6 +16,10 @@ 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>;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
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
|
+
}
|
|
@@ -1,15 +1,57 @@
|
|
|
1
1
|
import type { Deferred } from "./deferred.ts";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* A parsed navigation location. `key` is the stable identity of the history
|
|
5
|
+
* entry (used by scroll restoration); `state` is the value passed via
|
|
6
|
+
* `navigate(to, { state })`. During SSR `hash` is always `""` (the fragment
|
|
7
|
+
* never reaches the server) and `key` is `"default"`.
|
|
8
|
+
*/
|
|
9
|
+
export interface RouterLocation {
|
|
10
|
+
pathname: string;
|
|
11
|
+
/** Raw query string including the leading `?`, or `""`. */
|
|
12
|
+
search: string;
|
|
13
|
+
/** Fragment including the leading `#`, or `""`. */
|
|
14
|
+
hash: string;
|
|
15
|
+
state: unknown;
|
|
16
|
+
key: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LoaderArgs<TSearch extends Record<string, unknown> = Record<string, unknown>> {
|
|
4
20
|
request: Request;
|
|
5
21
|
params: Record<string, string>;
|
|
6
22
|
context: Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* The request's search params, validated/coerced by the route's
|
|
25
|
+
* `searchSchema` export when present; otherwise the raw string record
|
|
26
|
+
* (repeated keys become arrays).
|
|
27
|
+
*
|
|
28
|
+
* Parameterize to skip the cast in routes with a schema:
|
|
29
|
+
* `loader({ search }: LoaderArgs<BoardSearch>)`.
|
|
30
|
+
*/
|
|
31
|
+
search: TSearch;
|
|
7
32
|
}
|
|
8
33
|
|
|
9
|
-
export interface ActionArgs extends
|
|
34
|
+
export interface ActionArgs<TSearch extends Record<string, unknown> = Record<string, unknown>>
|
|
35
|
+
extends LoaderArgs<TSearch> {
|
|
10
36
|
formData: FormData;
|
|
11
37
|
}
|
|
12
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The data a route's loader resolves to, for typing `useLoaderData`.
|
|
41
|
+
*
|
|
42
|
+
* Pass the loader FUNCTION type and it unwraps the return (awaited, with the
|
|
43
|
+
* `Response` redirect/throw branch removed): `useLoaderData<typeof loader>()`.
|
|
44
|
+
* Pass a plain object type and it's returned as-is (back-compat):
|
|
45
|
+
* `useLoaderData<HomeData>()`. `Deferred<V>` fields are preserved — that is the
|
|
46
|
+
* shape the component receives during streaming SSR (unwrap them with `<Await>`).
|
|
47
|
+
*/
|
|
48
|
+
export type LoaderData<T> = T extends (...args: never[]) => unknown
|
|
49
|
+
? Exclude<Awaited<ReturnType<T>>, Response>
|
|
50
|
+
: T;
|
|
51
|
+
|
|
52
|
+
/** The data a route's action resolves to, for typing `useActionData`. See {@link LoaderData}. */
|
|
53
|
+
export type ActionData<T> = LoaderData<T>;
|
|
54
|
+
|
|
13
55
|
export type MetaDescriptor =
|
|
14
56
|
| { title: string }
|
|
15
57
|
| { name: string; content: string }
|
|
@@ -37,17 +79,56 @@ export interface BeforeLoadArgs {
|
|
|
37
79
|
params: Record<string, string>;
|
|
38
80
|
context: Record<string, unknown>;
|
|
39
81
|
location: { pathname: string; search: string };
|
|
82
|
+
/** Validated search params (server-side only; absent in the client-side guard). */
|
|
83
|
+
search?: Record<string, unknown>;
|
|
40
84
|
}
|
|
41
85
|
|
|
42
86
|
export type BeforeLoadFunction = (
|
|
43
87
|
args: BeforeLoadArgs,
|
|
44
88
|
) => void | Response | Promise<void | Response>;
|
|
45
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Decide whether loader data should be refetched. Evaluated on the CLIENT for
|
|
92
|
+
* (a) the stale-while-revalidate background refetch and (b) the automatic
|
|
93
|
+
* revalidation after a `<Form>`/fetcher mutation. Return
|
|
94
|
+
* `args.defaultShouldRevalidate` (true) to keep the default behavior.
|
|
95
|
+
*/
|
|
96
|
+
export interface ShouldRevalidateArgs {
|
|
97
|
+
currentUrl: URL;
|
|
98
|
+
nextUrl: URL;
|
|
99
|
+
/** Present when the revalidation was triggered by a mutation. */
|
|
100
|
+
formMethod?: string;
|
|
101
|
+
/** HTTP status the action responded with, when mutation-triggered. */
|
|
102
|
+
actionStatus?: number;
|
|
103
|
+
defaultShouldRevalidate: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type ShouldRevalidateFunction = (args: ShouldRevalidateArgs) => boolean;
|
|
107
|
+
|
|
46
108
|
export interface RouteModule<TLoader = unknown, TAction = unknown> {
|
|
47
109
|
loader?: LoaderFunction<TLoader>;
|
|
48
110
|
action?: ActionFunction<TAction>;
|
|
49
111
|
meta?: MetaFunction<TLoader>;
|
|
50
112
|
beforeLoad?: BeforeLoadFunction;
|
|
113
|
+
shouldRevalidate?: ShouldRevalidateFunction;
|
|
114
|
+
/**
|
|
115
|
+
* Zod/Valibot-compatible schema validating the route's search params before
|
|
116
|
+
* loaders run. Failure → 400; use `.catch()`/`.default()` per field for
|
|
117
|
+
* URLs that must tolerate junk values.
|
|
118
|
+
*/
|
|
119
|
+
searchSchema?: unknown;
|
|
120
|
+
/**
|
|
121
|
+
* Selective SSR (TanStack-style):
|
|
122
|
+
* - `true` (default) — full document SSR with loader data.
|
|
123
|
+
* - `"data-only"` — loaders run on the server, but the component renders
|
|
124
|
+
* only on the client (`Fallback` SSRs in its place).
|
|
125
|
+
* - `false` — neither the route loader nor the component runs during
|
|
126
|
+
* document SSR; the client fetches `/_data` after hydration. `beforeLoad`
|
|
127
|
+
* STILL runs on the server — it is the auth gate.
|
|
128
|
+
*/
|
|
129
|
+
ssr?: boolean | "data-only";
|
|
130
|
+
/** SSR'd in the component's place for `ssr: false` / `"data-only"` routes (HydrateFallback equivalent). */
|
|
131
|
+
Fallback?: React.ComponentType;
|
|
51
132
|
handle?: Record<string, unknown>;
|
|
52
133
|
ErrorBoundary?: React.ComponentType<{ error: unknown }>;
|
|
53
134
|
default?: React.ComponentType;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// This is the root layout for your BractJS app.
|
|
2
2
|
// Every route renders inside this component.
|
|
3
|
-
import { Scripts, LiveReload, Outlet } from "@bractjs/bractjs";
|
|
3
|
+
import { Scripts, LiveReload, Outlet, ScrollRestoration } from "@bractjs/bractjs";
|
|
4
4
|
|
|
5
5
|
export default function Root() {
|
|
6
6
|
return (
|
|
@@ -12,6 +12,7 @@ export default function Root() {
|
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<Outlet />
|
|
15
|
+
<ScrollRestoration />
|
|
15
16
|
<Scripts />
|
|
16
17
|
<LiveReload />
|
|
17
18
|
</body>
|
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { defineConfig } from "@bractjs/bractjs";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// All fields are optional and merged over BractJS defaults. `defineConfig`
|
|
4
|
+
// gives you autocomplete + type-checking without annotating the full type.
|
|
5
|
+
// (The build manifest is injected at runtime — you never set it here.)
|
|
6
|
+
export default defineConfig({
|
|
4
7
|
port: 3000,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
buildDir: "./build",
|
|
8
|
-
manifest: { clientEntry: "", routes: {} }, // populated by `bractjs build`
|
|
9
|
-
minify: true,
|
|
10
|
-
sourcemap: "external",
|
|
11
|
-
clientEnv: [],
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
export default config;
|
|
8
|
+
clientEnv: [], // process.env keys to expose to the client bundle
|
|
9
|
+
});
|
package/types/config.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BunPlugin } from "bun";
|
|
2
2
|
import type { RouteFile, RouteModule } from "./route.d.ts";
|
|
3
|
+
import type { BractAdapter, I18nConfig, OnErrorHook } from "./index.d.ts";
|
|
3
4
|
|
|
4
5
|
export interface BractJSConfig {
|
|
5
6
|
/** TCP port to listen on. Default: 3000. */
|
|
@@ -20,10 +21,20 @@ export interface BractJSConfig {
|
|
|
20
21
|
clientEnv?: string[];
|
|
21
22
|
/** User Bun bundler plugins appended to the client build (e.g. bun-plugin-tailwind). */
|
|
22
23
|
plugins?: BunPlugin[];
|
|
24
|
+
/** Directory for the transformed-image cache. Default: ".bract-image-cache". */
|
|
25
|
+
imageCacheDir?: string;
|
|
26
|
+
/** WebSocket port for dev HMR (used by `bractjs dev` only). Default 3001. */
|
|
27
|
+
hmrPort?: number;
|
|
28
|
+
/** Custom server adapter (Cloudflare Workers, Deno, Node, etc.). Defaults to Bun.serve(). */
|
|
29
|
+
adapter?: BractAdapter;
|
|
30
|
+
/** i18n locale-prefix routing config consumed by the i18n utilities. */
|
|
31
|
+
i18n?: I18nConfig;
|
|
23
32
|
/** Called once after the server starts listening. Use to open DB connections, warm caches, etc. */
|
|
24
33
|
onStart?: () => Promise<void> | void;
|
|
25
34
|
/** Called before the process exits (any signal or uncaught error). Use to close DB connections, flush queues, etc. */
|
|
26
35
|
onShutdown?: () => Promise<void> | void;
|
|
36
|
+
/** Called for every unexpected error (loader/action throws, uncaught exceptions). Redirects and HttpErrors are not reported. */
|
|
37
|
+
onError?: OnErrorHook;
|
|
27
38
|
/**
|
|
28
39
|
* Pre-scanned route list. Typically imported from `app/_generated/routes.ts`.
|
|
29
40
|
* Required for `bun build --compile` binaries where the routes/ directory
|
|
@@ -41,6 +52,14 @@ export interface BractJSConfig {
|
|
|
41
52
|
* proxy plugin hashed during the client build.
|
|
42
53
|
*/
|
|
43
54
|
actionModules?: Array<{ relPath: string; mod: Record<string, unknown> }>;
|
|
55
|
+
/**
|
|
56
|
+
* SPA mode: `false` serves one static shell for every document GET instead
|
|
57
|
+
* of SSR ("no document SSR", not "no server" — /_data, actions, images and
|
|
58
|
+
* API routes keep working). Default `true`.
|
|
59
|
+
*/
|
|
60
|
+
ssr?: boolean;
|
|
61
|
+
/** Paths to prerender at build time (SSG); served from disk before dynamic SSR. */
|
|
62
|
+
prerender?: string[] | (() => string[] | Promise<string[]>);
|
|
44
63
|
}
|
|
45
64
|
|
|
46
65
|
export interface ServerManifest {
|
|
@@ -60,4 +79,6 @@ export interface BuildConfig {
|
|
|
60
79
|
minify?: boolean;
|
|
61
80
|
clientEnv?: string[];
|
|
62
81
|
plugins?: import("bun").BunPlugin[];
|
|
82
|
+
/** SPA mode: when `false`, the build also emits the static document shell. */
|
|
83
|
+
ssr?: boolean;
|
|
63
84
|
}
|