@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.
Files changed (95) hide show
  1. package/README.md +242 -36
  2. package/bin/cli.ts +18 -1
  3. package/package.json +1 -1
  4. package/src/__tests__/codegen-write.test.ts +67 -0
  5. package/src/__tests__/codegen.test.ts +29 -2
  6. package/src/__tests__/compile-safety.test.ts +4 -0
  7. package/src/__tests__/csp.test.ts +10 -0
  8. package/src/__tests__/define-actions.test.ts +69 -0
  9. package/src/__tests__/env.test.ts +18 -0
  10. package/src/__tests__/fetcher-store.test.ts +67 -0
  11. package/src/__tests__/fixtures/app/root.tsx +7 -2
  12. package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
  13. package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
  14. package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
  15. package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
  16. package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
  17. package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
  18. package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
  19. package/src/__tests__/form-data-helpers.test.ts +43 -0
  20. package/src/__tests__/integration.test.ts +56 -0
  21. package/src/__tests__/loader.test.ts +32 -1
  22. package/src/__tests__/nav-utils.test.ts +46 -0
  23. package/src/__tests__/prerender.test.ts +102 -0
  24. package/src/__tests__/programmatic-api.test.ts +20 -1
  25. package/src/__tests__/revalidation.test.ts +65 -0
  26. package/src/__tests__/route-lint.test.ts +74 -0
  27. package/src/__tests__/route-table.test.ts +33 -0
  28. package/src/__tests__/safe-validate.test.ts +96 -0
  29. package/src/__tests__/scroll-restoration.test.ts +66 -0
  30. package/src/__tests__/search-serializer.test.ts +42 -0
  31. package/src/__tests__/search-validation.test.ts +125 -0
  32. package/src/__tests__/security.test.ts +110 -1
  33. package/src/__tests__/selective-ssr.test.ts +85 -0
  34. package/src/__tests__/spa-mode.test.ts +77 -0
  35. package/src/__tests__/typed-routing.test.ts +51 -1
  36. package/src/build/bundler.ts +33 -0
  37. package/src/build/prerender.ts +88 -0
  38. package/src/build/route-lint.ts +49 -0
  39. package/src/client/ClientRouter.tsx +239 -47
  40. package/src/client/cache.ts +8 -0
  41. package/src/client/components/Await.tsx +9 -2
  42. package/src/client/components/Form.tsx +23 -34
  43. package/src/client/components/Link.tsx +80 -9
  44. package/src/client/components/Outlet.tsx +8 -2
  45. package/src/client/components/ScrollRestoration.tsx +125 -0
  46. package/src/client/entry.tsx +39 -2
  47. package/src/client/fetcher-store.ts +61 -0
  48. package/src/client/form-utils.ts +3 -0
  49. package/src/client/hooks/useActionData.ts +7 -3
  50. package/src/client/hooks/useFetcher.ts +116 -33
  51. package/src/client/hooks/useFetchers.ts +23 -0
  52. package/src/client/hooks/useLoaderData.ts +8 -4
  53. package/src/client/hooks/useLocation.ts +27 -0
  54. package/src/client/hooks/useNavigate.ts +11 -6
  55. package/src/client/hooks/useRevalidator.ts +26 -0
  56. package/src/client/hooks/useSearch.ts +73 -0
  57. package/src/client/hooks/useSearchParams.ts +7 -2
  58. package/src/client/nav-utils.ts +26 -0
  59. package/src/client/prefetch.ts +110 -15
  60. package/src/client/registry.ts +24 -0
  61. package/src/client/revalidation.ts +25 -0
  62. package/src/client/router.tsx +28 -1
  63. package/src/client/scroll-restoration.ts +48 -0
  64. package/src/client/search-serializer.ts +40 -0
  65. package/src/client/types.ts +6 -0
  66. package/src/codegen/route-codegen.ts +141 -8
  67. package/src/config/load.ts +21 -0
  68. package/src/dev/hmr-client.ts +3 -1
  69. package/src/dev/route-table.ts +27 -0
  70. package/src/dev/server.ts +106 -8
  71. package/src/dev/watcher.ts +25 -3
  72. package/src/index.ts +27 -3
  73. package/src/server/action-handler.ts +12 -3
  74. package/src/server/action-registry.ts +35 -0
  75. package/src/server/csp.ts +10 -1
  76. package/src/server/csrf.ts +26 -0
  77. package/src/server/env.ts +26 -5
  78. package/src/server/layout.ts +31 -1
  79. package/src/server/loader.ts +14 -8
  80. package/src/server/render.ts +18 -3
  81. package/src/server/request-handler.ts +50 -8
  82. package/src/server/search.ts +43 -0
  83. package/src/server/serve.ts +88 -1
  84. package/src/server/spa.ts +62 -0
  85. package/src/server/stream-handler.ts +10 -1
  86. package/src/server/validate.ts +85 -13
  87. package/src/shared/context.ts +5 -0
  88. package/src/shared/define-actions.ts +39 -0
  89. package/src/shared/form-data.ts +34 -0
  90. package/src/shared/route-types.ts +83 -2
  91. package/templates/new-app/app/root.tsx +2 -1
  92. package/templates/new-app/bractjs.config.ts +7 -12
  93. package/types/config.d.ts +21 -0
  94. package/types/index.d.ts +165 -9
  95. package/types/route.d.ts +62 -2
@@ -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") {
@@ -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
- * Validate `input` against a Zod-compatible or Valibot-compatible schema.
52
- *
53
- * - If the schema has `.safeParse()`: uses it to collect field errors and throws
54
- * a typed `ValidationError` on failure (which the framework converts to a 400).
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 validate<T>(
56
+ export async function runSchema<T>(
61
57
  schema: Schema<T>,
62
- input: FormData | Record<string, unknown>,
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
+ }
@@ -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
- export interface LoaderArgs {
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 LoaderArgs {
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 type { BractJSConfig } from "@bractjs/bractjs";
1
+ import { defineConfig } from "@bractjs/bractjs";
2
2
 
3
- const config: BractJSConfig = {
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
- appDir: "./app",
6
- publicDir: "./public",
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
  }