@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,68 @@
|
|
|
1
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export type Segment = string | { param: string } | { catchAll: string };
|
|
4
|
+
|
|
5
|
+
export interface RouteFile {
|
|
6
|
+
filePath: string;
|
|
7
|
+
urlPattern: string;
|
|
8
|
+
segments: Segment[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export function pathToSegments(pattern: string): Segment[] {
|
|
14
|
+
if (pattern === "") return [];
|
|
15
|
+
return pattern.split("/").map((seg) => {
|
|
16
|
+
if (seg.startsWith("[...") && seg.endsWith("]")) {
|
|
17
|
+
return { catchAll: seg.slice(4, -1) };
|
|
18
|
+
}
|
|
19
|
+
if (seg.startsWith("[") && seg.endsWith("]")) {
|
|
20
|
+
return { param: seg.slice(1, -1) };
|
|
21
|
+
}
|
|
22
|
+
return seg;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function filePathToPattern(filePath: string): string {
|
|
27
|
+
// Strip "routes/" prefix and file extension
|
|
28
|
+
let path = filePath.replace(/^routes\//, "").replace(/\.(tsx|ts)$/, "");
|
|
29
|
+
|
|
30
|
+
// Handle nested _index (e.g. blog/_index → blog)
|
|
31
|
+
path = path.replace(/\/_index$/, "");
|
|
32
|
+
|
|
33
|
+
// Handle root _index
|
|
34
|
+
if (path === "_index" || path === "") return "";
|
|
35
|
+
|
|
36
|
+
// Convert [param] and [...catchAll] segments — keep as-is for pattern string
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function segmentScore(seg: Segment): number {
|
|
41
|
+
if (typeof seg === "string") return 0; // static
|
|
42
|
+
if ("param" in seg) return 1; // dynamic
|
|
43
|
+
return 2; // catch-all
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function routeScore(route: RouteFile): number {
|
|
47
|
+
return route.segments.reduce((sum, seg) => sum + segmentScore(seg), 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Main ───────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export async function scanRoutes(appDir: string): Promise<RouteFile[]> {
|
|
53
|
+
const glob = new Bun.Glob("routes/**/*.{tsx,ts}");
|
|
54
|
+
const routes: RouteFile[] = [];
|
|
55
|
+
|
|
56
|
+
for await (const filePath of glob.scan(appDir)) {
|
|
57
|
+
// Skip layout files — handled separately
|
|
58
|
+
if (filePath.endsWith("/layout.tsx") || filePath.endsWith("/layout.ts")) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const urlPattern = filePathToPattern(filePath);
|
|
63
|
+
const segments = pathToSegments(urlPattern);
|
|
64
|
+
routes.push({ filePath, urlPattern, segments });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return routes.sort((a, b) => routeScore(a) - routeScore(b));
|
|
68
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { scanRoutes } from "./scanner.ts";
|
|
2
|
+
import { buildTrie } from "./matcher.ts";
|
|
3
|
+
import { handleRequest, type HandlerConfig } from "./request-handler.ts";
|
|
4
|
+
import { type ServerManifest } from "./render.ts";
|
|
5
|
+
import { isDev } from "./env.ts";
|
|
6
|
+
import { loadManifest } from "../build/manifest.ts";
|
|
7
|
+
import { serveStatic } from "./static.ts";
|
|
8
|
+
import { handleImageRequest } from "../image/handler.ts";
|
|
9
|
+
import { loadServerActions } from "./action-registry.ts";
|
|
10
|
+
import { handleActionRequest } from "./action-handler.ts";
|
|
11
|
+
import { resolve, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export interface BractJSConfig {
|
|
14
|
+
port: number;
|
|
15
|
+
appDir: string;
|
|
16
|
+
publicDir: string;
|
|
17
|
+
manifest: ServerManifest;
|
|
18
|
+
// Build options (used by src/build/bundler.ts)
|
|
19
|
+
sourcemap?: "none" | "linked" | "inline" | "external";
|
|
20
|
+
minify?: boolean;
|
|
21
|
+
clientEnv?: string[];
|
|
22
|
+
buildDir?: string;
|
|
23
|
+
/** Directory for transformed image cache. Defaults to .bract-image-cache */
|
|
24
|
+
imageCacheDir?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_MANIFEST: ServerManifest = {
|
|
28
|
+
clientEntry: "/build/client/client.js",
|
|
29
|
+
routes: {},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* In dev mode: read the manifest from disk on every request so that rebuilds
|
|
34
|
+
* are reflected immediately without restarting the server.
|
|
35
|
+
* The manifest is written by rebuildClient() in src/dev/rebuilder.ts.
|
|
36
|
+
*/
|
|
37
|
+
async function readDevManifest(buildDir: string): Promise<ServerManifest> {
|
|
38
|
+
const f = Bun.file(join(buildDir, "route-manifest.json"));
|
|
39
|
+
if (!(await f.exists())) return DEFAULT_MANIFEST;
|
|
40
|
+
const m = await f.json() as { clientEntry?: string; rootChunk?: string; routes?: Record<string, { chunk?: string }> };
|
|
41
|
+
return {
|
|
42
|
+
clientEntry: m.clientEntry ?? DEFAULT_MANIFEST.clientEntry,
|
|
43
|
+
rootChunk: m.rootChunk,
|
|
44
|
+
routes: Object.fromEntries(
|
|
45
|
+
Object.entries(m.routes ?? {}).map(([pat, e]) => [pat, { file: e.chunk ?? "", chunk: e.chunk }]),
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createServer(config?: Partial<BractJSConfig>): {
|
|
51
|
+
server: ReturnType<typeof Bun.serve>;
|
|
52
|
+
stop(): void;
|
|
53
|
+
} {
|
|
54
|
+
const port = config?.port ?? 3000;
|
|
55
|
+
const appDir = resolve(config?.appDir ?? "./app");
|
|
56
|
+
const publicDir = resolve(config?.publicDir ?? "./public");
|
|
57
|
+
const buildDir = resolve(config?.buildDir ?? "./build");
|
|
58
|
+
const imageCacheDir = resolve(config?.imageCacheDir ?? ".bract-image-cache");
|
|
59
|
+
|
|
60
|
+
// In production, load the pre-built manifest; otherwise use provided or default
|
|
61
|
+
const manifestReady: Promise<ServerManifest> = !isDev() && !config?.manifest
|
|
62
|
+
? loadManifest(buildDir).then((m) => ({
|
|
63
|
+
clientEntry: m.clientEntry,
|
|
64
|
+
rootChunk: m.rootChunk,
|
|
65
|
+
routes: Object.fromEntries(
|
|
66
|
+
Object.entries(m.routes).map(([pat, e]) => [pat, { file: e.chunk, chunk: e.chunk }]),
|
|
67
|
+
),
|
|
68
|
+
}))
|
|
69
|
+
: Promise.resolve(config?.manifest ?? DEFAULT_MANIFEST);
|
|
70
|
+
|
|
71
|
+
// Build route trie and register server actions concurrently at startup.
|
|
72
|
+
const trieReady = scanRoutes(appDir).then(buildTrie);
|
|
73
|
+
const actionsReady = loadServerActions(appDir);
|
|
74
|
+
|
|
75
|
+
const server = Bun.serve({
|
|
76
|
+
port,
|
|
77
|
+
async fetch(request) {
|
|
78
|
+
const url = new URL(request.url);
|
|
79
|
+
const { pathname } = url;
|
|
80
|
+
|
|
81
|
+
// Dev-only: on-demand module compilation for HMR module swap
|
|
82
|
+
if (isDev() && pathname === "/_hmr/module") {
|
|
83
|
+
const { handleHmrModuleRequest } = await import("../dev/hmr-module-handler.ts");
|
|
84
|
+
return handleHmrModuleRequest(url, appDir);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Server actions endpoint
|
|
88
|
+
if (pathname.startsWith("/_action")) {
|
|
89
|
+
await actionsReady;
|
|
90
|
+
const actionRes = await handleActionRequest(request);
|
|
91
|
+
if (actionRes) return actionRes;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Image optimization endpoint
|
|
95
|
+
if (pathname === "/_image") {
|
|
96
|
+
const imgRes = await handleImageRequest(request, publicDir, imageCacheDir);
|
|
97
|
+
if (imgRes) return imgRes;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Serve hashed client assets + public/ with correct cache headers
|
|
101
|
+
const staticRes = await serveStatic(pathname, buildDir, publicDir);
|
|
102
|
+
if (staticRes) return staticRes;
|
|
103
|
+
|
|
104
|
+
const trie = await trieReady;
|
|
105
|
+
const manifest = isDev() ? await readDevManifest(buildDir) : await manifestReady;
|
|
106
|
+
const handlerConfig: HandlerConfig = { appDir, publicDir, manifest };
|
|
107
|
+
return handleRequest(request, trie, handlerConfig);
|
|
108
|
+
},
|
|
109
|
+
// Return JSON for any uncaught exception so the client's r.json() never sees
|
|
110
|
+
// plain-text "Internal Server Error" bodies → prevents JSON.parse errors.
|
|
111
|
+
error(err: Error) {
|
|
112
|
+
console.error("[bractjs] unhandled server error:", err);
|
|
113
|
+
return new Response(JSON.stringify({ error: err.message }), {
|
|
114
|
+
status: 500,
|
|
115
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
console.log(`[bract] Server running at http://localhost:${port}`);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
server,
|
|
124
|
+
stop() { server.stop(); },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Allow running directly: bun run src/server/serve.ts
|
|
129
|
+
if (import.meta.main) {
|
|
130
|
+
createServer();
|
|
131
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export type SessionData = Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
export interface Session {
|
|
4
|
+
get(key: string): unknown;
|
|
5
|
+
set(key: string, val: unknown): void;
|
|
6
|
+
delete(key: string): void;
|
|
7
|
+
has(key: string): boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SessionStorage {
|
|
11
|
+
getSession(cookie?: string | null): Promise<Session>;
|
|
12
|
+
commitSession(session: Session, opts?: CommitOptions): Promise<string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CookieSessionOptions {
|
|
16
|
+
name: string;
|
|
17
|
+
secrets: string[];
|
|
18
|
+
maxAge?: number;
|
|
19
|
+
secure?: boolean;
|
|
20
|
+
sameSite?: "Strict" | "Lax" | "None";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CommitOptions {
|
|
24
|
+
maxAge?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Internal brand for accessing session data without exposing it on the public interface
|
|
28
|
+
const DATA = Symbol("bract.session.data");
|
|
29
|
+
interface InternalSession extends Session { [DATA]: SessionData }
|
|
30
|
+
|
|
31
|
+
// ── Private helpers ─────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function encode(data: SessionData): string {
|
|
34
|
+
return btoa(JSON.stringify(data))
|
|
35
|
+
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function decode(encoded: string): SessionData {
|
|
39
|
+
const pad = "=".repeat((4 - (encoded.length % 4)) % 4);
|
|
40
|
+
return JSON.parse(atob(encoded.replace(/-/g, "+").replace(/_/g, "/") + pad)) as SessionData;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function sign(data: string, secret: string): Promise<string> {
|
|
44
|
+
const enc = new TextEncoder();
|
|
45
|
+
const key = await crypto.subtle.importKey(
|
|
46
|
+
"raw", enc.encode(secret),
|
|
47
|
+
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"],
|
|
48
|
+
);
|
|
49
|
+
const buf = await crypto.subtle.sign("HMAC", key, enc.encode(data));
|
|
50
|
+
return btoa(String.fromCharCode(...new Uint8Array(buf)))
|
|
51
|
+
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function verify(data: string, sig: string, secrets: string[]): Promise<boolean> {
|
|
55
|
+
for (const secret of secrets) {
|
|
56
|
+
const expected = await sign(data, secret);
|
|
57
|
+
if (expected.length === sig.length) {
|
|
58
|
+
let diff = 0;
|
|
59
|
+
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
|
|
60
|
+
if (diff === 0) return true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeSession(data: SessionData): InternalSession {
|
|
67
|
+
return {
|
|
68
|
+
[DATA]: data,
|
|
69
|
+
get: (key) => data[key],
|
|
70
|
+
set: (key, val) => { data[key] = val; },
|
|
71
|
+
delete: (key) => { delete data[key]; },
|
|
72
|
+
has: (key) => key in data,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export function createCookieSession(options: CookieSessionOptions): SessionStorage {
|
|
79
|
+
const { name, secrets, maxAge, secure = true, sameSite = "Lax" } = options;
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
async getSession(cookie?: string | null): Promise<Session> {
|
|
83
|
+
if (!cookie) return makeSession({});
|
|
84
|
+
const pair = cookie.split(";").map((s) => s.trim()).find((p) => p.startsWith(`${name}=`));
|
|
85
|
+
if (!pair) return makeSession({});
|
|
86
|
+
|
|
87
|
+
const value = pair.slice(name.length + 1);
|
|
88
|
+
const dot = value.lastIndexOf(".");
|
|
89
|
+
if (dot === -1) return makeSession({});
|
|
90
|
+
|
|
91
|
+
const encoded = value.slice(0, dot);
|
|
92
|
+
const sig = value.slice(dot + 1);
|
|
93
|
+
if (!(await verify(encoded, sig, secrets))) return makeSession({});
|
|
94
|
+
|
|
95
|
+
try { return makeSession(decode(encoded)); }
|
|
96
|
+
catch { return makeSession({}); }
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async commitSession(session: Session, opts?: CommitOptions): Promise<string> {
|
|
100
|
+
const data = (session as InternalSession)[DATA] ?? {};
|
|
101
|
+
const encoded = encode(data);
|
|
102
|
+
const sig = await sign(encoded, secrets[0]);
|
|
103
|
+
const age = opts?.maxAge ?? maxAge;
|
|
104
|
+
|
|
105
|
+
const parts = [`${name}=${encoded}.${sig}`, "HttpOnly", `SameSite=${sameSite}`, "Path=/"];
|
|
106
|
+
if (age !== undefined) parts.push(`Max-Age=${age}`);
|
|
107
|
+
if (secure) parts.push("Secure");
|
|
108
|
+
return parts.join("; ");
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
const IMMUTABLE = "public, max-age=31536000, immutable";
|
|
4
|
+
const NO_CACHE = "no-cache";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Serve hashed client assets or public/ files.
|
|
8
|
+
* Returns null if the path doesn't match or the file isn't found.
|
|
9
|
+
* Guards against path traversal by resolving and prefix-checking.
|
|
10
|
+
*/
|
|
11
|
+
export async function serveStatic(
|
|
12
|
+
pathname: string,
|
|
13
|
+
buildDir: string,
|
|
14
|
+
publicDir: string,
|
|
15
|
+
): Promise<Response | null> {
|
|
16
|
+
// Security: reject traversal sequences before any path resolution
|
|
17
|
+
if (pathname.includes("..")) return null;
|
|
18
|
+
|
|
19
|
+
if (pathname.startsWith("/build/client/")) {
|
|
20
|
+
const rel = pathname.slice("/build/client/".length);
|
|
21
|
+
const root = resolve(join(buildDir, "client"));
|
|
22
|
+
const full = resolve(join(root, rel));
|
|
23
|
+
if (!full.startsWith(root + "/") && full !== root) return null;
|
|
24
|
+
const file = Bun.file(full);
|
|
25
|
+
if (!(await file.exists())) return null;
|
|
26
|
+
return new Response(file, { headers: { "Cache-Control": IMMUTABLE } });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (pathname.startsWith("/public/")) {
|
|
30
|
+
const rel = pathname.slice("/public/".length);
|
|
31
|
+
const root = resolve(publicDir);
|
|
32
|
+
const full = resolve(join(root, rel));
|
|
33
|
+
if (!full.startsWith(root + "/") && full !== root) return null;
|
|
34
|
+
const file = Bun.file(full);
|
|
35
|
+
if (!(await file.exists())) return null;
|
|
36
|
+
return new Response(file, { headers: { "Cache-Control": NO_CACHE } });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createContext, useContext, createElement, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface RouteManifest {
|
|
4
|
+
[routeId: string]: {
|
|
5
|
+
file: string;
|
|
6
|
+
imports?: string[];
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface BractJSContextValue {
|
|
11
|
+
loaderData: Record<string, unknown>;
|
|
12
|
+
actionData: unknown;
|
|
13
|
+
params: Record<string, string>;
|
|
14
|
+
pathname: string;
|
|
15
|
+
manifest: RouteManifest;
|
|
16
|
+
/** SSR-only: the matched route's default export so <Outlet> can render it without ClientRouter */
|
|
17
|
+
RouteComponent?: ComponentType;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const BractJSContext = createContext<BractJSContextValue>(null!);
|
|
21
|
+
|
|
22
|
+
interface BractJSProviderProps {
|
|
23
|
+
value: BractJSContextValue;
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function BractJSProvider({ value, children }: BractJSProviderProps) {
|
|
28
|
+
return createElement(BractJSContext.Provider, { value }, children);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useBractJSContext(): BractJSContextValue {
|
|
32
|
+
const ctx = useContext(BractJSContext);
|
|
33
|
+
if (ctx === null) {
|
|
34
|
+
throw new Error("useBractJSContext must be used within a BractJSProvider");
|
|
35
|
+
}
|
|
36
|
+
return ctx;
|
|
37
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const DEFERRED_MARKER = Symbol("bract.deferred");
|
|
2
|
+
|
|
3
|
+
export class Deferred<T> {
|
|
4
|
+
readonly promise: Promise<T>;
|
|
5
|
+
readonly [DEFERRED_MARKER] = true as const;
|
|
6
|
+
|
|
7
|
+
constructor(promise: Promise<T>) {
|
|
8
|
+
this.promise = promise;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type DeferredData<T extends Record<string, unknown>> = {
|
|
13
|
+
[K in keyof T]: T[K] extends Promise<infer V> ? Deferred<V> : T[K];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function defer<T extends Record<string, unknown>>(
|
|
17
|
+
data: T
|
|
18
|
+
): DeferredData<T> {
|
|
19
|
+
const result: Record<string, unknown> = {};
|
|
20
|
+
|
|
21
|
+
for (const key of Object.keys(data)) {
|
|
22
|
+
const value = data[key];
|
|
23
|
+
result[key] =
|
|
24
|
+
value instanceof Promise ? new Deferred(value) : value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return result as DeferredData<T>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isDeferred<T>(value: unknown): value is Deferred<T> {
|
|
31
|
+
return value instanceof Deferred;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Returns only the already-resolved (non-Promise) values from a DeferredData object. */
|
|
35
|
+
export function stripDeferred<T extends Record<string, unknown>>(
|
|
36
|
+
data: T
|
|
37
|
+
): Record<string, unknown> {
|
|
38
|
+
const result: Record<string, unknown> = {};
|
|
39
|
+
for (const key of Object.keys(data)) {
|
|
40
|
+
if (!isDeferred(data[key])) result[key] = data[key];
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Returns only the deferred promises from a DeferredData object, keyed by field name. */
|
|
46
|
+
export function promisesOf<T extends Record<string, unknown>>(
|
|
47
|
+
data: T
|
|
48
|
+
): Record<string, Promise<unknown>> {
|
|
49
|
+
const result: Record<string, Promise<unknown>> = {};
|
|
50
|
+
for (const key of Object.keys(data)) {
|
|
51
|
+
const value = data[key];
|
|
52
|
+
if (isDeferred(value)) result[key] = (value as Deferred<unknown>).promise;
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Component, type ComponentType, type ReactNode, type ReactElement } from "react";
|
|
2
|
+
|
|
3
|
+
// ── DefaultErrorBoundary ───────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
interface DefaultErrorBoundaryProps {
|
|
6
|
+
error: Error;
|
|
7
|
+
requestId?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function DefaultErrorBoundary({ error, requestId }: DefaultErrorBoundaryProps): ReactElement {
|
|
11
|
+
if (process.env.NODE_ENV !== "production") {
|
|
12
|
+
return (
|
|
13
|
+
<div style={{ padding: "2rem", fontFamily: "monospace" }}>
|
|
14
|
+
<h2 style={{ color: "#e74c3c", margin: "0 0 1rem" }}>{error.message}</h2>
|
|
15
|
+
<pre style={{ overflow: "auto", background: "#111", color: "#f8f8f8", padding: "1rem" }}>
|
|
16
|
+
{error.stack}
|
|
17
|
+
</pre>
|
|
18
|
+
<button onClick={() => { void navigator.clipboard.writeText(error.stack ?? error.message); }}>
|
|
19
|
+
Copy stack
|
|
20
|
+
</button>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div style={{ padding: "2rem" }}>
|
|
27
|
+
<h2>Something went wrong</h2>
|
|
28
|
+
{requestId && <p>Request ID: {requestId}</p>}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── RouteErrorBoundary ─────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
interface RouteErrorBoundaryProps {
|
|
36
|
+
errorBoundary?: ComponentType<{ error: Error }>;
|
|
37
|
+
children: ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface RouteErrorBoundaryState {
|
|
41
|
+
error: Error | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class RouteErrorBoundary extends Component<RouteErrorBoundaryProps, RouteErrorBoundaryState> {
|
|
45
|
+
state: RouteErrorBoundaryState = { error: null };
|
|
46
|
+
|
|
47
|
+
static getDerivedStateFromError(error: Error): RouteErrorBoundaryState {
|
|
48
|
+
return { error };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
render(): ReactNode {
|
|
52
|
+
if (this.state.error) {
|
|
53
|
+
const ErrorComponent = this.props.errorBoundary ?? DefaultErrorBoundary;
|
|
54
|
+
return <ErrorComponent error={this.state.error} />;
|
|
55
|
+
}
|
|
56
|
+
return this.props.children;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export class BractJSError extends Error {
|
|
2
|
+
readonly status: number;
|
|
3
|
+
|
|
4
|
+
constructor(message: string, status: number = 500) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "BractJSError";
|
|
7
|
+
this.status = status;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class HttpError extends BractJSError {
|
|
12
|
+
constructor(status: number, message?: string) {
|
|
13
|
+
super(message ?? httpStatusText(status), status);
|
|
14
|
+
this.name = "HttpError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isRedirect(value: unknown): value is Response {
|
|
19
|
+
return (
|
|
20
|
+
value instanceof Response &&
|
|
21
|
+
value.status >= 300 &&
|
|
22
|
+
value.status < 400
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isHttpError(value: unknown): value is HttpError {
|
|
27
|
+
return value instanceof HttpError;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isBractJSError(value: unknown): value is BractJSError {
|
|
31
|
+
return value instanceof BractJSError;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function httpStatusText(status: number): string {
|
|
35
|
+
const texts: Record<number, string> = {
|
|
36
|
+
400: "Bad Request",
|
|
37
|
+
401: "Unauthorized",
|
|
38
|
+
403: "Forbidden",
|
|
39
|
+
404: "Not Found",
|
|
40
|
+
405: "Method Not Allowed",
|
|
41
|
+
422: "Unprocessable Entity",
|
|
42
|
+
429: "Too Many Requests",
|
|
43
|
+
500: "Internal Server Error",
|
|
44
|
+
503: "Service Unavailable",
|
|
45
|
+
};
|
|
46
|
+
return texts[status] ?? `HTTP Error ${status}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { DefaultErrorBoundary, RouteErrorBoundary } from "./error-boundary.tsx";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Deferred } from "./deferred.ts";
|
|
2
|
+
|
|
3
|
+
export interface LoaderArgs {
|
|
4
|
+
request: Request;
|
|
5
|
+
params: Record<string, string>;
|
|
6
|
+
context: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ActionArgs extends LoaderArgs {
|
|
10
|
+
formData: FormData;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type MetaDescriptor =
|
|
14
|
+
| { title: string }
|
|
15
|
+
| { name: string; content: string }
|
|
16
|
+
| { property: string; content: string }
|
|
17
|
+
| { [key: string]: string };
|
|
18
|
+
|
|
19
|
+
export interface MetaArgs<T = unknown> {
|
|
20
|
+
loaderData: T;
|
|
21
|
+
params: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type LoaderFunction<T = unknown> = (
|
|
25
|
+
args: LoaderArgs
|
|
26
|
+
) => Promise<T | Response> | T | Response;
|
|
27
|
+
|
|
28
|
+
export type ActionFunction<T = unknown> = (
|
|
29
|
+
args: ActionArgs
|
|
30
|
+
) => Promise<T | Response> | T | Response;
|
|
31
|
+
|
|
32
|
+
export type MetaFunction<T = unknown> = (
|
|
33
|
+
args: MetaArgs<T>
|
|
34
|
+
) => MetaDescriptor[];
|
|
35
|
+
|
|
36
|
+
export interface RouteModule<TLoader = unknown, TAction = unknown> {
|
|
37
|
+
loader?: LoaderFunction<TLoader>;
|
|
38
|
+
action?: ActionFunction<TAction>;
|
|
39
|
+
meta?: MetaFunction<TLoader>;
|
|
40
|
+
handle?: Record<string, unknown>;
|
|
41
|
+
ErrorBoundary?: React.ComponentType<{ error: unknown }>;
|
|
42
|
+
default?: React.ComponentType;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RouteDefinition {
|
|
46
|
+
id: string;
|
|
47
|
+
path: string;
|
|
48
|
+
filePath: string;
|
|
49
|
+
parentId?: string;
|
|
50
|
+
index?: boolean;
|
|
51
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// This is the root layout for your BractJS app.
|
|
2
|
+
// Every route renders inside this component.
|
|
3
|
+
import { Scripts, LiveReload, Outlet } from "bractjs";
|
|
4
|
+
|
|
5
|
+
export default function Root() {
|
|
6
|
+
return (
|
|
7
|
+
<html lang="en">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charSet="utf-8" />
|
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
11
|
+
<title>{{APP_NAME}}</title>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<Outlet />
|
|
15
|
+
<Scripts />
|
|
16
|
+
<LiveReload />
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useLoaderData } from "bractjs";
|
|
2
|
+
import type { LoaderArgs } from "bractjs";
|
|
3
|
+
import { Link } from "bractjs";
|
|
4
|
+
|
|
5
|
+
interface HomeData {
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function loader(_args: LoaderArgs): Promise<HomeData> {
|
|
10
|
+
return { message: "Hello from BractJS!" };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function meta() {
|
|
14
|
+
return [{ title: "Home | {{APP_NAME}}" }];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function Index() {
|
|
18
|
+
const { message } = useLoaderData<HomeData>();
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<main style={{ fontFamily: "system-ui, sans-serif", padding: "2rem" }}>
|
|
22
|
+
<h1>{message}</h1>
|
|
23
|
+
<p>
|
|
24
|
+
Edit <code>app/routes/_index.tsx</code> to get started.
|
|
25
|
+
</p>
|
|
26
|
+
<nav>
|
|
27
|
+
<Link to="/about">About →</Link>
|
|
28
|
+
</nav>
|
|
29
|
+
</main>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Link } from "bractjs";
|
|
2
|
+
|
|
3
|
+
export function meta() {
|
|
4
|
+
return [{ title: "About | {{APP_NAME}}" }];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export default function About() {
|
|
8
|
+
return (
|
|
9
|
+
<main style={{ fontFamily: "system-ui, sans-serif", padding: "2rem" }}>
|
|
10
|
+
<h1>About</h1>
|
|
11
|
+
<p>
|
|
12
|
+
This app is built with{" "}
|
|
13
|
+
<a href="https://github.com/bractjs" target="_blank" rel="noreferrer">
|
|
14
|
+
BractJS
|
|
15
|
+
</a>{" "}
|
|
16
|
+
— an SSR framework for Bun + React 19.
|
|
17
|
+
</p>
|
|
18
|
+
<Link to="/">← Home</Link>
|
|
19
|
+
</main>
|
|
20
|
+
);
|
|
21
|
+
}
|