@bractjs/bractjs 0.1.6 → 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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/loader.test.ts +5 -2
  3. package/src/adapters/cloudflare.ts +65 -0
  4. package/src/build/bundler.ts +2 -1
  5. package/src/build/env-plugin.ts +7 -0
  6. package/src/build/plugins/css-modules.ts +110 -0
  7. package/src/client/ClientRouter.tsx +113 -9
  8. package/src/client/cache.ts +69 -0
  9. package/src/client/components/Link.tsx +16 -2
  10. package/src/client/components/LiveReload.tsx +4 -0
  11. package/src/client/hooks/useBlocker.ts +44 -0
  12. package/src/client/hooks/useFetcher.ts +66 -6
  13. package/src/client/hooks/useLocale.ts +12 -0
  14. package/src/client/hooks/useLocalizedLink.ts +18 -0
  15. package/src/client/hooks/useSearchParams.ts +74 -0
  16. package/src/client/rpc.ts +70 -0
  17. package/src/codegen/route-codegen.ts +63 -1
  18. package/src/dev/devtools.ts +144 -0
  19. package/src/dev/hmr-client.ts +14 -0
  20. package/src/dev/hmr-module-handler.ts +17 -1
  21. package/src/dev/hmr-server.ts +16 -0
  22. package/src/image/handler.ts +5 -2
  23. package/src/image/optimizer.ts +6 -1
  24. package/src/index.ts +27 -0
  25. package/src/middleware/cors.ts +4 -0
  26. package/src/middleware/requestLogger.ts +4 -0
  27. package/src/server/action-handler.ts +8 -4
  28. package/src/server/adapter.ts +57 -0
  29. package/src/server/api-route.ts +127 -0
  30. package/src/server/context.ts +22 -0
  31. package/src/server/csrf.ts +1 -0
  32. package/src/server/env.ts +16 -0
  33. package/src/server/i18n.ts +63 -0
  34. package/src/server/loader.ts +61 -1
  35. package/src/server/render.ts +7 -0
  36. package/src/server/request-handler.ts +66 -8
  37. package/src/server/serve.ts +102 -55
  38. package/src/server/session.ts +1 -0
  39. package/src/server/static.ts +8 -1
  40. package/src/server/stream-handler.ts +111 -0
  41. package/src/server/validate.ts +89 -0
  42. package/src/shared/route-types.ts +11 -0
  43. package/types/index.d.ts +94 -1
  44. package/types/route.d.ts +11 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bractjs/bractjs",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Production-grade SSR framework for Bun + React 19. File-based routing, streaming SSR, server actions, typed routes.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/bractjs/bractjs#readme",
@@ -26,7 +26,10 @@ describe("safeRun", () => {
26
26
 
27
27
  test("wraps non-redirect errors in __error", async () => {
28
28
  const result = await safeRun(async () => { throw new Error("boom"); }, stubArgs);
29
- expect(result).toMatchObject({ __error: expect.any(Error) });
29
+ // safeRun now returns a sanitized __error object ({ message } in prod,
30
+ // { message, stack } in dev) rather than the raw Error instance, to
31
+ // prevent error-subclass fields from leaking into the SSR HTML payload.
32
+ expect(result).toMatchObject({ __error: { message: expect.any(String) } });
30
33
  });
31
34
 
32
35
  test("re-throws HttpError (does not wrap)", async () => {
@@ -71,7 +74,7 @@ describe("runLoaders", () => {
71
74
  route: { ...emptyModule, loader: async () => ({ ok: true }) },
72
75
  };
73
76
  const results = await runLoaders(chain, stubArgs);
74
- expect(results.root).toMatchObject({ __error: expect.any(Error) });
77
+ expect(results.root).toMatchObject({ __error: { message: expect.any(String) } });
75
78
  expect(results.route).toEqual({ ok: true });
76
79
  });
77
80
  });
@@ -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
+ }
@@ -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
 
@@ -43,6 +43,13 @@ export function clientEnvPlugin(
43
43
  build.onLoad({ filter: /\.(ts|tsx|js|jsx)$/ }, async (args) => {
44
44
  if (args.path.includes("/node_modules/")) return undefined;
45
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.
46
53
  const contents = src.replace(
47
54
  /process\.env\.([A-Z_][A-Z0-9_]*)/g,
48
55
  (_match, key: string) =>
@@ -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,6 +150,20 @@ 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>) ?? {});
@@ -85,9 +187,12 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
85
187
  history.pushState({}, "", to);
86
188
  }, [loadRoute]);
87
189
 
190
+ // Keep navigateRef current so loadRoute can redirect via navigate.
191
+ useEffect(() => { navigateRef.current = navigate; }, [navigate]);
192
+
88
193
  // Handle browser back / forward
89
194
  useEffect(() => {
90
- const onPopState = () => { void loadRoute(location.pathname); };
195
+ const onPopState = () => { void loadRoute(location.pathname + location.search); };
91
196
  window.addEventListener("popstate", onPopState);
92
197
  return () => window.removeEventListener("popstate", onPopState);
93
198
  }, [loadRoute]);
@@ -107,7 +212,6 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
107
212
  return () => { delete w.__BRACTJS_HMR_ACCEPT__; };
108
213
  }, [pathname, manifest]);
109
214
 
110
- // Stub — real implementation in Prompt 2.6
111
215
  const submit = useCallback(async (
112
216
  _to: string,
113
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() {
@@ -8,6 +8,10 @@ import { hmrClientScript } from "../../dev/hmr-client.ts";
8
8
  export function LiveReload(): ReactElement | null {
9
9
  if (process.env.NODE_ENV === "production") return null;
10
10
 
11
+ // SECURITY(low): dangerouslySetInnerHTML is safe here — hmrClientScript is a
12
+ // build-time constant string with no user input. The NODE_ENV gate above
13
+ // ensures this is never rendered in production. If hmrClientScript ever
14
+ // accepts dynamic content, audit for XSS.
11
15
  return (
12
16
  <script
13
17
  dangerouslySetInnerHTML={{ __html: hmrClientScript }}
@@ -0,0 +1,44 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ /**
4
+ * Intercepts browser back/forward and <Link> clicks when `shouldBlock()` returns true.
5
+ * Shows a native confirm() dialog; the user must confirm to continue navigating.
6
+ *
7
+ * Note: The Link component calls NavigationContext.navigate(), which bypasses this
8
+ * hook's popstate listener. The hook also patches window.history.pushState so
9
+ * programmatic navigation (including <Link>) is also intercepted.
10
+ */
11
+ export function useBlocker(shouldBlock: () => boolean): void {
12
+ // Keep a stable ref so listeners always call the latest version.
13
+ const shouldBlockRef = useRef(shouldBlock);
14
+ useEffect(() => { shouldBlockRef.current = shouldBlock; });
15
+
16
+ // Intercept popstate (browser back/forward).
17
+ useEffect(() => {
18
+ function onPopState(e: PopStateEvent) {
19
+ if (!shouldBlockRef.current()) return;
20
+ // The browser already moved back — push the user back to the current
21
+ // page before asking, then confirm.
22
+ e.preventDefault();
23
+ if (!window.confirm("Leave page? Changes you made may not be saved.")) {
24
+ // Re-push the current URL so the address bar doesn't change.
25
+ history.pushState(null, "", window.location.href);
26
+ }
27
+ }
28
+ window.addEventListener("popstate", onPopState);
29
+ return () => window.removeEventListener("popstate", onPopState);
30
+ }, []);
31
+
32
+ // Patch history.pushState so <Link> navigations (which call pushState) are
33
+ // intercepted. Restore on cleanup.
34
+ useEffect(() => {
35
+ const original = history.pushState.bind(history);
36
+ history.pushState = (state: unknown, title: string, url?: string | URL | null) => {
37
+ if (shouldBlockRef.current()) {
38
+ if (!window.confirm("Leave page? Changes you made may not be saved.")) return;
39
+ }
40
+ original(state, title, url);
41
+ };
42
+ return () => { history.pushState = original; };
43
+ }, []);
44
+ }
@@ -16,10 +16,70 @@ interface FetcherResult {
16
16
  submit(path: string, opts: SubmitOptions): Promise<void>;
17
17
  }
18
18
 
19
+ interface StreamFetcherResult<T = unknown> {
20
+ events: AsyncGenerator<T>;
21
+ connect(actionId: string): AsyncGenerator<T>;
22
+ }
23
+
24
+ // ── SSE async generator ────────────────────────────────────────────────────
25
+
26
+ async function* sseStream<T>(actionId: string): AsyncGenerator<T> {
27
+ // Send X-BractJS-Action so the server's CSRF gate accepts this same-origin
28
+ // GET. Cross-origin <script>/<img>/<link rel=prefetch> tags cannot set this
29
+ // header, so the gate blocks CSRF invocations of server actions.
30
+ const res = await fetch(`/_stream?id=${encodeURIComponent(actionId)}`, {
31
+ headers: { "X-BractJS-Action": "1" },
32
+ });
33
+ if (!res.ok || !res.body) {
34
+ throw new Error(`[bractjs] /_stream ${res.status}`);
35
+ }
36
+
37
+ const reader = res.body.getReader();
38
+ const decoder = new TextDecoder();
39
+ let buf = "";
40
+
41
+ try {
42
+ while (true) {
43
+ const { done, value } = await reader.read();
44
+ if (done) break;
45
+ buf += decoder.decode(value, { stream: true });
46
+ const parts = buf.split("\n\n");
47
+ buf = parts.pop() ?? "";
48
+ for (const part of parts) {
49
+ const lines = part.trim().split("\n");
50
+ let event = "data";
51
+ let data = "";
52
+ for (const line of lines) {
53
+ if (line.startsWith("event: ")) event = line.slice(7).trim();
54
+ else if (line.startsWith("data: ")) data = line.slice(6);
55
+ }
56
+ if (event === "done") return;
57
+ if (event === "error") throw new Error((JSON.parse(data) as { message: string }).message);
58
+ if (event === "data" && data) yield JSON.parse(data) as T;
59
+ }
60
+ }
61
+ } finally {
62
+ reader.releaseLock();
63
+ }
64
+ }
65
+
19
66
  // ── Hook ───────────────────────────────────────────────────────────────────
20
67
 
21
- export function useFetcher(): FetcherResult {
68
+ export function useFetcher(): FetcherResult;
69
+ export function useFetcher<T>(opts: { stream: true }): StreamFetcherResult<T>;
70
+ export function useFetcher<T>(opts?: { stream?: boolean }): FetcherResult | StreamFetcherResult<T> {
71
+ if (opts?.stream) {
72
+ return {
73
+ events: (null as unknown) as AsyncGenerator<T>,
74
+ connect(actionId: string): AsyncGenerator<T> {
75
+ return sseStream<T>(actionId);
76
+ },
77
+ } satisfies StreamFetcherResult<T>;
78
+ }
79
+
80
+ // eslint-disable-next-line react-hooks/rules-of-hooks
22
81
  const [data, setData] = useState<unknown>(undefined);
82
+ // eslint-disable-next-line react-hooks/rules-of-hooks
23
83
  const [state, setState] = useState<FetcherState>("idle");
24
84
 
25
85
  async function load(path: string): Promise<void> {
@@ -33,14 +93,14 @@ export function useFetcher(): FetcherResult {
33
93
  }
34
94
  }
35
95
 
36
- async function submit(path: string, opts: SubmitOptions): Promise<void> {
96
+ async function submit(path: string, submitOpts: SubmitOptions): Promise<void> {
37
97
  setState("submitting");
38
98
  try {
39
99
  const body =
40
- opts.body instanceof FormData
41
- ? opts.body
42
- : new URLSearchParams(opts.body as Record<string, string>);
43
- const res = await fetch(path, { method: opts.method, body });
100
+ submitOpts.body instanceof FormData
101
+ ? submitOpts.body
102
+ : new URLSearchParams(submitOpts.body as Record<string, string>);
103
+ const res = await fetch(path, { method: submitOpts.method, body });
44
104
  setData(await res.json());
45
105
  } finally {
46
106
  setState("idle");