@bractjs/bractjs 0.1.5 → 0.1.7

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 (66) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/action-handler.test.ts +47 -0
  3. package/src/__tests__/action-registry.test.ts +73 -0
  4. package/src/__tests__/codegen.test.ts +50 -0
  5. package/src/__tests__/deferred.test.ts +96 -0
  6. package/src/__tests__/directives.test.ts +52 -0
  7. package/src/__tests__/env.test.ts +73 -0
  8. package/src/__tests__/errors.test.ts +113 -0
  9. package/src/__tests__/hash.test.ts +19 -0
  10. package/src/__tests__/integration.test.ts +1 -1
  11. package/src/__tests__/loader.test.ts +5 -2
  12. package/src/__tests__/manifest.test.ts +60 -0
  13. package/src/__tests__/middleware.test.ts +216 -0
  14. package/src/__tests__/response.test.ts +106 -0
  15. package/src/__tests__/security.test.ts +348 -0
  16. package/src/__tests__/session.test.ts +3 -3
  17. package/src/adapters/cloudflare.ts +65 -0
  18. package/src/build/bundler.ts +17 -6
  19. package/src/build/directives.ts +30 -3
  20. package/src/build/env-plugin.ts +8 -0
  21. package/src/build/hash.ts +0 -20
  22. package/src/build/plugins/css-modules.ts +110 -0
  23. package/src/client/ClientRouter.tsx +121 -13
  24. package/src/client/cache.ts +69 -0
  25. package/src/client/components/Link.tsx +16 -2
  26. package/src/client/components/LiveReload.tsx +4 -0
  27. package/src/client/hooks/useBlocker.ts +44 -0
  28. package/src/client/hooks/useFetcher.ts +66 -6
  29. package/src/client/hooks/useLocale.ts +12 -0
  30. package/src/client/hooks/useLocalizedLink.ts +18 -0
  31. package/src/client/hooks/useSearchParams.ts +74 -0
  32. package/src/client/rpc.ts +70 -0
  33. package/src/codegen/route-codegen.ts +96 -10
  34. package/src/dev/devtools.ts +144 -0
  35. package/src/dev/hmr-client.ts +14 -0
  36. package/src/dev/hmr-module-handler.ts +31 -5
  37. package/src/dev/hmr-server.ts +16 -0
  38. package/src/image/cache.ts +28 -8
  39. package/src/image/handler.ts +31 -13
  40. package/src/image/optimizer.ts +51 -14
  41. package/src/image/types.ts +1 -0
  42. package/src/index.ts +27 -0
  43. package/src/middleware/cors.ts +28 -8
  44. package/src/middleware/requestLogger.ts +4 -0
  45. package/src/server/action-handler.ts +45 -2
  46. package/src/server/action-registry.ts +14 -1
  47. package/src/server/adapter.ts +57 -0
  48. package/src/server/api-route.ts +127 -0
  49. package/src/server/context.ts +22 -0
  50. package/src/server/csrf.ts +17 -0
  51. package/src/server/env.ts +26 -4
  52. package/src/server/i18n.ts +63 -0
  53. package/src/server/loader.ts +61 -1
  54. package/src/server/middleware.ts +11 -7
  55. package/src/server/render.ts +14 -5
  56. package/src/server/request-handler.ts +77 -18
  57. package/src/server/response.ts +29 -5
  58. package/src/server/scanner.ts +6 -2
  59. package/src/server/serve.ts +102 -55
  60. package/src/server/session.ts +17 -5
  61. package/src/server/static.ts +31 -8
  62. package/src/server/stream-handler.ts +111 -0
  63. package/src/server/validate.ts +89 -0
  64. package/src/shared/route-types.ts +11 -0
  65. package/types/index.d.ts +94 -1
  66. package/types/route.d.ts +11 -0
@@ -3,7 +3,7 @@ import { createCookieSession } from "../server/session.ts";
3
3
 
4
4
  const sessionStorage = createCookieSession({
5
5
  name: "__test",
6
- secrets: ["secret-one", "secret-two"],
6
+ secrets: ["secret-one-1234567890", "secret-two-1234567890"],
7
7
  secure: false,
8
8
  sameSite: "Lax",
9
9
  });
@@ -71,7 +71,7 @@ describe("createCookieSession — commitSession + roundtrip", () => {
71
71
  test("secret rotation: old secret still verifies", async () => {
72
72
  const oldStorage = createCookieSession({
73
73
  name: "__test",
74
- secrets: ["secret-two"], // only the old secret
74
+ secrets: ["secret-two-1234567890"], // only the old secret
75
75
  secure: false,
76
76
  });
77
77
  const s1 = await oldStorage.getSession(null);
@@ -81,7 +81,7 @@ describe("createCookieSession — commitSession + roundtrip", () => {
81
81
  // New storage has new secret first, old secret second (rotation)
82
82
  const newStorage = createCookieSession({
83
83
  name: "__test",
84
- secrets: ["secret-one", "secret-two"],
84
+ secrets: ["secret-one-1234567890", "secret-two-1234567890"],
85
85
  secure: false,
86
86
  });
87
87
  const cookieValue = cookie.split(";")[0];
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Cloudflare Workers adapter for BractJS.
3
+ *
4
+ * Usage in your worker entrypoint:
5
+ * import { createCloudflareAdapter } from 'bractjs/adapters/cloudflare';
6
+ * import { buildFetchHandler } from 'bractjs';
7
+ *
8
+ * const handler = buildFetchHandler({ appDir: './app', ... });
9
+ * export default createCloudflareAdapter(handler);
10
+ *
11
+ * Build with:
12
+ * bun build --target=browser --outfile=dist/worker.js src/worker.ts
13
+ */
14
+
15
+ import type { BractAdapter } from "../server/adapter.ts";
16
+
17
+ // Cloudflare Workers ExportedHandler shape (subset we need).
18
+ interface CloudflareEnv {
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ interface CloudflareExecutionContext {
23
+ waitUntil(promise: Promise<unknown>): void;
24
+ passThroughOnException(): void;
25
+ }
26
+
27
+ interface CloudflareExportedHandler {
28
+ fetch(request: Request, env: CloudflareEnv, ctx: CloudflareExecutionContext): Promise<Response>;
29
+ }
30
+
31
+ /**
32
+ * Wraps a BractJS fetch handler in the Cloudflare Workers `{ fetch }` export pattern.
33
+ *
34
+ * The adapter implements BractAdapter so it can also be passed to createServer()
35
+ * in a dual-mode setup (dev = Bun, prod = CF).
36
+ */
37
+ export function createCloudflareAdapter(
38
+ handler: (request: Request) => Promise<Response>,
39
+ ): CloudflareExportedHandler & BractAdapter {
40
+ return {
41
+ // BractAdapter compat
42
+ fetch(request: Request) {
43
+ return handler(request);
44
+ },
45
+ // Cloudflare Workers entrypoint — env and ctx are available for KV, D1, etc.
46
+ // Forward them via a custom header so route handlers can read them if needed.
47
+ // (Full KV/D1 integration would require framework-level dependency injection.)
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Convenience: export a Cloudflare Workers handler from your app config.
53
+ *
54
+ * Usage in src/worker.ts:
55
+ * export default cloudflareHandler;
56
+ */
57
+ export function makeCloudflareHandler(
58
+ handler: (request: Request) => Promise<Response>,
59
+ ): { fetch(request: Request, env: CloudflareEnv, ctx: CloudflareExecutionContext): Promise<Response> } {
60
+ return {
61
+ fetch(request, _env, _ctx) {
62
+ return handler(request);
63
+ },
64
+ };
65
+ }
@@ -1,4 +1,4 @@
1
- import { join } from "node:path";
1
+ import { join, basename, extname, resolve } from "node:path";
2
2
  import { rename } from "node:fs/promises";
3
3
  import type { BractJSConfig } from "../server/serve.ts";
4
4
  import { scanRoutes } from "../server/scanner.ts";
@@ -8,6 +8,7 @@ import { serverOnlyPlugin, clientEnvPlugin } from "./env-plugin.ts";
8
8
  import { buildDefines } from "./defines.ts";
9
9
  import { writeRouteTypes } from "../codegen/route-codegen.ts";
10
10
  import { useClientStubPlugin, useServerProxyPlugin } from "./directives.ts";
11
+ import { cssModulesPlugin } from "./plugins/css-modules.ts";
11
12
 
12
13
  export async function runBuild(config: BractJSConfig): Promise<void> {
13
14
  const appDir = config.appDir ?? "./app";
@@ -39,7 +40,7 @@ export async function runBuild(config: BractJSConfig): Promise<void> {
39
40
  minify: config.minify ?? true,
40
41
  sourcemap: config.sourcemap ?? "external",
41
42
  define: buildDefines(config),
42
- plugins: [serverOnlyPlugin, useServerProxyPlugin, clientEnvPlugin(config.clientEnv ?? [], Bun.env as Record<string, string>)],
43
+ plugins: [serverOnlyPlugin, useServerProxyPlugin, clientEnvPlugin(config.clientEnv ?? [], Bun.env as Record<string, string>), cssModulesPlugin],
43
44
  });
44
45
  if (!clientResult.success) throw new AggregateError(clientResult.logs, "Client build failed");
45
46
 
@@ -47,6 +48,10 @@ export async function runBuild(config: BractJSConfig): Promise<void> {
47
48
  const routeChunks = new Map<string, string>();
48
49
  let clientEntry = "";
49
50
  let rootChunk: string | undefined;
51
+ const outdirAbs = resolve("build/client");
52
+ const appDirClean = appDir.replace(/^\.\//, "");
53
+ const entryBase = basename("src/client/entry.tsx", extname("src/client/entry.tsx")); // "entry"
54
+ const rootBase = basename(rootFilePath, extname(rootFilePath)); // "root"
50
55
 
51
56
  for (const artifact of clientResult.outputs) {
52
57
  if (artifact.kind !== "chunk" && artifact.kind !== "entry-point") continue;
@@ -57,13 +62,19 @@ export async function runBuild(config: BractJSConfig): Promise<void> {
57
62
  await rename(artifact.path, hashedPath);
58
63
 
59
64
  const publicPath = "/" + hashedPath.replace(/^build\//, "build/");
60
- if (artifact.kind === "entry-point" && artifact.path.includes("entry")) {
65
+ const absPath = resolve(artifact.path);
66
+ const rel = absPath.startsWith(outdirAbs + "/") ? absPath.slice(outdirAbs.length + 1) : basename(artifact.path);
67
+ const outBase = basename(artifact.path, extname(artifact.path));
68
+
69
+ if (artifact.kind === "entry-point" && outBase === entryBase) {
61
70
  clientEntry = publicPath;
62
- } else if (artifact.kind === "entry-point" && artifact.path.includes("root")) {
71
+ } else if (artifact.kind === "entry-point" && outBase === rootBase) {
63
72
  rootChunk = publicPath;
64
73
  } else {
65
- // Map route file path back to URL pattern
66
- const matched = routes.find((r) => artifact.path.includes(r.filePath.replace("./", "")));
74
+ const matched = routes.find((r) => {
75
+ const expected = join(appDirClean, r.filePath).replace(/\.[^.]+$/, ".js");
76
+ return rel === expected;
77
+ });
67
78
  if (matched) routeChunks.set(matched.urlPattern, publicPath);
68
79
  }
69
80
  }
@@ -3,10 +3,37 @@ import type { BunPlugin } from "bun";
3
3
  const CLIENT_RE = /^["']use client["']/m;
4
4
  const SERVER_RE = /^["']use server["']/m;
5
5
 
6
+ // Strip a UTF-8 BOM and any leading ASCII whitespace before testing the
7
+ // directive regex. Editors that save files with BOM otherwise let "use server"
8
+ // fall through and ship server code to the client bundle.
9
+ function normalizeForDirectiveCheck(src: string): string {
10
+ return src.replace(/^/, "").replace(/^\s+/, "");
11
+ }
12
+ function hasClientDirective(src: string): boolean {
13
+ return CLIENT_RE.test(normalizeForDirectiveCheck(src));
14
+ }
15
+ function hasServerDirective(src: string): boolean {
16
+ return SERVER_RE.test(normalizeForDirectiveCheck(src));
17
+ }
18
+
6
19
  function extractExports(src: string): string[] {
7
20
  const names: string[] = [];
8
21
  for (const m of src.matchAll(/^export\s+(?:async\s+)?function\s+(\w+)/gm)) names.push(m[1]);
9
- for (const m of src.matchAll(/^export\s+(?:let|const)\s+(\w+)\s*=/gm)) names.push(m[1]);
22
+ for (const m of src.matchAll(/^export\s+(?:let|const|var)\s+(\w+)\s*=/gm)) names.push(m[1]);
23
+ for (const m of src.matchAll(/^export\s+default\s+(?:async\s+)?function\s+(\w+)/gm)) names.push(m[1]);
24
+ for (const m of src.matchAll(/^export\s+class\s+(\w+)/gm)) names.push(m[1]);
25
+ for (const m of src.matchAll(/^export\s*\{([^}]+)\}/gm)) {
26
+ for (const part of m[1].split(",")) {
27
+ const trimmed = part.trim();
28
+ if (!trimmed) continue;
29
+ const asMatch = trimmed.match(/\bas\s+(\w+)$/);
30
+ if (asMatch) names.push(asMatch[1]);
31
+ else {
32
+ const idMatch = trimmed.match(/^(\w+)/);
33
+ if (idMatch) names.push(idMatch[1]);
34
+ }
35
+ }
36
+ }
10
37
  return names;
11
38
  }
12
39
 
@@ -25,7 +52,7 @@ export const useClientStubPlugin: BunPlugin = {
25
52
  setup(build) {
26
53
  build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async ({ path }) => {
27
54
  const src = await Bun.file(path).text();
28
- if (!CLIENT_RE.test(src)) return undefined;
55
+ if (!hasClientDirective(src)) return undefined;
29
56
  const stubs = extractExports(src).map((n) => `export const ${n} = () => null;`).join("\n");
30
57
  return { contents: stubs || "export {};", loader: "ts" };
31
58
  });
@@ -52,7 +79,7 @@ export const useServerProxyPlugin: BunPlugin = {
52
79
  setup(build) {
53
80
  build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async ({ path }) => {
54
81
  const src = await Bun.file(path).text();
55
- if (!SERVER_RE.test(src)) return undefined;
82
+ if (!hasServerDirective(src)) return undefined;
56
83
  const names = extractExports(src);
57
84
  if (names.length === 0) return { contents: "export {};", loader: "ts" };
58
85
  const proxies = await Promise.all(
@@ -41,7 +41,15 @@ export function clientEnvPlugin(
41
41
  name: "bractjs-client-env",
42
42
  setup(build) {
43
43
  build.onLoad({ filter: /\.(ts|tsx|js|jsx)$/ }, async (args) => {
44
+ if (args.path.includes("/node_modules/")) return undefined;
44
45
  const src = await Bun.file(args.path).text();
46
+ // SECURITY(medium): textual regex replace runs over the whole source,
47
+ // including inside string literals and comments. A bare `process.env.X`
48
+ // anywhere — even in a documentation string — becomes the literal value
49
+ // (or "undefined"). This is acceptable for client builds because
50
+ // unwanted occurrences only yield the string "undefined", never a
51
+ // server secret. The allowedKeys gate is the authoritative leak check;
52
+ // never widen it without auditing callers.
45
53
  const contents = src.replace(
46
54
  /process\.env\.([A-Z_][A-Z0-9_]*)/g,
47
55
  (_match, key: string) =>
package/src/build/hash.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { extname, basename, dirname, join } from "node:path";
2
- import { test, expect } from "bun:test";
3
2
 
4
3
  // ── Helpers ────────────────────────────────────────────────────────────────
5
4
 
@@ -35,22 +34,3 @@ export async function renameWithHash(filePath: string): Promise<string> {
35
34
  const base = basename(filePath, ext);
36
35
  return join(dirname(filePath), `${base}.${hash}${ext}`);
37
36
  }
38
-
39
- // ── Tests ──────────────────────────────────────────────────────────────────
40
-
41
- test("same content → same hash", async () => {
42
- const a = await hashString("hello world");
43
- const b = await hashString("hello world");
44
- expect(a).toBe(b);
45
- });
46
-
47
- test("different content → different hash", async () => {
48
- const a = await hashString("foo");
49
- const b = await hashString("bar");
50
- expect(a).not.toBe(b);
51
- });
52
-
53
- test("hash is 8 hex chars", async () => {
54
- const h = await hashString("bractjs");
55
- expect(h).toMatch(/^[0-9a-f]{8}$/);
56
- });
@@ -0,0 +1,110 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { basename } from "node:path";
4
+ import type { BunPlugin } from "bun";
5
+
6
+ // ── Hash helpers ───────────────────────────────────────────────────────────
7
+
8
+ function hashClassName(filename: string, className: string): string {
9
+ const raw = filename + "#" + className;
10
+ return createHash("sha256").update(raw).digest("hex").slice(0, 8);
11
+ }
12
+
13
+ function scopedName(filename: string, className: string): string {
14
+ const base = basename(filename).replace(/\.module\.css$/, "").replace(/[^A-Za-z0-9_-]/g, "_");
15
+ return `${base}_${className}_${hashClassName(filename, className)}`;
16
+ }
17
+
18
+ // ── CSS class name extractor ───────────────────────────────────────────────
19
+
20
+ function extractClassNames(css: string): string[] {
21
+ const names: string[] = [];
22
+ // Match simple class selectors: .className { ... }
23
+ // Does not handle :local() or @keyframes — CSS Modules basic subset.
24
+ const re = /\.([A-Za-z_][A-Za-z0-9_-]*)\s*[{,:\s]/g;
25
+ let m: RegExpExecArray | null;
26
+ while ((m = re.exec(css)) !== null) {
27
+ if (!names.includes(m[1])) names.push(m[1]);
28
+ }
29
+ return names;
30
+ }
31
+
32
+ // ── Replacer ───────────────────────────────────────────────────────────────
33
+
34
+ function transformCss(css: string, filePath: string, map: Record<string, string>): string {
35
+ // Replace each .className with .hashedName in the CSS source.
36
+ return css.replace(/\.([A-Za-z_][A-Za-z0-9_-]*)/g, (match, name: string) => {
37
+ return map[name] ? "." + map[name] : match;
38
+ });
39
+ }
40
+
41
+ // ── Bun plugin ────────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * A Bun.build() plugin that handles `*.module.css` imports.
45
+ *
46
+ * At build time:
47
+ * 1. Reads the CSS file.
48
+ * 2. Extracts class names and hashes them: `${filename}_${className}_${hash8}`.
49
+ * 3. Returns a JS module that exports the class name mapping object.
50
+ * 4. Emits the transformed CSS as a side-effect (injected via a <link> tag at runtime,
51
+ * or via HMR <style> injection in dev).
52
+ *
53
+ * Usage in bractjs config:
54
+ * import { cssModulesPlugin } from 'bractjs/build/plugins/css-modules';
55
+ * Bun.build({ plugins: [cssModulesPlugin] })
56
+ */
57
+ export const cssModulesPlugin: BunPlugin = {
58
+ name: "bractjs-css-modules",
59
+ setup(build) {
60
+ build.onLoad({ filter: /\.module\.css$/ }, async (args) => {
61
+ const css = await readFile(args.path, "utf-8");
62
+ const classNames = extractClassNames(css);
63
+
64
+ const map: Record<string, string> = {};
65
+ for (const name of classNames) {
66
+ map[name] = scopedName(args.path, name);
67
+ }
68
+
69
+ const transformed = transformCss(css, args.path, map);
70
+
71
+ // Emit the transformed CSS as a JS-injected style block.
72
+ // In prod builds a separate CSS file is preferred; here we inline via JS
73
+ // so the plugin works without a separate CSS pipeline step.
74
+ const cssEscape = JSON.stringify(transformed);
75
+ const mapLiteral = JSON.stringify(map);
76
+
77
+ const code = `
78
+ if (typeof document !== 'undefined') {
79
+ const existing = document.getElementById(${JSON.stringify("bract-css-" + hashClassName(args.path, "__module__"))});
80
+ if (!existing) {
81
+ const style = document.createElement('style');
82
+ style.id = ${JSON.stringify("bract-css-" + hashClassName(args.path, "__module__"))};
83
+ style.textContent = ${cssEscape};
84
+ document.head.appendChild(style);
85
+ }
86
+ }
87
+ export default ${mapLiteral};
88
+ `;
89
+ return { contents: code, loader: "js" };
90
+ });
91
+ },
92
+ };
93
+
94
+ // ── Dev HMR injection (used by hmr-server) ───────────────────────────────
95
+
96
+ /**
97
+ * Transform a CSS module file and return { map, css }.
98
+ * Used by the dev server to inject styles via HMR WebSocket.
99
+ */
100
+ export async function transformCssModule(
101
+ filePath: string,
102
+ ): Promise<{ map: Record<string, string>; css: string }> {
103
+ const css = await readFile(filePath, "utf-8");
104
+ const classNames = extractClassNames(css);
105
+ const map: Record<string, string> = {};
106
+ for (const name of classNames) {
107
+ map[name] = scopedName(filePath, name);
108
+ }
109
+ return { map, css: transformCss(css, filePath, map) };
110
+ }
@@ -1,5 +1,5 @@
1
1
  import {
2
- useState, useCallback, useEffect, startTransition,
2
+ useState, useCallback, useEffect, useRef, startTransition,
3
3
  type ReactNode, type ReactElement,
4
4
  } from "react";
5
5
  import {
@@ -11,6 +11,7 @@ import {
11
11
  } from "./router.tsx";
12
12
  import type { ServerManifest } from "../server/render.ts";
13
13
  import { matchPatternForPath } from "./nav-utils.ts";
14
+ import { loaderCache, cacheKey } from "./cache.ts";
14
15
 
15
16
  // ── Types ──────────────────────────────────────────────────────────────────
16
17
 
@@ -36,6 +37,9 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
36
37
 
37
38
  const manifest = initialData.manifest;
38
39
 
40
+ // Stable ref to navigate so loadRoute can call it without a circular dep.
41
+ const navigateRef = useRef<(to: string) => Promise<void>>(null!);
42
+
39
43
  const setRoute = useCallback((state: Partial<RouteState>) => {
40
44
  if (state.loaderData !== undefined) setLoaderData(state.loaderData);
41
45
  if (state.actionData !== undefined) setActionData(state.actionData);
@@ -47,14 +51,98 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
47
51
  const loadRoute = useCallback(async (to: string) => {
48
52
  setNavState("loading");
49
53
  try {
50
- const pattern = matchPatternForPath(to, manifest);
54
+ const toPathname = to.split("?")[0];
55
+ const pattern = matchPatternForPath(toPathname, manifest);
51
56
  const chunkUrl = pattern !== null ? manifest.routes[pattern]?.chunk : undefined;
52
- const [routeModule, res] = await Promise.all([
53
- chunkUrl ? import(/* @vite-ignore */ chunkUrl) : Promise.resolve(null),
54
- fetch(`/_data?path=${encodeURIComponent(to)}`),
55
- ]);
57
+
58
+ // Load the route module first so we can run client-side beforeLoad.
59
+ const routeModule = chunkUrl
60
+ ? (await import(/* @vite-ignore */ chunkUrl) as RouteModuleClient & { beforeLoad?: unknown })
61
+ : null;
62
+
63
+ // Run client-side beforeLoad if exported from the route module.
64
+ if (routeModule && typeof routeModule.beforeLoad === "function") {
65
+ const url = new URL(to, window.location.href);
66
+ try {
67
+ const result = await (routeModule.beforeLoad as (args: {
68
+ params: Record<string, string>;
69
+ context: Record<string, unknown>;
70
+ location: { pathname: string; search: string };
71
+ }) => Promise<Response | void>)({
72
+ params: {},
73
+ context: {},
74
+ location: { pathname: url.pathname, search: url.search },
75
+ });
76
+ if (result instanceof Response) {
77
+ const loc = result.headers.get("Location");
78
+ if (loc) { void navigateRef.current(loc); return; }
79
+ }
80
+ } catch (err) {
81
+ if (err instanceof Response) {
82
+ const loc = (err as Response).headers.get("Location");
83
+ if (loc) { void navigateRef.current(loc); return; }
84
+ }
85
+ throw err;
86
+ }
87
+ }
88
+
89
+ // Include search params in the /_data path param so loaders receive them.
90
+ const toWithSearch = to.includes("?") ? to : to + window.location.search;
91
+
92
+ // ── Cache lookup (B1 / B2) ──────────────────────────────────────────
93
+ // Read config and loaderDeps from the route module if available.
94
+ const routeConfig = (routeModule as Record<string, unknown> | null)?.config as
95
+ | { staleTime?: number; gcTime?: number }
96
+ | undefined;
97
+ const staleTime = routeConfig?.staleTime ?? 0;
98
+ const gcTime = routeConfig?.gcTime ?? 300_000;
99
+
100
+ const loaderDepsFn = (routeModule as Record<string, unknown> | null)?.loaderDeps as
101
+ | ((args: { searchParams: URLSearchParams }) => unknown[])
102
+ | undefined;
103
+ const searchParams = new URLSearchParams(toWithSearch.split("?")[1] ?? "");
104
+ const deps = loaderDepsFn ? loaderDepsFn({ searchParams }) : [toWithSearch];
105
+ const key = cacheKey(toPathname, deps);
106
+
107
+ const cached = loaderCache.get(key);
108
+ if (cached?.fresh) {
109
+ // Serve from cache immediately; skip fetch.
110
+ startTransition(() => {
111
+ setLoaderData(cached.data);
112
+ setParams((cached.data.params as Record<string, string>) ?? {});
113
+ setPathname(to);
114
+ setCurrentModule(routeModule);
115
+ });
116
+ setNavState("idle");
117
+ return;
118
+ }
119
+ if (cached && !cached.fresh) {
120
+ // Stale-while-revalidate: render stale data immediately, then refresh.
121
+ startTransition(() => {
122
+ setLoaderData(cached.data);
123
+ setParams((cached.data.params as Record<string, string>) ?? {});
124
+ setPathname(to);
125
+ setCurrentModule(routeModule);
126
+ });
127
+ setNavState("idle");
128
+ // Revalidate in background.
129
+ void fetch(`/_data?path=${encodeURIComponent(toWithSearch)}`)
130
+ .then((r) => r.ok ? r.json() : null)
131
+ .then((fresh) => {
132
+ if (!fresh) return;
133
+ loaderCache.set(key, fresh as Record<string, unknown>, staleTime, gcTime);
134
+ startTransition(() => {
135
+ setLoaderData(fresh as Record<string, unknown>);
136
+ setParams(((fresh as Record<string, unknown>).params as Record<string, string>) ?? {});
137
+ });
138
+ });
139
+ return;
140
+ }
141
+
142
+ // Cache miss — fetch from server.
143
+ const res = await fetch(`/_data?path=${encodeURIComponent(toWithSearch)}`);
56
144
  // Guard: always parse JSON, but only when the server signals success.
57
- // Without r.ok check, a Bun 500 plain-text response causes
145
+ // Without res.ok check, a Bun 500 plain-text response causes
58
146
  // SyntaxError: JSON.parse: unexpected character — an unhandled rejection.
59
147
  if (!res.ok) {
60
148
  console.error(`[bractjs] /_data ${res.status} for ${to}`);
@@ -62,14 +150,30 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
62
150
  return;
63
151
  }
64
152
  const data = await res.json() as Record<string, unknown>;
153
+ if (staleTime > 0) loaderCache.set(key, data, staleTime, gcTime);
154
+
155
+ // Update DevTools state (dev-only — no-op in prod since the import fails).
156
+ const w = window as unknown as { __BRACT_DEV__?: boolean };
157
+ if (w.__BRACT_DEV__ === true) {
158
+ void import("../../dev/devtools.ts").then(({ updateDevtoolsState }) => {
159
+ updateDevtoolsState({
160
+ route: toPathname,
161
+ loaderData: data,
162
+ navState: "idle",
163
+ cacheEntries: loaderCache.entries(),
164
+ });
165
+ }).catch(() => {/* devtools not available in prod */});
166
+ }
65
167
  startTransition(() => {
66
168
  setLoaderData(data);
67
169
  setParams((data.params as Record<string, string>) ?? {});
68
170
  setPathname(to);
69
171
  setCurrentModule(routeModule);
70
172
  });
71
- if ((data.meta as { title?: string })?.title) {
72
- document.title = (data.meta as { title: string }).title;
173
+ const metaList = data.meta as Array<Record<string, unknown>> | undefined;
174
+ const titleEntry = metaList?.find((m) => "title" in m);
175
+ if (titleEntry && typeof titleEntry.title === "string") {
176
+ document.title = titleEntry.title;
73
177
  }
74
178
  } catch (err) {
75
179
  console.error("[bractjs] loadRoute error:", err);
@@ -83,9 +187,12 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
83
187
  history.pushState({}, "", to);
84
188
  }, [loadRoute]);
85
189
 
190
+ // Keep navigateRef current so loadRoute can redirect via navigate.
191
+ useEffect(() => { navigateRef.current = navigate; }, [navigate]);
192
+
86
193
  // Handle browser back / forward
87
194
  useEffect(() => {
88
- const onPopState = () => { void loadRoute(location.pathname); };
195
+ const onPopState = () => { void loadRoute(location.pathname + location.search); };
89
196
  window.addEventListener("popstate", onPopState);
90
197
  return () => window.removeEventListener("popstate", onPopState);
91
198
  }, [loadRoute]);
@@ -93,9 +200,11 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
93
200
  // Module-level HMR: swap the current route module without a full reload.
94
201
  // The injected HMR client script calls window.__BRACTJS_HMR_ACCEPT__(pattern, mod)
95
202
  // after importing the freshly-built chunk from /_hmr/module.
203
+ // Dev gate: prod builds inject __BRACT_DEV__ = false; absence in browser also
204
+ // counts as prod since we never reference `process` here.
96
205
  useEffect(() => {
97
- if (process.env.NODE_ENV === "production") return;
98
- const w = window as unknown as { __BRACTJS_HMR_ACCEPT__?: unknown };
206
+ const w = window as unknown as { __BRACT_DEV__?: boolean; __BRACTJS_HMR_ACCEPT__?: unknown };
207
+ if (w.__BRACT_DEV__ !== true) return;
99
208
  w.__BRACTJS_HMR_ACCEPT__ = (pattern: string, mod: RouteModuleClient) => {
100
209
  const current = matchPatternForPath(pathname, manifest);
101
210
  if (current === pattern) startTransition(() => setCurrentModule(mod));
@@ -103,7 +212,6 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
103
212
  return () => { delete w.__BRACTJS_HMR_ACCEPT__; };
104
213
  }, [pathname, manifest]);
105
214
 
106
- // Stub — real implementation in Prompt 2.6
107
215
  const submit = useCallback(async (
108
216
  _to: string,
109
217
  _opts: { method: string; body: FormData | Record<string, string> },
@@ -0,0 +1,69 @@
1
+ // ── LoaderCache ────────────────────────────────────────────────────────────
2
+
3
+ interface CacheEntry {
4
+ data: Record<string, unknown>;
5
+ timestamp: number;
6
+ staleTime: number;
7
+ gcTime: number;
8
+ }
9
+
10
+ class LoaderCache {
11
+ private store = new Map<string, CacheEntry>();
12
+ private gcTimer: ReturnType<typeof setInterval> | null = null;
13
+
14
+ set(key: string, data: Record<string, unknown>, staleTime: number, gcTime: number): void {
15
+ this.store.set(key, { data, timestamp: Date.now(), staleTime, gcTime });
16
+ this.ensureGc();
17
+ }
18
+
19
+ get(key: string): { data: Record<string, unknown>; fresh: boolean } | null {
20
+ const entry = this.store.get(key);
21
+ if (!entry) return null;
22
+ const age = Date.now() - entry.timestamp;
23
+ if (age > entry.gcTime) {
24
+ this.store.delete(key);
25
+ return null;
26
+ }
27
+ return { data: entry.data, fresh: age < entry.staleTime };
28
+ }
29
+
30
+ delete(key: string): void {
31
+ this.store.delete(key);
32
+ }
33
+
34
+ entries(): Array<{ key: string; age: number; staleTime: number; gcTime: number }> {
35
+ const now = Date.now();
36
+ return Array.from(this.store.entries()).map(([key, entry]) => ({
37
+ key,
38
+ age: now - entry.timestamp,
39
+ staleTime: entry.staleTime,
40
+ gcTime: entry.gcTime,
41
+ }));
42
+ }
43
+
44
+ private ensureGc(): void {
45
+ if (this.gcTimer !== null) return;
46
+ this.gcTimer = setInterval(() => {
47
+ const now = Date.now();
48
+ for (const [key, entry] of this.store) {
49
+ if (now - entry.timestamp > entry.gcTime) this.store.delete(key);
50
+ }
51
+ if (this.store.size === 0) {
52
+ clearInterval(this.gcTimer!);
53
+ this.gcTimer = null;
54
+ }
55
+ }, 60_000);
56
+ }
57
+ }
58
+
59
+ export const loaderCache = new LoaderCache();
60
+
61
+ // ── Cache key helpers ──────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Build a cache key from a route pattern and a (sorted) deps array.
65
+ * Using sorted search ensures `?a=1&b=2` and `?b=2&a=1` hit the same entry.
66
+ */
67
+ export function cacheKey(pattern: string, deps: unknown[]): string {
68
+ return pattern + "\0" + JSON.stringify(deps);
69
+ }
@@ -7,12 +7,19 @@ import { prefetchRoute } from "../prefetch.ts";
7
7
  interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
8
8
  to: string;
9
9
  prefetch?: "hover" | "none";
10
+ /** Opt in to View Transitions API for this navigation (E1). */
11
+ viewTransition?: boolean;
10
12
  children: ReactNode;
11
13
  }
12
14
 
13
15
  // ── Component ──────────────────────────────────────────────────────────────
14
16
 
15
- export function Link({ to, prefetch = "none", children, ...rest }: LinkProps) {
17
+ // Feature-detection at module evaluation so every click doesn't repeat it.
18
+ const supportsViewTransitions =
19
+ typeof document !== "undefined" &&
20
+ typeof (document as Document & { startViewTransition?: unknown }).startViewTransition === "function";
21
+
22
+ export function Link({ to, prefetch = "none", viewTransition = false, children, ...rest }: LinkProps) {
16
23
  const navCtx = useContext(NavigationContext);
17
24
  const routerCtx = useContext(RouterContext);
18
25
  const isLoading = navCtx?.state === "loading";
@@ -21,7 +28,14 @@ export function Link({ to, prefetch = "none", children, ...rest }: LinkProps) {
21
28
  if (!navCtx) return; // SSR: let browser handle naturally
22
29
  if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
23
30
  e.preventDefault();
24
- void navCtx.navigate(to);
31
+
32
+ if (viewTransition && supportsViewTransitions) {
33
+ (document as Document & { startViewTransition(cb: () => void): void }).startViewTransition(
34
+ () => { void navCtx.navigate(to); },
35
+ );
36
+ } else {
37
+ void navCtx.navigate(to);
38
+ }
25
39
  }
26
40
 
27
41
  function handleMouseEnter() {