@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.
- package/package.json +1 -1
- package/src/__tests__/action-handler.test.ts +47 -0
- package/src/__tests__/action-registry.test.ts +73 -0
- package/src/__tests__/codegen.test.ts +50 -0
- package/src/__tests__/deferred.test.ts +96 -0
- package/src/__tests__/directives.test.ts +52 -0
- package/src/__tests__/env.test.ts +73 -0
- package/src/__tests__/errors.test.ts +113 -0
- package/src/__tests__/hash.test.ts +19 -0
- package/src/__tests__/integration.test.ts +1 -1
- package/src/__tests__/loader.test.ts +5 -2
- package/src/__tests__/manifest.test.ts +60 -0
- package/src/__tests__/middleware.test.ts +216 -0
- package/src/__tests__/response.test.ts +106 -0
- package/src/__tests__/security.test.ts +348 -0
- package/src/__tests__/session.test.ts +3 -3
- package/src/adapters/cloudflare.ts +65 -0
- package/src/build/bundler.ts +17 -6
- package/src/build/directives.ts +30 -3
- package/src/build/env-plugin.ts +8 -0
- package/src/build/hash.ts +0 -20
- package/src/build/plugins/css-modules.ts +110 -0
- package/src/client/ClientRouter.tsx +121 -13
- 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 +96 -10
- package/src/dev/devtools.ts +144 -0
- package/src/dev/hmr-client.ts +14 -0
- package/src/dev/hmr-module-handler.ts +31 -5
- package/src/dev/hmr-server.ts +16 -0
- package/src/image/cache.ts +28 -8
- package/src/image/handler.ts +31 -13
- package/src/image/optimizer.ts +51 -14
- package/src/image/types.ts +1 -0
- package/src/index.ts +27 -0
- package/src/middleware/cors.ts +28 -8
- package/src/middleware/requestLogger.ts +4 -0
- package/src/server/action-handler.ts +45 -2
- package/src/server/action-registry.ts +14 -1
- 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 +17 -0
- package/src/server/env.ts +26 -4
- package/src/server/i18n.ts +63 -0
- package/src/server/loader.ts +61 -1
- package/src/server/middleware.ts +11 -7
- package/src/server/render.ts +14 -5
- package/src/server/request-handler.ts +77 -18
- package/src/server/response.ts +29 -5
- package/src/server/scanner.ts +6 -2
- package/src/server/serve.ts +102 -55
- package/src/server/session.ts +17 -5
- package/src/server/static.ts +31 -8
- 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
|
@@ -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
|
+
}
|
package/src/build/bundler.ts
CHANGED
|
@@ -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
|
-
|
|
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" &&
|
|
71
|
+
} else if (artifact.kind === "entry-point" && outBase === rootBase) {
|
|
63
72
|
rootChunk = publicPath;
|
|
64
73
|
} else {
|
|
65
|
-
|
|
66
|
-
|
|
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
|
}
|
package/src/build/directives.ts
CHANGED
|
@@ -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 (!
|
|
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 (!
|
|
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(
|
package/src/build/env-plugin.ts
CHANGED
|
@@ -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
|
|
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,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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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() {
|