@bractjs/bractjs 0.1.0
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/LICENSE +21 -0
- package/README.md +586 -0
- package/bin/cli.ts +101 -0
- package/package.json +58 -0
- package/src/__tests__/fixtures/app/root.tsx +9 -0
- package/src/__tests__/fixtures/app/routes/_index.tsx +20 -0
- package/src/__tests__/integration.test.ts +66 -0
- package/src/__tests__/loader.test.ts +89 -0
- package/src/__tests__/matcher.test.ts +69 -0
- package/src/__tests__/meta.test.ts +81 -0
- package/src/__tests__/scanner.test.ts +58 -0
- package/src/__tests__/session.test.ts +103 -0
- package/src/build/bundler.ts +75 -0
- package/src/build/defines.ts +16 -0
- package/src/build/directives.ts +67 -0
- package/src/build/env-plugin.ts +56 -0
- package/src/build/hash.ts +56 -0
- package/src/build/manifest.ts +60 -0
- package/src/client/ClientRouter.tsx +122 -0
- package/src/client/components/Await.tsx +26 -0
- package/src/client/components/Form.tsx +67 -0
- package/src/client/components/Image.tsx +79 -0
- package/src/client/components/Link.tsx +42 -0
- package/src/client/components/LiveReload.tsx +16 -0
- package/src/client/components/Outlet.tsx +64 -0
- package/src/client/components/Scripts.tsx +12 -0
- package/src/client/entry.tsx +49 -0
- package/src/client/form-utils.ts +12 -0
- package/src/client/hooks/useActionData.ts +14 -0
- package/src/client/hooks/useFetcher.ts +51 -0
- package/src/client/hooks/useLoaderData.ts +14 -0
- package/src/client/hooks/useNavigation.ts +12 -0
- package/src/client/hooks/useParams.ts +14 -0
- package/src/client/nav-utils.ts +35 -0
- package/src/client/prefetch.ts +32 -0
- package/src/client/route-cache.ts +20 -0
- package/src/client/router.tsx +54 -0
- package/src/client/types.ts +23 -0
- package/src/codegen/route-codegen.ts +99 -0
- package/src/dev/error-overlay.ts +33 -0
- package/src/dev/hmr-client.ts +43 -0
- package/src/dev/hmr-module-handler.ts +47 -0
- package/src/dev/hmr-server.ts +51 -0
- package/src/dev/rebuilder.ts +95 -0
- package/src/dev/server.ts +38 -0
- package/src/dev/watcher.ts +32 -0
- package/src/image/cache.ts +75 -0
- package/src/image/handler.ts +82 -0
- package/src/image/optimizer.ts +76 -0
- package/src/image/types.ts +27 -0
- package/src/index.ts +51 -0
- package/src/middleware/authGuard.ts +37 -0
- package/src/middleware/cors.ts +36 -0
- package/src/middleware/requestLogger.ts +15 -0
- package/src/server/action-handler.ts +35 -0
- package/src/server/action-registry.ts +41 -0
- package/src/server/env.ts +29 -0
- package/src/server/index.ts +8 -0
- package/src/server/layout.ts +92 -0
- package/src/server/loader.ts +80 -0
- package/src/server/matcher.ts +99 -0
- package/src/server/meta.ts +92 -0
- package/src/server/middleware.ts +47 -0
- package/src/server/render.ts +65 -0
- package/src/server/request-handler.ts +142 -0
- package/src/server/response.ts +17 -0
- package/src/server/scanner.ts +68 -0
- package/src/server/serve.ts +131 -0
- package/src/server/session.ts +111 -0
- package/src/server/static.ts +40 -0
- package/src/shared/context.ts +37 -0
- package/src/shared/deferred.ts +55 -0
- package/src/shared/error-boundary.tsx +58 -0
- package/src/shared/errors.ts +49 -0
- package/src/shared/route-types.ts +51 -0
- package/templates/new-app/app/root.tsx +20 -0
- package/templates/new-app/app/routes/_index.tsx +31 -0
- package/templates/new-app/app/routes/about.tsx +21 -0
- package/templates/new-app/bractjs.config.ts +14 -0
- package/templates/new-app/package.json +20 -0
- package/types/config.d.ts +25 -0
- package/types/index.d.ts +109 -0
- package/types/middleware.d.ts +19 -0
- package/types/route.d.ts +41 -0
- package/types/session.d.ts +32 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { BunPlugin } from "bun";
|
|
2
|
+
|
|
3
|
+
const CLIENT_RE = /^["']use client["']/m;
|
|
4
|
+
const SERVER_RE = /^["']use server["']/m;
|
|
5
|
+
|
|
6
|
+
function extractExports(src: string): string[] {
|
|
7
|
+
const names: string[] = [];
|
|
8
|
+
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]);
|
|
10
|
+
return names;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function actionId(filePath: string, name: string): Promise<string> {
|
|
14
|
+
const raw = new TextEncoder().encode(filePath + "#" + name);
|
|
15
|
+
const buf = await crypto.subtle.digest("SHA-256", raw);
|
|
16
|
+
return Array.from(new Uint8Array(buf))
|
|
17
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
18
|
+
.join("")
|
|
19
|
+
.slice(0, 16);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Server build: stub "use client" modules → null components to prevent browser API crashes. */
|
|
23
|
+
export const useClientStubPlugin: BunPlugin = {
|
|
24
|
+
name: "bractjs:use-client-stub",
|
|
25
|
+
setup(build) {
|
|
26
|
+
build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async ({ path }) => {
|
|
27
|
+
const src = await Bun.file(path).text();
|
|
28
|
+
if (!CLIENT_RE.test(src)) return undefined;
|
|
29
|
+
const stubs = extractExports(src).map((n) => `export const ${n} = () => null;`).join("\n");
|
|
30
|
+
return { contents: stubs || "export {};", loader: "ts" };
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Async fetch helper inlined into every generated "use server" proxy module.
|
|
36
|
+
const PROXY_HELPER = `async function __bract(id: string, args: unknown[]): Promise<unknown> {
|
|
37
|
+
const isForm = args.length === 1 && args[0] instanceof FormData;
|
|
38
|
+
const r = await fetch("/_action?id=" + encodeURIComponent(id), {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: isForm
|
|
41
|
+
? { "X-BractJS-Action": "1" }
|
|
42
|
+
: { "Content-Type": "application/json", "X-BractJS-Action": "1" },
|
|
43
|
+
body: isForm ? (args[0] as FormData) : JSON.stringify(args),
|
|
44
|
+
});
|
|
45
|
+
if (!r.ok) throw new Error("[bractjs] action " + id + " failed: " + r.status);
|
|
46
|
+
return r.json() as Promise<unknown>;
|
|
47
|
+
}`;
|
|
48
|
+
|
|
49
|
+
/** Client build: replace "use server" exports with fetch proxy stubs. */
|
|
50
|
+
export const useServerProxyPlugin: BunPlugin = {
|
|
51
|
+
name: "bractjs:use-server-proxy",
|
|
52
|
+
setup(build) {
|
|
53
|
+
build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async ({ path }) => {
|
|
54
|
+
const src = await Bun.file(path).text();
|
|
55
|
+
if (!SERVER_RE.test(src)) return undefined;
|
|
56
|
+
const names = extractExports(src);
|
|
57
|
+
if (names.length === 0) return { contents: "export {};", loader: "ts" };
|
|
58
|
+
const proxies = await Promise.all(
|
|
59
|
+
names.map(async (name) => {
|
|
60
|
+
const id = await actionId(path, name);
|
|
61
|
+
return `export const ${name} = (...args: unknown[]) => __bract("${id}", args);`;
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
return { contents: PROXY_HELPER + "\n" + proxies.join("\n"), loader: "ts" };
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { BunPlugin } from "bun";
|
|
2
|
+
|
|
3
|
+
// ── Server-only import guard ───────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Blocks any import matching *.server.ts / *.server.tsx during client builds.
|
|
7
|
+
* Uses a two-step plugin: onResolve redirects to a virtual namespace,
|
|
8
|
+
* then onLoad throws a hard build error.
|
|
9
|
+
*/
|
|
10
|
+
export const serverOnlyPlugin: BunPlugin = {
|
|
11
|
+
name: "bractjs-server-only",
|
|
12
|
+
setup(build) {
|
|
13
|
+
build.onResolve({ filter: /\.server(\.(tsx?|jsx?))?$/ }, (args) => ({
|
|
14
|
+
path: args.path,
|
|
15
|
+
namespace: "bractjs-server-only",
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
build.onLoad(
|
|
19
|
+
{ filter: /.*/, namespace: "bractjs-server-only" },
|
|
20
|
+
(args) => {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`[BractJS] Cannot import "${args.path}" in client code.\n` +
|
|
23
|
+
`Move this to a loader() or action().`,
|
|
24
|
+
);
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ── Client env allowlist ───────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Replaces process.env.KEY with string literals for allowed keys.
|
|
34
|
+
* All other process.env.* references become the string "undefined".
|
|
35
|
+
*/
|
|
36
|
+
export function clientEnvPlugin(
|
|
37
|
+
allowedKeys: string[],
|
|
38
|
+
envValues: Record<string, string>,
|
|
39
|
+
): BunPlugin {
|
|
40
|
+
return {
|
|
41
|
+
name: "bractjs-client-env",
|
|
42
|
+
setup(build) {
|
|
43
|
+
build.onLoad({ filter: /\.(ts|tsx|js|jsx)$/ }, async (args) => {
|
|
44
|
+
const src = await Bun.file(args.path).text();
|
|
45
|
+
const contents = src.replace(
|
|
46
|
+
/process\.env\.([A-Z_][A-Z0-9_]*)/g,
|
|
47
|
+
(_match, key: string) =>
|
|
48
|
+
allowedKeys.includes(key)
|
|
49
|
+
? JSON.stringify(envValues[key] ?? "")
|
|
50
|
+
: '"undefined"',
|
|
51
|
+
);
|
|
52
|
+
return { contents, loader: args.loader };
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { extname, basename, dirname, join } from "node:path";
|
|
2
|
+
import { test, expect } from "bun:test";
|
|
3
|
+
|
|
4
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
async function digestToHex(buffer: ArrayBuffer): Promise<string> {
|
|
7
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
|
|
8
|
+
return Array.from(new Uint8Array(hashBuffer))
|
|
9
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
10
|
+
.join("");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** SHA-256 of file contents → first 8 hex chars. */
|
|
16
|
+
export async function contentHash(filePath: string): Promise<string> {
|
|
17
|
+
const buffer = await Bun.file(filePath).arrayBuffer();
|
|
18
|
+
return (await digestToHex(buffer)).slice(0, 8);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** SHA-256 of a string → first 8 hex chars. */
|
|
22
|
+
export async function hashString(content: string): Promise<string> {
|
|
23
|
+
const buffer = new TextEncoder().encode(content).buffer as ArrayBuffer;
|
|
24
|
+
return (await digestToHex(buffer)).slice(0, 8);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Inserts the content hash before the file extension.
|
|
29
|
+
* Example: client.js → client.abc12345.js
|
|
30
|
+
* Returns the new path (does NOT rename on disk).
|
|
31
|
+
*/
|
|
32
|
+
export async function renameWithHash(filePath: string): Promise<string> {
|
|
33
|
+
const hash = await contentHash(filePath);
|
|
34
|
+
const ext = extname(filePath);
|
|
35
|
+
const base = basename(filePath, ext);
|
|
36
|
+
return join(dirname(filePath), `${base}.${hash}${ext}`);
|
|
37
|
+
}
|
|
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,60 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export interface RouteManifestEntry {
|
|
6
|
+
chunk: string;
|
|
7
|
+
pattern: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RouteManifest {
|
|
11
|
+
version: 1;
|
|
12
|
+
clientEntry: string;
|
|
13
|
+
rootChunk?: string;
|
|
14
|
+
routes: Record<string, RouteManifestEntry>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Module-scope cache ─────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
let cached: RouteManifest | null = null;
|
|
20
|
+
|
|
21
|
+
// ── Functions ──────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a RouteManifest from hashed output paths.
|
|
25
|
+
* @param opts.clientEntry Hashed path to main client bundle
|
|
26
|
+
* @param opts.routeChunks Map<urlPattern, hashedChunkPath>
|
|
27
|
+
*/
|
|
28
|
+
export function generateManifest(opts: {
|
|
29
|
+
clientEntry: string;
|
|
30
|
+
rootChunk?: string;
|
|
31
|
+
routeChunks: Map<string, string>;
|
|
32
|
+
}): RouteManifest {
|
|
33
|
+
const routes: Record<string, RouteManifestEntry> = {};
|
|
34
|
+
for (const [pattern, chunk] of opts.routeChunks) {
|
|
35
|
+
routes[pattern] = { chunk, pattern };
|
|
36
|
+
}
|
|
37
|
+
return { version: 1, clientEntry: opts.clientEntry, rootChunk: opts.rootChunk, routes };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Write manifest to {outDir}/route-manifest.json (pretty-printed).
|
|
42
|
+
*/
|
|
43
|
+
export async function writeManifest(
|
|
44
|
+
manifest: RouteManifest,
|
|
45
|
+
outDir: string,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
const dest = join(outDir, "route-manifest.json");
|
|
48
|
+
await Bun.write(dest, JSON.stringify(manifest, null, 2));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Load and cache the manifest from disk.
|
|
53
|
+
* Call this at production server startup.
|
|
54
|
+
*/
|
|
55
|
+
export async function loadManifest(buildDir: string): Promise<RouteManifest> {
|
|
56
|
+
if (cached) return cached;
|
|
57
|
+
const src = join(buildDir, "route-manifest.json");
|
|
58
|
+
cached = (await Bun.file(src).json()) as RouteManifest;
|
|
59
|
+
return cached;
|
|
60
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useState, useCallback, useEffect, startTransition,
|
|
3
|
+
type ReactNode, type ReactElement,
|
|
4
|
+
} from "react";
|
|
5
|
+
import {
|
|
6
|
+
RouterContext,
|
|
7
|
+
NavigationContext,
|
|
8
|
+
type RouteState,
|
|
9
|
+
type NavigationState,
|
|
10
|
+
type RouteModuleClient,
|
|
11
|
+
} from "./router.tsx";
|
|
12
|
+
import type { ServerManifest } from "../server/render.ts";
|
|
13
|
+
import { matchPatternForPath } from "./nav-utils.ts";
|
|
14
|
+
|
|
15
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface BractJSInitialData extends RouteState {
|
|
18
|
+
manifest: ServerManifest;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ClientRouterProps {
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
initialData: BractJSInitialData;
|
|
24
|
+
initialModule?: RouteModuleClient | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── Component ──────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export function ClientRouter({ children, initialData, initialModule = null }: ClientRouterProps): ReactElement {
|
|
30
|
+
const [loaderData, setLoaderData] = useState(initialData.loaderData);
|
|
31
|
+
const [actionData, setActionData] = useState<unknown>(initialData.actionData);
|
|
32
|
+
const [params, setParams] = useState(initialData.params);
|
|
33
|
+
const [pathname, setPathname] = useState(initialData.pathname);
|
|
34
|
+
const [navState, setNavState] = useState<NavigationState>("idle");
|
|
35
|
+
const [currentModule, setCurrentModule] = useState<RouteModuleClient | null>(initialModule);
|
|
36
|
+
|
|
37
|
+
const manifest = initialData.manifest;
|
|
38
|
+
|
|
39
|
+
const setRoute = useCallback((state: Partial<RouteState>) => {
|
|
40
|
+
if (state.loaderData !== undefined) setLoaderData(state.loaderData);
|
|
41
|
+
if (state.actionData !== undefined) setActionData(state.actionData);
|
|
42
|
+
if (state.params !== undefined) setParams(state.params);
|
|
43
|
+
if (state.pathname !== undefined) setPathname(state.pathname);
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
/** Load route data + module without touching history. */
|
|
47
|
+
const loadRoute = useCallback(async (to: string) => {
|
|
48
|
+
setNavState("loading");
|
|
49
|
+
try {
|
|
50
|
+
const pattern = matchPatternForPath(to, manifest);
|
|
51
|
+
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
|
+
]);
|
|
56
|
+
// Guard: always parse JSON, but only when the server signals success.
|
|
57
|
+
// Without r.ok check, a Bun 500 plain-text response causes
|
|
58
|
+
// SyntaxError: JSON.parse: unexpected character — an unhandled rejection.
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
console.error(`[bractjs] /_data ${res.status} for ${to}`);
|
|
61
|
+
setNavState("idle");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const data = await res.json() as Record<string, unknown>;
|
|
65
|
+
startTransition(() => {
|
|
66
|
+
setLoaderData(data);
|
|
67
|
+
setParams((data.params as Record<string, string>) ?? {});
|
|
68
|
+
setPathname(to);
|
|
69
|
+
setCurrentModule(routeModule);
|
|
70
|
+
});
|
|
71
|
+
if ((data.meta as { title?: string })?.title) {
|
|
72
|
+
document.title = (data.meta as { title: string }).title;
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error("[bractjs] loadRoute error:", err);
|
|
76
|
+
} finally {
|
|
77
|
+
setNavState("idle");
|
|
78
|
+
}
|
|
79
|
+
}, [manifest]);
|
|
80
|
+
|
|
81
|
+
const navigate = useCallback(async (to: string) => {
|
|
82
|
+
await loadRoute(to);
|
|
83
|
+
history.pushState({}, "", to);
|
|
84
|
+
}, [loadRoute]);
|
|
85
|
+
|
|
86
|
+
// Handle browser back / forward
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const onPopState = () => { void loadRoute(location.pathname); };
|
|
89
|
+
window.addEventListener("popstate", onPopState);
|
|
90
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
91
|
+
}, [loadRoute]);
|
|
92
|
+
|
|
93
|
+
// Module-level HMR: swap the current route module without a full reload.
|
|
94
|
+
// The injected HMR client script calls window.__BRACTJS_HMR_ACCEPT__(pattern, mod)
|
|
95
|
+
// after importing the freshly-built chunk from /_hmr/module.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (process.env.NODE_ENV === "production") return;
|
|
98
|
+
const w = window as unknown as { __BRACTJS_HMR_ACCEPT__?: unknown };
|
|
99
|
+
w.__BRACTJS_HMR_ACCEPT__ = (pattern: string, mod: RouteModuleClient) => {
|
|
100
|
+
const current = matchPatternForPath(pathname, manifest);
|
|
101
|
+
if (current === pattern) startTransition(() => setCurrentModule(mod));
|
|
102
|
+
};
|
|
103
|
+
return () => { delete w.__BRACTJS_HMR_ACCEPT__; };
|
|
104
|
+
}, [pathname, manifest]);
|
|
105
|
+
|
|
106
|
+
// Stub — real implementation in Prompt 2.6
|
|
107
|
+
const submit = useCallback(async (
|
|
108
|
+
_to: string,
|
|
109
|
+
_opts: { method: string; body: FormData | Record<string, string> },
|
|
110
|
+
) => {
|
|
111
|
+
setNavState("submitting");
|
|
112
|
+
setNavState("idle");
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<RouterContext.Provider value={{ loaderData, actionData, params, pathname, manifest, currentModule, setRoute }}>
|
|
117
|
+
<NavigationContext.Provider value={{ state: navState, navigate, submit }}>
|
|
118
|
+
{children}
|
|
119
|
+
</NavigationContext.Provider>
|
|
120
|
+
</RouterContext.Provider>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Suspense, use, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
interface AwaitProps<T> {
|
|
4
|
+
resolve: Promise<T>;
|
|
5
|
+
fallback: ReactNode;
|
|
6
|
+
children: (data: T) => ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Unwraps a promise using React 19's `use()` API.
|
|
11
|
+
* The nearest <Suspense> boundary (provided by <Await> itself) handles the
|
|
12
|
+
* pending state by rendering `fallback`. On resolve, `children` is called
|
|
13
|
+
* with the resolved value.
|
|
14
|
+
*/
|
|
15
|
+
function Resolved<T>({ resolve, children }: Pick<AwaitProps<T>, "resolve" | "children">) {
|
|
16
|
+
const data = use(resolve);
|
|
17
|
+
return <>{children(data)}</>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function Await<T>({ resolve, fallback, children }: AwaitProps<T>) {
|
|
21
|
+
return (
|
|
22
|
+
<Suspense fallback={fallback}>
|
|
23
|
+
<Resolved resolve={resolve}>{children}</Resolved>
|
|
24
|
+
</Suspense>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useContext, type FormEvent, type ReactNode, type FormHTMLAttributes } from "react";
|
|
2
|
+
import { RouterContext, NavigationContext } from "../router.tsx";
|
|
3
|
+
import { reloadLoaders } from "../form-utils.ts";
|
|
4
|
+
|
|
5
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
type FormMethod = "post" | "put" | "delete";
|
|
8
|
+
|
|
9
|
+
interface FormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, "method" | "onSubmit"> {
|
|
10
|
+
method?: FormMethod;
|
|
11
|
+
action?: string;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── Component ──────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export function Form({ method = "post", action, children, ...rest }: FormProps) {
|
|
18
|
+
const routerCtx = useContext(RouterContext);
|
|
19
|
+
const navCtx = useContext(NavigationContext);
|
|
20
|
+
|
|
21
|
+
// SSR: render a plain form — no JS submit handler needed
|
|
22
|
+
if (!routerCtx || !navCtx) {
|
|
23
|
+
return (
|
|
24
|
+
<form method={method} action={action} {...rest}>
|
|
25
|
+
{children}
|
|
26
|
+
</form>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { pathname, setRoute } = routerCtx;
|
|
31
|
+
const { navigate } = navCtx;
|
|
32
|
+
|
|
33
|
+
// setLoaderData shim — updates just the loaderData slice via setRoute
|
|
34
|
+
function setLoaderData(data: Record<string, unknown>) {
|
|
35
|
+
setRoute({ loaderData: data });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
setRoute({ actionData: null }); // clear stale action data
|
|
41
|
+
|
|
42
|
+
const target = e.currentTarget;
|
|
43
|
+
const url = action ?? pathname;
|
|
44
|
+
const formData = new FormData(target);
|
|
45
|
+
|
|
46
|
+
const response = await fetch(url, {
|
|
47
|
+
method: method.toUpperCase(),
|
|
48
|
+
body: formData,
|
|
49
|
+
headers: { "X-BractJS-Action": "1" },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (response.redirected) {
|
|
53
|
+
await navigate(response.url);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const actionData = (await response.json()) as unknown;
|
|
58
|
+
setRoute({ actionData });
|
|
59
|
+
await reloadLoaders(pathname, setLoaderData);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<form method={method} onSubmit={(e) => { void handleSubmit(e); }} {...rest}>
|
|
64
|
+
{children}
|
|
65
|
+
</form>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
export type ImageFormat = "webp" | "avif" | "jpeg" | "png";
|
|
4
|
+
export type ImageFit = "cover" | "contain" | "fill";
|
|
5
|
+
|
|
6
|
+
export interface ImageProps {
|
|
7
|
+
/** Must be a path under /public/, e.g. /public/hero.jpg */
|
|
8
|
+
src: string;
|
|
9
|
+
alt: string;
|
|
10
|
+
width?: number;
|
|
11
|
+
height?: number;
|
|
12
|
+
quality?: number;
|
|
13
|
+
format?: ImageFormat;
|
|
14
|
+
fit?: ImageFit;
|
|
15
|
+
/** Disables lazy loading and sets fetchpriority=high (above-the-fold images). */
|
|
16
|
+
priority?: boolean;
|
|
17
|
+
sizes?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
style?: CSSProperties;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const WIDTHS = [320, 640, 768, 1024, 1280, 1536, 1920];
|
|
23
|
+
|
|
24
|
+
function imgUrl(src: string, w: number, q: number, format: ImageFormat, fit: ImageFit): string {
|
|
25
|
+
const sp = new URLSearchParams({
|
|
26
|
+
src,
|
|
27
|
+
w: String(w),
|
|
28
|
+
q: String(q),
|
|
29
|
+
format,
|
|
30
|
+
fit,
|
|
31
|
+
});
|
|
32
|
+
return `/_image?${sp.toString()}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function Image({
|
|
36
|
+
src,
|
|
37
|
+
alt,
|
|
38
|
+
width,
|
|
39
|
+
height,
|
|
40
|
+
quality = 80,
|
|
41
|
+
format = "webp",
|
|
42
|
+
fit = "cover",
|
|
43
|
+
priority = false,
|
|
44
|
+
sizes = "100vw",
|
|
45
|
+
className,
|
|
46
|
+
style,
|
|
47
|
+
}: ImageProps) {
|
|
48
|
+
// Only include widths up to 1.5× the declared intrinsic width to avoid
|
|
49
|
+
// generating unnecessarily large variants.
|
|
50
|
+
const widths = width
|
|
51
|
+
? WIDTHS.filter((w) => w <= Math.ceil(width * 1.5))
|
|
52
|
+
: WIDTHS;
|
|
53
|
+
|
|
54
|
+
// Ensure at least one srcset entry even if widths filtered to empty.
|
|
55
|
+
const srcWidths = widths.length > 0 ? widths : [width ?? 1280];
|
|
56
|
+
|
|
57
|
+
const srcset = srcWidths
|
|
58
|
+
.map((w) => `${imgUrl(src, w, quality, format, fit)} ${w}w`)
|
|
59
|
+
.join(", ");
|
|
60
|
+
|
|
61
|
+
const defaultSrc = imgUrl(src, width ?? 1280, quality, format, fit);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<img
|
|
65
|
+
src={defaultSrc}
|
|
66
|
+
srcSet={srcset}
|
|
67
|
+
alt={alt}
|
|
68
|
+
width={width}
|
|
69
|
+
height={height}
|
|
70
|
+
loading={priority ? "eager" : "lazy"}
|
|
71
|
+
decoding={priority ? "sync" : "async"}
|
|
72
|
+
// @ts-expect-error — fetchpriority is a valid HTML attribute in React 19
|
|
73
|
+
fetchpriority={priority ? "high" : "auto"}
|
|
74
|
+
sizes={sizes}
|
|
75
|
+
className={className}
|
|
76
|
+
style={style}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useContext, type AnchorHTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { NavigationContext, RouterContext } from "../router.tsx";
|
|
3
|
+
import { prefetchRoute } from "../prefetch.ts";
|
|
4
|
+
|
|
5
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
|
|
8
|
+
to: string;
|
|
9
|
+
prefetch?: "hover" | "none";
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ── Component ──────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export function Link({ to, prefetch = "none", children, ...rest }: LinkProps) {
|
|
16
|
+
const navCtx = useContext(NavigationContext);
|
|
17
|
+
const routerCtx = useContext(RouterContext);
|
|
18
|
+
const isLoading = navCtx?.state === "loading";
|
|
19
|
+
|
|
20
|
+
function handleClick(e: React.MouseEvent<HTMLAnchorElement>) {
|
|
21
|
+
if (!navCtx) return; // SSR: let browser handle naturally
|
|
22
|
+
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
void navCtx.navigate(to);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function handleMouseEnter() {
|
|
28
|
+
if (prefetch === "hover" && routerCtx) prefetchRoute(to, routerCtx.manifest);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<a
|
|
33
|
+
href={to}
|
|
34
|
+
onClick={handleClick}
|
|
35
|
+
onMouseEnter={handleMouseEnter}
|
|
36
|
+
aria-disabled={isLoading || undefined}
|
|
37
|
+
{...rest}
|
|
38
|
+
>
|
|
39
|
+
{children}
|
|
40
|
+
</a>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import { hmrClientScript } from "../../dev/hmr-client.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Renders an inline WebSocket HMR client in development.
|
|
6
|
+
* Returns null in production.
|
|
7
|
+
*/
|
|
8
|
+
export function LiveReload(): ReactElement | null {
|
|
9
|
+
if (process.env.NODE_ENV === "production") return null;
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<script
|
|
13
|
+
dangerouslySetInnerHTML={{ __html: hmrClientScript }}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Component, Suspense, useContext,
|
|
3
|
+
type ComponentType, type ReactElement, type ReactNode,
|
|
4
|
+
} from "react";
|
|
5
|
+
import { RouterContext } from "../router.tsx";
|
|
6
|
+
import { BractJSContext } from "../../shared/context.ts";
|
|
7
|
+
|
|
8
|
+
// ── Error Boundary ─────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
interface EBProps {
|
|
11
|
+
fallback: ComponentType<{ error: Error }>;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
interface EBState { error: Error | null }
|
|
15
|
+
|
|
16
|
+
class RouteErrorBoundary extends Component<EBProps, EBState> {
|
|
17
|
+
state: EBState = { error: null };
|
|
18
|
+
|
|
19
|
+
static getDerivedStateFromError(error: Error): EBState {
|
|
20
|
+
return { error };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
render(): ReactNode {
|
|
24
|
+
if (this.state.error) {
|
|
25
|
+
const Fallback = this.props.fallback;
|
|
26
|
+
return <Fallback error={this.state.error} />;
|
|
27
|
+
}
|
|
28
|
+
return this.props.children;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function DefaultErrorFallback({ error }: { error: Error }): ReactElement {
|
|
33
|
+
return <div style={{ color: "red" }}>Route error: {error.message}</div>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Outlet ─────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export function Outlet(): ReactElement | null {
|
|
39
|
+
// Client-side: use RouterContext (set by ClientRouter after navigation)
|
|
40
|
+
const routerCtx = useContext(RouterContext);
|
|
41
|
+
// Server-side (SSR): fall back to BractJSContext which carries RouteComponent
|
|
42
|
+
const bractCtx = useContext(BractJSContext);
|
|
43
|
+
|
|
44
|
+
const RouteComponent: ComponentType | undefined =
|
|
45
|
+
routerCtx?.currentModule?.default ?? bractCtx?.RouteComponent;
|
|
46
|
+
const ErrorFallback: ComponentType<{ error: Error }> =
|
|
47
|
+
routerCtx?.currentModule?.ErrorBoundary ?? DefaultErrorFallback;
|
|
48
|
+
|
|
49
|
+
if (!RouteComponent) {
|
|
50
|
+
return (
|
|
51
|
+
<Suspense fallback={null}>
|
|
52
|
+
{null}
|
|
53
|
+
</Suspense>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<RouteErrorBoundary fallback={ErrorFallback}>
|
|
59
|
+
<Suspense fallback={null}>
|
|
60
|
+
<RouteComponent />
|
|
61
|
+
</Suspense>
|
|
62
|
+
</RouteErrorBoundary>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scripts is a marker component.
|
|
3
|
+
* Bract's SSR render pipeline injects the client entry script and
|
|
4
|
+
* bootstrap data via `bootstrapScripts` and `bootstrapScriptContent`
|
|
5
|
+
* in renderToReadableStream — not through this component.
|
|
6
|
+
*
|
|
7
|
+
* At runtime this returns null; its presence in the component tree
|
|
8
|
+
* signals to the framework where scripts logically belong in the layout.
|
|
9
|
+
*/
|
|
10
|
+
export function Scripts(): null {
|
|
11
|
+
return null;
|
|
12
|
+
}
|