@bractjs/bractjs 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/loader.test.ts +5 -2
- package/src/adapters/cloudflare.ts +65 -0
- package/src/build/bundler.ts +5 -3
- package/src/build/env-plugin.ts +7 -0
- package/src/build/plugins/css-modules.ts +110 -0
- package/src/client/ClientRouter.tsx +113 -9
- package/src/client/cache.ts +69 -0
- package/src/client/components/Link.tsx +16 -2
- package/src/client/components/LiveReload.tsx +4 -0
- package/src/client/hooks/useBlocker.ts +44 -0
- package/src/client/hooks/useFetcher.ts +66 -6
- package/src/client/hooks/useLocale.ts +12 -0
- package/src/client/hooks/useLocalizedLink.ts +18 -0
- package/src/client/hooks/useSearchParams.ts +74 -0
- package/src/client/rpc.ts +70 -0
- package/src/codegen/route-codegen.ts +63 -1
- package/src/dev/devtools.ts +144 -0
- package/src/dev/hmr-client.ts +14 -0
- package/src/dev/hmr-module-handler.ts +17 -1
- package/src/dev/hmr-server.ts +16 -0
- package/src/image/handler.ts +5 -2
- package/src/image/optimizer.ts +6 -1
- package/src/index.ts +27 -0
- package/src/middleware/cors.ts +4 -0
- package/src/middleware/requestLogger.ts +4 -0
- package/src/server/action-handler.ts +8 -4
- package/src/server/adapter.ts +57 -0
- package/src/server/api-route.ts +127 -0
- package/src/server/context.ts +22 -0
- package/src/server/csrf.ts +1 -0
- package/src/server/env.ts +16 -0
- package/src/server/i18n.ts +63 -0
- package/src/server/loader.ts +61 -1
- package/src/server/render.ts +7 -0
- package/src/server/request-handler.ts +66 -8
- package/src/server/serve.ts +102 -55
- package/src/server/session.ts +1 -0
- package/src/server/static.ts +8 -1
- package/src/server/stream-handler.ts +111 -0
- package/src/server/validate.ts +89 -0
- package/src/shared/route-types.ts +11 -0
- package/types/index.d.ts +94 -1
- 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.
|
|
3
|
+
"version": "0.1.8",
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|
package/src/build/bundler.ts
CHANGED
|
@@ -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";
|
|
@@ -19,8 +20,9 @@ export async function runBuild(config: BractJSConfig): Promise<void> {
|
|
|
19
20
|
const rootFilePath = join(appDir, "root.tsx");
|
|
20
21
|
|
|
21
22
|
// ── 1. Server bundle ────────────────────────────────────────────────────
|
|
23
|
+
const pkgRoot = join(import.meta.dir, "../..");
|
|
22
24
|
const serverResult = await Bun.build({
|
|
23
|
-
entrypoints: ["src/server/index.ts"],
|
|
25
|
+
entrypoints: [join(pkgRoot, "src/server/index.ts")],
|
|
24
26
|
target: "bun",
|
|
25
27
|
outdir: "build/server",
|
|
26
28
|
sourcemap: config.sourcemap ?? "external",
|
|
@@ -30,7 +32,7 @@ export async function runBuild(config: BractJSConfig): Promise<void> {
|
|
|
30
32
|
|
|
31
33
|
// ── 2. Client bundle (code-split) ───────────────────────────────────────
|
|
32
34
|
const clientResult = await Bun.build({
|
|
33
|
-
entrypoints: ["src/client/entry.tsx", rootFilePath, ...routeFilePaths],
|
|
35
|
+
entrypoints: [join(pkgRoot, "src/client/entry.tsx"), rootFilePath, ...routeFilePaths],
|
|
34
36
|
target: "browser",
|
|
35
37
|
splitting: true,
|
|
36
38
|
outdir: "build/client",
|
|
@@ -39,7 +41,7 @@ export async function runBuild(config: BractJSConfig): Promise<void> {
|
|
|
39
41
|
minify: config.minify ?? true,
|
|
40
42
|
sourcemap: config.sourcemap ?? "external",
|
|
41
43
|
define: buildDefines(config),
|
|
42
|
-
plugins: [serverOnlyPlugin, useServerProxyPlugin, clientEnvPlugin(config.clientEnv ?? [], Bun.env as Record<string, string>)],
|
|
44
|
+
plugins: [serverOnlyPlugin, useServerProxyPlugin, clientEnvPlugin(config.clientEnv ?? [], Bun.env as Record<string, string>), cssModulesPlugin],
|
|
43
45
|
});
|
|
44
46
|
if (!clientResult.success) throw new AggregateError(clientResult.logs, "Client build failed");
|
|
45
47
|
|
package/src/build/env-plugin.ts
CHANGED
|
@@ -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
|
|
54
|
+
const toPathname = to.split("?")[0];
|
|
55
|
+
const pattern = matchPatternForPath(toPathname, manifest);
|
|
51
56
|
const chunkUrl = pattern !== null ? manifest.routes[pattern]?.chunk : undefined;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|