@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,8 @@
|
|
|
1
|
+
export { createServer } from "./serve.ts";
|
|
2
|
+
export type { BractJSConfig } from "./serve.ts";
|
|
3
|
+
|
|
4
|
+
export { renderRoute } from "./render.ts";
|
|
5
|
+
export type { RenderOptions, ServerManifest } from "./render.ts";
|
|
6
|
+
|
|
7
|
+
export { redirect, json, error } from "./response.ts";
|
|
8
|
+
export { isDev, requireEnv, safeStringify } from "./env.ts";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import type { RouteFile } from "./scanner.ts";
|
|
3
|
+
import type { RouteModule } from "../shared/route-types.ts";
|
|
4
|
+
|
|
5
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface LayoutChain {
|
|
8
|
+
root: RouteModule;
|
|
9
|
+
layouts: RouteModule[];
|
|
10
|
+
route: RouteModule;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ResolvedRoute extends RouteFile {
|
|
14
|
+
layoutFiles: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/** Derive the ancestor directory segments from a route's urlPattern. */
|
|
20
|
+
function layoutDirs(urlPattern: string): string[] {
|
|
21
|
+
if (urlPattern === "") return [];
|
|
22
|
+
const segments = urlPattern.split("/");
|
|
23
|
+
// For "blog/[id]" → check "routes/blog/layout.tsx" only (not the leaf)
|
|
24
|
+
segments.pop();
|
|
25
|
+
const dirs: string[] = [];
|
|
26
|
+
for (let i = 1; i <= segments.length; i++) {
|
|
27
|
+
dirs.push(segments.slice(0, i).join("/"));
|
|
28
|
+
}
|
|
29
|
+
return dirs;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── resolveLayoutChain ─────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export async function resolveLayoutChain(
|
|
35
|
+
routeFile: RouteFile,
|
|
36
|
+
appDir: string
|
|
37
|
+
): Promise<ResolvedRoute> {
|
|
38
|
+
const layoutFiles: string[] = [];
|
|
39
|
+
|
|
40
|
+
// root.tsx is always first — resolve to absolute so dynamic import works
|
|
41
|
+
// regardless of which package file calls importRouteModule.
|
|
42
|
+
const rootPath = resolve(join(appDir, "root.tsx"));
|
|
43
|
+
if (await Bun.file(rootPath).exists()) {
|
|
44
|
+
layoutFiles.push(rootPath);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Intermediate layout.tsx files, outermost → innermost
|
|
48
|
+
for (const dir of layoutDirs(routeFile.urlPattern)) {
|
|
49
|
+
const layoutPath = resolve(join(appDir, "routes", dir, "layout.tsx"));
|
|
50
|
+
if (await Bun.file(layoutPath).exists()) {
|
|
51
|
+
layoutFiles.push(layoutPath);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { ...routeFile, layoutFiles };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── importRouteModule ──────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export async function importRouteModule(filePath: string): Promise<RouteModule> {
|
|
61
|
+
const mod = await import(filePath);
|
|
62
|
+
return {
|
|
63
|
+
loader: mod.loader,
|
|
64
|
+
action: mod.action,
|
|
65
|
+
meta: mod.meta,
|
|
66
|
+
handle: mod.handle,
|
|
67
|
+
ErrorBoundary: mod.ErrorBoundary,
|
|
68
|
+
default: mod.default,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── resolveRouteChain ──────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export async function resolveRouteChain(
|
|
75
|
+
routeFile: RouteFile,
|
|
76
|
+
appDir: string
|
|
77
|
+
): Promise<LayoutChain> {
|
|
78
|
+
const resolved = await resolveLayoutChain(routeFile, appDir);
|
|
79
|
+
|
|
80
|
+
const [rootMod, ...layoutMods] = await Promise.all(
|
|
81
|
+
resolved.layoutFiles.map(importRouteModule)
|
|
82
|
+
);
|
|
83
|
+
const routeMod = await importRouteModule(
|
|
84
|
+
resolve(join(appDir, routeFile.filePath))
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
root: rootMod ?? {},
|
|
89
|
+
layouts: layoutMods,
|
|
90
|
+
route: routeMod,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { LoaderArgs, ActionArgs, RouteModule } from "../shared/route-types.ts";
|
|
2
|
+
import type { LayoutChain } from "./layout.ts";
|
|
3
|
+
import { isRedirect, isHttpError } from "../shared/errors.ts";
|
|
4
|
+
|
|
5
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export type LoaderResult = unknown | { __error: unknown } | null;
|
|
8
|
+
|
|
9
|
+
export interface LoaderResults {
|
|
10
|
+
root: LoaderResult;
|
|
11
|
+
layouts: LoaderResult[];
|
|
12
|
+
route: LoaderResult;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── safeRun ────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export async function safeRun<T>(
|
|
18
|
+
fn: ((args: LoaderArgs) => Promise<T> | T) | undefined,
|
|
19
|
+
args: LoaderArgs
|
|
20
|
+
): Promise<T | { __error: unknown } | null> {
|
|
21
|
+
if (!fn) return null;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
return await fn(args);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
// Re-throw redirects and HTTP errors — caller handles them
|
|
27
|
+
if (isRedirect(err) || isHttpError(err)) throw err;
|
|
28
|
+
return { __error: err };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── runLoaders ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export async function runLoaders(
|
|
35
|
+
chain: LayoutChain,
|
|
36
|
+
args: LoaderArgs
|
|
37
|
+
): Promise<LoaderResults> {
|
|
38
|
+
const layoutLoaders = chain.layouts.map((mod) =>
|
|
39
|
+
safeRun(mod.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const [root, ...layoutResults] = await Promise.all([
|
|
43
|
+
safeRun(chain.root.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args),
|
|
44
|
+
...layoutLoaders,
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const route = await safeRun(
|
|
48
|
+
chain.route.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined,
|
|
49
|
+
args
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return { root, layouts: layoutResults, route };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── runAction ──────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export async function runAction(
|
|
58
|
+
routeModule: RouteModule,
|
|
59
|
+
args: ActionArgs
|
|
60
|
+
): Promise<unknown> {
|
|
61
|
+
if (!routeModule.action) return null;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
return await (routeModule.action as (a: ActionArgs) => Promise<unknown>)(args);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
// Re-throw redirects so the server can issue the 3xx response
|
|
67
|
+
if (isRedirect(err) || isHttpError(err)) throw err;
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── buildLoaderArgs ────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export function buildLoaderArgs(
|
|
75
|
+
request: Request,
|
|
76
|
+
params: Record<string, string>,
|
|
77
|
+
context: Record<string, unknown>
|
|
78
|
+
): LoaderArgs {
|
|
79
|
+
return { request, params, context };
|
|
80
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { RouteFile, Segment } from "./scanner.ts";
|
|
2
|
+
|
|
3
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export interface TrieNode {
|
|
6
|
+
children: Map<string, TrieNode>;
|
|
7
|
+
paramChild?: { name: string; node: TrieNode };
|
|
8
|
+
catchAllChild?: { name: string; node: TrieNode };
|
|
9
|
+
routeFile?: RouteFile;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type MatchResult = {
|
|
13
|
+
routeFile: RouteFile;
|
|
14
|
+
params: Record<string, string>;
|
|
15
|
+
} | null;
|
|
16
|
+
|
|
17
|
+
// ── Build ──────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function makeNode(): TrieNode {
|
|
20
|
+
return { children: new Map() };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildTrie(routes: RouteFile[]): TrieNode {
|
|
24
|
+
const root = makeNode();
|
|
25
|
+
|
|
26
|
+
for (const route of routes) {
|
|
27
|
+
let node = root;
|
|
28
|
+
|
|
29
|
+
for (const seg of route.segments) {
|
|
30
|
+
if (typeof seg === "string") {
|
|
31
|
+
if (!node.children.has(seg)) node.children.set(seg, makeNode());
|
|
32
|
+
node = node.children.get(seg)!;
|
|
33
|
+
} else if ("param" in seg) {
|
|
34
|
+
if (!node.paramChild) node.paramChild = { name: seg.param, node: makeNode() };
|
|
35
|
+
node = node.paramChild.node;
|
|
36
|
+
} else {
|
|
37
|
+
// catchAll — terminal, store and stop
|
|
38
|
+
if (!node.catchAllChild) node.catchAllChild = { name: seg.catchAll, node: makeNode() };
|
|
39
|
+
node = node.catchAllChild.node;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
node.routeFile = route;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return root;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Match ──────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export function matchRoute(pathname: string, trie: TrieNode): MatchResult {
|
|
53
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
54
|
+
return walk(trie, parts, 0, {});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function walk(
|
|
58
|
+
node: TrieNode,
|
|
59
|
+
parts: string[],
|
|
60
|
+
idx: number,
|
|
61
|
+
params: Record<string, string>
|
|
62
|
+
): MatchResult {
|
|
63
|
+
// All parts consumed — check for route at this node
|
|
64
|
+
if (idx === parts.length) {
|
|
65
|
+
return node.routeFile ? { routeFile: node.routeFile, params } : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const part = parts[idx];
|
|
69
|
+
|
|
70
|
+
// 1. Prefer static match
|
|
71
|
+
const staticChild = node.children.get(part);
|
|
72
|
+
if (staticChild) {
|
|
73
|
+
const result = walk(staticChild, parts, idx + 1, params);
|
|
74
|
+
if (result) return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 2. Try param match
|
|
78
|
+
if (node.paramChild) {
|
|
79
|
+
const result = walk(node.paramChild.node, parts, idx + 1, {
|
|
80
|
+
...params,
|
|
81
|
+
[node.paramChild.name]: part,
|
|
82
|
+
});
|
|
83
|
+
if (result) return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 3. Try catch-all — consumes remaining segments
|
|
87
|
+
if (node.catchAllChild) {
|
|
88
|
+
const remaining = parts.slice(idx).join("/");
|
|
89
|
+
const catchNode = node.catchAllChild.node;
|
|
90
|
+
if (catchNode.routeFile) {
|
|
91
|
+
return {
|
|
92
|
+
routeFile: catchNode.routeFile,
|
|
93
|
+
params: { ...params, [node.catchAllChild.name]: remaining },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { LayoutChain } from "./layout.ts";
|
|
2
|
+
import type { LoaderResults } from "./loader.ts";
|
|
3
|
+
import type { MetaDescriptor } from "../shared/route-types.ts";
|
|
4
|
+
|
|
5
|
+
type Params = Record<string, string>;
|
|
6
|
+
|
|
7
|
+
// ── resolveMeta ────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Calls each route module's meta() in layout chain order (root → layouts → route),
|
|
11
|
+
* passing the appropriate loaderData slice + params to each.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveMeta(
|
|
14
|
+
chain: LayoutChain,
|
|
15
|
+
loaderData: LoaderResults,
|
|
16
|
+
params: Params,
|
|
17
|
+
): MetaDescriptor[] {
|
|
18
|
+
const all: MetaDescriptor[] = [];
|
|
19
|
+
|
|
20
|
+
if (chain.root.meta) {
|
|
21
|
+
all.push(...chain.root.meta({ loaderData: loaderData.root, params }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
chain.layouts.forEach((mod, i) => {
|
|
25
|
+
if (mod.meta) {
|
|
26
|
+
all.push(...mod.meta({ loaderData: loaderData.layouts[i] ?? null, params }));
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (chain.route.meta) {
|
|
31
|
+
all.push(...chain.route.meta({ loaderData: loaderData.route, params }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return all;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── mergeMeta ──────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Deduplicates descriptors: for same `name` or `property`, last-writer wins.
|
|
41
|
+
* Title: last `{ title }` descriptor wins.
|
|
42
|
+
*/
|
|
43
|
+
export function mergeMeta(descriptors: MetaDescriptor[]): MetaDescriptor[] {
|
|
44
|
+
const byName = new Map<string, MetaDescriptor>();
|
|
45
|
+
const byProperty = new Map<string, MetaDescriptor>();
|
|
46
|
+
let title: MetaDescriptor | null = null;
|
|
47
|
+
const rest: MetaDescriptor[] = [];
|
|
48
|
+
|
|
49
|
+
for (const d of descriptors) {
|
|
50
|
+
if ("title" in d) {
|
|
51
|
+
title = d;
|
|
52
|
+
} else if ("name" in d) {
|
|
53
|
+
byName.set((d as { name: string }).name, d);
|
|
54
|
+
} else if ("property" in d) {
|
|
55
|
+
byProperty.set((d as { property: string }).property, d);
|
|
56
|
+
} else {
|
|
57
|
+
rest.push(d);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return [
|
|
62
|
+
...(title ? [title] : []),
|
|
63
|
+
...Array.from(byName.values()),
|
|
64
|
+
...Array.from(byProperty.values()),
|
|
65
|
+
...rest,
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── renderMetaTags ─────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/** Returns HTML string of <title> and <meta> tags for SSR head injection. */
|
|
72
|
+
export function renderMetaTags(descriptors: MetaDescriptor[]): string {
|
|
73
|
+
return descriptors
|
|
74
|
+
.map((d) => {
|
|
75
|
+
if ("title" in d) return `<title>${escHtml(String((d as { title: string }).title))}</title>`;
|
|
76
|
+
if ("name" in d) {
|
|
77
|
+
const { name, content } = d as { name: string; content: string };
|
|
78
|
+
return `<meta name="${escHtml(name)}" content="${escHtml(content)}">`;
|
|
79
|
+
}
|
|
80
|
+
if ("property" in d) {
|
|
81
|
+
const { property, content } = d as { property: string; content: string };
|
|
82
|
+
return `<meta property="${escHtml(property)}" content="${escHtml(content)}">`;
|
|
83
|
+
}
|
|
84
|
+
return "";
|
|
85
|
+
})
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function escHtml(s: string): string {
|
|
91
|
+
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
92
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface MiddlewareContext {
|
|
4
|
+
request: Request;
|
|
5
|
+
params: Record<string, string>;
|
|
6
|
+
context: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type MiddlewareFn = (
|
|
10
|
+
ctx: MiddlewareContext,
|
|
11
|
+
next: () => Promise<Response>,
|
|
12
|
+
) => Promise<Response>;
|
|
13
|
+
|
|
14
|
+
// ── Pipeline ───────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export class MiddlewarePipeline {
|
|
17
|
+
private fns: MiddlewareFn[] = [];
|
|
18
|
+
|
|
19
|
+
/** Register a middleware function. Returns `this` for chaining. */
|
|
20
|
+
use(fn: MiddlewareFn): this {
|
|
21
|
+
this.fns.push(fn);
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Compose all registered middleware into a single chain and execute it.
|
|
27
|
+
* Each fn calls `next()` to invoke the next fn; the last `next()` calls `handler`.
|
|
28
|
+
*/
|
|
29
|
+
run(
|
|
30
|
+
ctx: MiddlewareContext,
|
|
31
|
+
handler: () => Promise<Response>,
|
|
32
|
+
): Promise<Response> {
|
|
33
|
+
let index = 0;
|
|
34
|
+
const fns = this.fns;
|
|
35
|
+
|
|
36
|
+
const dispatch = (): Promise<Response> => {
|
|
37
|
+
if (index >= fns.length) return handler();
|
|
38
|
+
const fn = fns[index++];
|
|
39
|
+
return fn(ctx, dispatch);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return dispatch();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Module-level default pipeline — attach middleware here via pipeline.use(). */
|
|
47
|
+
export const pipeline = new MiddlewarePipeline();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { renderToReadableStream } from "react-dom/server";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { MetaDescriptor } from "../shared/route-types.ts";
|
|
4
|
+
import { safeStringify, isDev } from "./env.ts";
|
|
5
|
+
import { errorOverlayScript } from "../dev/error-overlay.ts";
|
|
6
|
+
import { mergeMeta, renderMetaTags } from "./meta.ts";
|
|
7
|
+
|
|
8
|
+
export interface ServerManifest {
|
|
9
|
+
clientEntry: string;
|
|
10
|
+
rootChunk?: string;
|
|
11
|
+
routes: Record<string, { file: string; chunk?: string; imports?: string[] }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RenderOptions {
|
|
15
|
+
shell: ReactNode;
|
|
16
|
+
loaderData: Record<string, unknown>;
|
|
17
|
+
actionData: unknown;
|
|
18
|
+
params: Record<string, string>;
|
|
19
|
+
pathname: string;
|
|
20
|
+
manifest: ServerManifest;
|
|
21
|
+
meta: MetaDescriptor[];
|
|
22
|
+
status?: number;
|
|
23
|
+
/** Path of the matched route file (e.g. "routes/_index.tsx"), used by the client to pre-import the module before hydration. */
|
|
24
|
+
routeFile?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function renderRoute(options: RenderOptions): Promise<Response> {
|
|
28
|
+
const {
|
|
29
|
+
shell,
|
|
30
|
+
loaderData,
|
|
31
|
+
actionData,
|
|
32
|
+
params,
|
|
33
|
+
pathname,
|
|
34
|
+
manifest,
|
|
35
|
+
status = 200,
|
|
36
|
+
} = options;
|
|
37
|
+
|
|
38
|
+
const devOverlay = isDev() ? errorOverlayScript + "\n" : "";
|
|
39
|
+
const metaHtml = renderMetaTags(mergeMeta(options.meta ?? []));
|
|
40
|
+
// Include manifest + routeFile so the client can pre-import the route module
|
|
41
|
+
// before hydrateRoot(), preventing the SSR/client tree mismatch.
|
|
42
|
+
const bootstrapScriptContent =
|
|
43
|
+
devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, manifest, routeFile: options.routeFile, meta: metaHtml })};`;
|
|
44
|
+
|
|
45
|
+
let renderError: unknown;
|
|
46
|
+
|
|
47
|
+
const stream = await renderToReadableStream(shell, {
|
|
48
|
+
bootstrapScriptContent,
|
|
49
|
+
bootstrapModules: [manifest.clientEntry],
|
|
50
|
+
onError(error) {
|
|
51
|
+
renderError = error;
|
|
52
|
+
console.error("[bract] renderToReadableStream error:", error);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const responseStatus = renderError ? 500 : status;
|
|
57
|
+
|
|
58
|
+
return new Response(stream, {
|
|
59
|
+
status: responseStatus,
|
|
60
|
+
headers: {
|
|
61
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
62
|
+
"Transfer-Encoding": "chunked",
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { createElement } from "react";
|
|
3
|
+
import type { TrieNode } from "./matcher.ts";
|
|
4
|
+
import { matchRoute } from "./matcher.ts";
|
|
5
|
+
import { resolveRouteChain } from "./layout.ts";
|
|
6
|
+
import { runLoaders, runAction, buildLoaderArgs } from "./loader.ts";
|
|
7
|
+
import { renderRoute, type ServerManifest } from "./render.ts";
|
|
8
|
+
import { resolveMeta } from "./meta.ts";
|
|
9
|
+
import { json, error } from "./response.ts";
|
|
10
|
+
import { isRedirect } from "../shared/errors.ts";
|
|
11
|
+
import { pipeline, type MiddlewareContext } from "./middleware.ts";
|
|
12
|
+
import { BractJSProvider } from "../shared/context.ts";
|
|
13
|
+
|
|
14
|
+
export interface HandlerConfig {
|
|
15
|
+
appDir: string;
|
|
16
|
+
publicDir: string;
|
|
17
|
+
manifest: ServerManifest;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MUTATING_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
|
|
21
|
+
|
|
22
|
+
export async function handleRequest(
|
|
23
|
+
request: Request,
|
|
24
|
+
trie: TrieNode,
|
|
25
|
+
config: HandlerConfig
|
|
26
|
+
): Promise<Response> {
|
|
27
|
+
const ctx: MiddlewareContext = {
|
|
28
|
+
request,
|
|
29
|
+
params: {},
|
|
30
|
+
context: {},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return pipeline.run(ctx, () => route(request, trie, config, ctx.context));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function route(
|
|
37
|
+
request: Request,
|
|
38
|
+
trie: TrieNode,
|
|
39
|
+
config: HandlerConfig,
|
|
40
|
+
context: Record<string, unknown>,
|
|
41
|
+
): Promise<Response> {
|
|
42
|
+
const { appDir, publicDir, manifest } = config;
|
|
43
|
+
const url = new URL(request.url);
|
|
44
|
+
const { pathname, searchParams } = url;
|
|
45
|
+
|
|
46
|
+
// ── Static public assets ──────────────────────────────────────────────
|
|
47
|
+
if (pathname.startsWith("/public/")) {
|
|
48
|
+
const file = Bun.file(join(publicDir, pathname.slice("/public/".length)));
|
|
49
|
+
if (await file.exists()) return new Response(file);
|
|
50
|
+
return error("Not Found", 404);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── /_data soft-nav JSON endpoint ─────────────────────────────────────
|
|
54
|
+
if (pathname.startsWith("/_data")) {
|
|
55
|
+
const targetPath = searchParams.get("path") ?? "/";
|
|
56
|
+
const match = matchRoute(targetPath, trie);
|
|
57
|
+
if (!match) return json({ error: "Not Found" }, { status: 404 });
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const chain = await resolveRouteChain(match.routeFile, appDir);
|
|
61
|
+
const args = buildLoaderArgs(request, match.params, {});
|
|
62
|
+
const results = await runLoaders(chain, args);
|
|
63
|
+
return json({ root: results.root, layouts: results.layouts, route: results.route, params: match.params });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error("[bractjs] /_data error:", err);
|
|
66
|
+
return json({ error: "Internal Server Error" }, { status: 500 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Route matching ────────────────────────────────────────────────────
|
|
71
|
+
const match = matchRoute(pathname, trie);
|
|
72
|
+
if (!match) return error("Not Found", 404);
|
|
73
|
+
|
|
74
|
+
const chain = await resolveRouteChain(match.routeFile, appDir);
|
|
75
|
+
const args = buildLoaderArgs(request, match.params, context);
|
|
76
|
+
|
|
77
|
+
// ── Action (mutating methods) ─────────────────────────────────────────
|
|
78
|
+
let actionData: unknown = null;
|
|
79
|
+
if (MUTATING_METHODS.has(request.method)) {
|
|
80
|
+
try {
|
|
81
|
+
const formData = await request.formData();
|
|
82
|
+
actionData = await runAction(chain.route, { ...args, formData });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (isRedirect(err)) return err as Response;
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Client-side Form submits with this header — return JSON, not HTML.
|
|
89
|
+
if (request.headers.get("X-BractJS-Action")) {
|
|
90
|
+
return json(actionData ?? null);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Loaders ───────────────────────────────────────────────────────────
|
|
95
|
+
let loaderResults;
|
|
96
|
+
try {
|
|
97
|
+
loaderResults = await runLoaders(chain, args);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (isRedirect(err)) return err as Response;
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const loaderData = {
|
|
104
|
+
root: loaderResults.root,
|
|
105
|
+
layouts: loaderResults.layouts,
|
|
106
|
+
route: loaderResults.route,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// ── SSR render ────────────────────────────────────────────────────────
|
|
110
|
+
const RootComponent = chain.root.default ?? (() => null);
|
|
111
|
+
const RouteComponent = chain.route.default;
|
|
112
|
+
|
|
113
|
+
// Wrap root in BractJSProvider so <Outlet> can render the route component
|
|
114
|
+
// server-side without needing a ClientRouter.
|
|
115
|
+
const shell = createElement(
|
|
116
|
+
BractJSProvider,
|
|
117
|
+
{
|
|
118
|
+
value: {
|
|
119
|
+
loaderData: loaderData as Record<string, unknown>,
|
|
120
|
+
actionData,
|
|
121
|
+
params: match.params,
|
|
122
|
+
pathname,
|
|
123
|
+
manifest: manifest as unknown as import("../shared/context.ts").RouteManifest,
|
|
124
|
+
RouteComponent,
|
|
125
|
+
},
|
|
126
|
+
children: createElement(RootComponent),
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const meta = resolveMeta(chain, loaderResults, match.params);
|
|
131
|
+
|
|
132
|
+
return renderRoute({
|
|
133
|
+
shell,
|
|
134
|
+
loaderData,
|
|
135
|
+
actionData,
|
|
136
|
+
params: match.params,
|
|
137
|
+
pathname,
|
|
138
|
+
manifest,
|
|
139
|
+
meta,
|
|
140
|
+
routeFile: match.routeFile.filePath,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function redirect(url: string, status: number = 302): Response {
|
|
2
|
+
return new Response(null, {
|
|
3
|
+
status,
|
|
4
|
+
headers: { Location: url },
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function json<T>(data: T, init?: ResponseInit): Response {
|
|
9
|
+
const body = JSON.stringify(data);
|
|
10
|
+
const headers = new Headers(init?.headers);
|
|
11
|
+
headers.set("Content-Type", "application/json; charset=utf-8");
|
|
12
|
+
return new Response(body, { ...init, headers });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function error(message: string, status: number = 500): Response {
|
|
16
|
+
return json({ error: message }, { status });
|
|
17
|
+
}
|