@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,49 @@
|
|
|
1
|
+
import { hydrateRoot } from "react-dom/client";
|
|
2
|
+
import { type ReactElement, type ComponentType } from "react";
|
|
3
|
+
import { ClientRouter } from "./ClientRouter.tsx";
|
|
4
|
+
import { Outlet } from "./components/Outlet.tsx";
|
|
5
|
+
import { matchPatternForPath } from "./nav-utils.ts";
|
|
6
|
+
import type { BractJSClientData } from "./types.ts";
|
|
7
|
+
import type { RouteModuleClient } from "./router.tsx";
|
|
8
|
+
|
|
9
|
+
// ── Fallback App shell (used when rootChunk is missing) ────────────────────
|
|
10
|
+
|
|
11
|
+
function FallbackApp(): ReactElement {
|
|
12
|
+
return <Outlet />;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── Hydration ──────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
// Wrapped in async IIFE so we can await module imports before hydrateRoot().
|
|
18
|
+
// This prevents the SSR/client tree mismatch (server renders full root + route
|
|
19
|
+
// component, client must start with the same tree shape).
|
|
20
|
+
(async () => {
|
|
21
|
+
const data: BractJSClientData = window.__BRACTJS_DATA__;
|
|
22
|
+
|
|
23
|
+
// 1. Import the root component (app/root.tsx) so the client tree matches
|
|
24
|
+
// the server-rendered shell (html, head, body, header, nav, etc.).
|
|
25
|
+
let RootComponent: ComponentType = FallbackApp;
|
|
26
|
+
if (data.manifest.rootChunk) {
|
|
27
|
+
const rootMod = await import(data.manifest.rootChunk);
|
|
28
|
+
if (rootMod.default) RootComponent = rootMod.default;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 2. Pre-load the current route module so <Outlet> sees it during hydration.
|
|
32
|
+
let initialModule: RouteModuleClient | null = null;
|
|
33
|
+
const pattern = matchPatternForPath(data.pathname, data.manifest);
|
|
34
|
+
const chunkUrl = pattern !== null ? data.manifest.routes[pattern]?.chunk : undefined;
|
|
35
|
+
|
|
36
|
+
if (chunkUrl) {
|
|
37
|
+
initialModule = (await import(chunkUrl)) as RouteModuleClient;
|
|
38
|
+
} else if (data.routeFile) {
|
|
39
|
+
const url = `/_hmr/module?file=${encodeURIComponent(data.routeFile)}&t=0`;
|
|
40
|
+
initialModule = (await import(url)) as RouteModuleClient;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
hydrateRoot(
|
|
44
|
+
document,
|
|
45
|
+
<ClientRouter initialData={data} initialModule={initialModule}>
|
|
46
|
+
<RootComponent />
|
|
47
|
+
</ClientRouter>,
|
|
48
|
+
);
|
|
49
|
+
})();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches fresh loader data for a pathname and updates the router context.
|
|
3
|
+
*/
|
|
4
|
+
export async function reloadLoaders(
|
|
5
|
+
pathname: string,
|
|
6
|
+
setLoaderData: (data: Record<string, unknown>) => void,
|
|
7
|
+
): Promise<void> {
|
|
8
|
+
const res = await fetch(`/_data?path=${encodeURIComponent(pathname)}`);
|
|
9
|
+
if (!res.ok) return;
|
|
10
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
11
|
+
setLoaderData(data);
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useContext } from "react";
|
|
2
|
+
import { RouterContext } from "../router.tsx";
|
|
3
|
+
import { BractJSContext } from "../../shared/context.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns the current route's action data, typed as T | null.
|
|
7
|
+
* Works in both SSR and client contexts.
|
|
8
|
+
*/
|
|
9
|
+
export function useActionData<T = unknown>(): T | null {
|
|
10
|
+
const router = useContext(RouterContext);
|
|
11
|
+
const bract = useContext(BractJSContext);
|
|
12
|
+
const actionData = router?.actionData ?? bract?.actionData ?? null;
|
|
13
|
+
return actionData as T | null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
type FetcherState = "idle" | "loading" | "submitting";
|
|
6
|
+
|
|
7
|
+
interface SubmitOptions {
|
|
8
|
+
method: string;
|
|
9
|
+
body: FormData | Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface FetcherResult {
|
|
13
|
+
data: unknown;
|
|
14
|
+
state: FetcherState;
|
|
15
|
+
load(path: string): Promise<void>;
|
|
16
|
+
submit(path: string, opts: SubmitOptions): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── Hook ───────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export function useFetcher(): FetcherResult {
|
|
22
|
+
const [data, setData] = useState<unknown>(undefined);
|
|
23
|
+
const [state, setState] = useState<FetcherState>("idle");
|
|
24
|
+
|
|
25
|
+
async function load(path: string): Promise<void> {
|
|
26
|
+
setState("loading");
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(`/_data?path=${encodeURIComponent(path)}`);
|
|
29
|
+
const json = (await res.json()) as { route?: unknown };
|
|
30
|
+
setData(json.route);
|
|
31
|
+
} finally {
|
|
32
|
+
setState("idle");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function submit(path: string, opts: SubmitOptions): Promise<void> {
|
|
37
|
+
setState("submitting");
|
|
38
|
+
try {
|
|
39
|
+
const body =
|
|
40
|
+
opts.body instanceof FormData
|
|
41
|
+
? opts.body
|
|
42
|
+
: new URLSearchParams(opts.body as Record<string, string>);
|
|
43
|
+
const res = await fetch(path, { method: opts.method, body });
|
|
44
|
+
setData(await res.json());
|
|
45
|
+
} finally {
|
|
46
|
+
setState("idle");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { data, state, load, submit };
|
|
51
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useContext } from "react";
|
|
2
|
+
import { RouterContext } from "../router.tsx";
|
|
3
|
+
import { BractJSContext } from "../../shared/context.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns the current route's loader data, typed as T.
|
|
7
|
+
* Works in both SSR and client contexts.
|
|
8
|
+
*/
|
|
9
|
+
export function useLoaderData<T = unknown>(): T {
|
|
10
|
+
const router = useContext(RouterContext);
|
|
11
|
+
const bract = useContext(BractJSContext);
|
|
12
|
+
const loaderData = router?.loaderData ?? bract?.loaderData ?? {};
|
|
13
|
+
return loaderData.route as T;
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useContext } from "react";
|
|
2
|
+
import { NavigationContext } from "../router.tsx";
|
|
3
|
+
import type { NavigationState } from "../router.tsx";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns the current navigation state.
|
|
7
|
+
* Returns 'idle' during SSR (no NavigationContext present).
|
|
8
|
+
*/
|
|
9
|
+
export function useNavigation(): { state: NavigationState } {
|
|
10
|
+
const ctx = useContext(NavigationContext);
|
|
11
|
+
return { state: ctx?.state ?? "idle" };
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useContext } from "react";
|
|
2
|
+
import { RouterContext } from "../router.tsx";
|
|
3
|
+
import { BractJSContext } from "../../shared/context.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns the current route's URL params (e.g. { id: "42" }).
|
|
7
|
+
* Pass a RouteParams<T> generic for typed params: useParams<RouteParams<"/blog/:id">>()
|
|
8
|
+
* Works in both SSR and client contexts.
|
|
9
|
+
*/
|
|
10
|
+
export function useParams<T extends Record<string, string> = Record<string, string>>(): T {
|
|
11
|
+
const router = useContext(RouterContext);
|
|
12
|
+
const bract = useContext(BractJSContext);
|
|
13
|
+
return (router?.params ?? bract?.params ?? {}) as T;
|
|
14
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ServerManifest } from "../server/render.ts";
|
|
2
|
+
|
|
3
|
+
// ── Pattern Matching ───────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests whether a pathname matches a manifest route pattern.
|
|
7
|
+
* Pattern segments: "static", "[param]", "[...catchAll]"
|
|
8
|
+
*/
|
|
9
|
+
function patternMatches(pathname: string, pattern: string): boolean {
|
|
10
|
+
const pathSegs = pathname.replace(/^\//, "").split("/").filter(Boolean);
|
|
11
|
+
const patSegs = pattern === "" ? [] : pattern.split("/");
|
|
12
|
+
let p = 0;
|
|
13
|
+
for (const seg of patSegs) {
|
|
14
|
+
if (seg.startsWith("[...") && seg.endsWith("]")) return true; // catch-all: rest matches
|
|
15
|
+
if (p >= pathSegs.length) return false;
|
|
16
|
+
if (!(seg.startsWith("[") && seg.endsWith("]"))) {
|
|
17
|
+
if (seg !== pathSegs[p]) return false; // static: must be exact
|
|
18
|
+
}
|
|
19
|
+
p++; // param or matching static: consume one segment
|
|
20
|
+
}
|
|
21
|
+
return p === pathSegs.length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Export ─────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/** Returns the manifest pattern key that matches pathname, or null. */
|
|
27
|
+
export function matchPatternForPath(
|
|
28
|
+
pathname: string,
|
|
29
|
+
manifest: ServerManifest,
|
|
30
|
+
): string | null {
|
|
31
|
+
for (const pattern of Object.keys(manifest.routes)) {
|
|
32
|
+
if (patternMatches(pathname, pattern)) return pattern;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ServerManifest } from "../server/render.ts";
|
|
2
|
+
import { matchPatternForPath } from "./nav-utils.ts";
|
|
3
|
+
|
|
4
|
+
// ── Cache ──────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const prefetched = new Set<string>();
|
|
7
|
+
|
|
8
|
+
// ── Implementation ─────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Prefetches both the route chunk and loader data for the given path.
|
|
12
|
+
* De-duplicates via a module-level Set — safe to call on every hover.
|
|
13
|
+
*/
|
|
14
|
+
export function prefetchRoute(path: string, manifest: ServerManifest): void {
|
|
15
|
+
if (prefetched.has(path)) return;
|
|
16
|
+
prefetched.add(path);
|
|
17
|
+
|
|
18
|
+
// Prefetch route chunk via <link rel="modulepreload">
|
|
19
|
+
const pattern = matchPatternForPath(path, manifest);
|
|
20
|
+
const chunk = pattern !== null ? manifest.routes[pattern]?.chunk : undefined;
|
|
21
|
+
if (chunk) {
|
|
22
|
+
const link = document.createElement("link");
|
|
23
|
+
link.rel = "modulepreload";
|
|
24
|
+
link.href = chunk;
|
|
25
|
+
document.head.appendChild(link);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Prefetch loader data at low priority
|
|
29
|
+
void fetch(`/_data?path=${encodeURIComponent(path)}`, {
|
|
30
|
+
priority: "low",
|
|
31
|
+
} as RequestInit);
|
|
32
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { lazy, type LazyExoticComponent, type ComponentType } from "react";
|
|
2
|
+
|
|
3
|
+
// ── Route Cache ────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
// Keyed by chunkUrl so React.lazy is only created once per chunk.
|
|
6
|
+
const routeCache = new Map<string, LazyExoticComponent<ComponentType>>();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Returns a React.lazy component for the given chunk URL.
|
|
10
|
+
* Caches by chunkUrl so re-renders don't create new lazy references.
|
|
11
|
+
*/
|
|
12
|
+
export function getLazyRoute(chunkUrl: string): LazyExoticComponent<ComponentType> {
|
|
13
|
+
if (!routeCache.has(chunkUrl)) {
|
|
14
|
+
routeCache.set(
|
|
15
|
+
chunkUrl,
|
|
16
|
+
lazy(() => import(chunkUrl) as Promise<{ default: ComponentType }>),
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return routeCache.get(chunkUrl)!;
|
|
20
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createContext, useContext, type ComponentType } from "react";
|
|
2
|
+
import type { ServerManifest } from "../server/render.ts";
|
|
3
|
+
|
|
4
|
+
// ── Route module shape visible on the client ───────────────────────────────
|
|
5
|
+
|
|
6
|
+
export interface RouteModuleClient {
|
|
7
|
+
default?: ComponentType;
|
|
8
|
+
ErrorBoundary?: ComponentType<{ error: Error }>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ── Router Context ─────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface RouteState {
|
|
14
|
+
loaderData: Record<string, unknown>;
|
|
15
|
+
actionData: unknown;
|
|
16
|
+
params: Record<string, string>;
|
|
17
|
+
pathname: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RouterContextValue extends RouteState {
|
|
21
|
+
manifest: ServerManifest;
|
|
22
|
+
currentModule: RouteModuleClient | null;
|
|
23
|
+
setRoute(state: Partial<RouteState>): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const RouterContext = createContext<RouterContextValue>(null!);
|
|
27
|
+
|
|
28
|
+
export function useRouterContext(): RouterContextValue {
|
|
29
|
+
const ctx = useContext(RouterContext);
|
|
30
|
+
if (ctx === null) {
|
|
31
|
+
throw new Error("useRouterContext must be used within a ClientRouter");
|
|
32
|
+
}
|
|
33
|
+
return ctx;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Navigation Context ─────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export type NavigationState = "idle" | "loading" | "submitting";
|
|
39
|
+
|
|
40
|
+
export interface NavigationContextValue {
|
|
41
|
+
state: NavigationState;
|
|
42
|
+
navigate(to: string): Promise<void>;
|
|
43
|
+
submit(to: string, options: { method: string; body: FormData | Record<string, string> }): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const NavigationContext = createContext<NavigationContextValue>(null!);
|
|
47
|
+
|
|
48
|
+
export function useNavigationContext(): NavigationContextValue {
|
|
49
|
+
const ctx = useContext(NavigationContext);
|
|
50
|
+
if (ctx === null) {
|
|
51
|
+
throw new Error("useNavigationContext must be used within a ClientRouter");
|
|
52
|
+
}
|
|
53
|
+
return ctx;
|
|
54
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ServerManifest } from "../server/render.ts";
|
|
2
|
+
|
|
3
|
+
// ── BractJSClientData ────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export interface BractJSClientData {
|
|
6
|
+
loaderData: Record<string, unknown>;
|
|
7
|
+
actionData: unknown;
|
|
8
|
+
params: Record<string, string>;
|
|
9
|
+
pathname: string;
|
|
10
|
+
manifest: ServerManifest;
|
|
11
|
+
/** Path of the matched route file, used to pre-import the module before hydration. */
|
|
12
|
+
routeFile?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── Window augmentation ────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
declare global {
|
|
18
|
+
interface Window {
|
|
19
|
+
__BRACTJS_DATA__: BractJSClientData;
|
|
20
|
+
/** Dev-only: registered by ClientRouter for module-level HMR swaps. */
|
|
21
|
+
__BRACTJS_HMR_ACCEPT__?: (pattern: string, mod: Record<string, unknown>) => void;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { scanRoutes } from "../server/scanner.ts";
|
|
3
|
+
import type { Segment } from "../server/scanner.ts";
|
|
4
|
+
|
|
5
|
+
// Convert [param] / [...catchAll] notation to :param colon-style for URLs.
|
|
6
|
+
function patternToColon(urlPattern: string): string {
|
|
7
|
+
if (urlPattern === "") return "/";
|
|
8
|
+
return "/" + urlPattern.split("/").map((seg) => {
|
|
9
|
+
if (seg.startsWith("[...") && seg.endsWith("]")) return ":" + seg.slice(4, -1);
|
|
10
|
+
if (seg.startsWith("[") && seg.endsWith("]")) return ":" + seg.slice(1, -1);
|
|
11
|
+
return seg;
|
|
12
|
+
}).join("/");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function paramsFromSegments(segments: Segment[]): string[] {
|
|
16
|
+
return segments.flatMap((seg) =>
|
|
17
|
+
typeof seg === "string" ? [] :
|
|
18
|
+
"param" in seg ? [seg.param] : [seg.catchAll],
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Replace each :paramName segment with ${params.paramName} for template literals.
|
|
23
|
+
function substituteParams(pattern: string, params: string[]): string {
|
|
24
|
+
const set = new Set(params);
|
|
25
|
+
return pattern.split("/").map((seg) =>
|
|
26
|
+
seg.startsWith(":") && set.has(seg.slice(1))
|
|
27
|
+
? "${params." + seg.slice(1) + "}"
|
|
28
|
+
: seg,
|
|
29
|
+
).join("/");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function builderEntry(pattern: string, params: string[]): string {
|
|
33
|
+
if (params.length === 0)
|
|
34
|
+
return " \"" + pattern + "\": () => \"" + pattern + "\" as const,";
|
|
35
|
+
const paramType = params.map((p) => p + ": string").join("; ");
|
|
36
|
+
const body = substituteParams(pattern, params);
|
|
37
|
+
return " \"" + pattern + "\": (params: { " + paramType + " }) => `" + body + "` as const,";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function paramsTypeLines(routes: Array<{ pattern: string; params: string[] }>): string {
|
|
41
|
+
const dynamic = routes.filter((r) => r.params.length > 0);
|
|
42
|
+
if (dynamic.length === 0) return "export type RouteParams<_T extends AppRoutes> = Record<never, never>;";
|
|
43
|
+
const branches = dynamic
|
|
44
|
+
.map((r) =>
|
|
45
|
+
" T extends \"" + r.pattern + "\" ? { "
|
|
46
|
+
+ r.params.map((p) => p + ": string").join("; ")
|
|
47
|
+
+ " } :",
|
|
48
|
+
)
|
|
49
|
+
.join("\n");
|
|
50
|
+
return "export type RouteParams<T extends AppRoutes> =\n" + branches + "\n Record<never, never>;";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Replaced at the top of the generated file.
|
|
54
|
+
const HEADER =
|
|
55
|
+
"// Generated by `bractjs codegen` — do not edit manually.\n" +
|
|
56
|
+
"// Re-run with: bunx bractjs codegen\n" +
|
|
57
|
+
"\n" +
|
|
58
|
+
"/* eslint-disable */\n";
|
|
59
|
+
|
|
60
|
+
export async function generateRouteTypes(appDir: string): Promise<string> {
|
|
61
|
+
const routeFiles = await scanRoutes(appDir);
|
|
62
|
+
const routes = routeFiles.map((r) => ({
|
|
63
|
+
pattern: patternToColon(r.urlPattern),
|
|
64
|
+
params: paramsFromSegments(r.segments),
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
const union = routes.length > 0
|
|
68
|
+
? routes.map((r) => " | \"" + r.pattern + "\"").join("\n")
|
|
69
|
+
: " never";
|
|
70
|
+
|
|
71
|
+
const builderEntries = routes.map((r) => builderEntry(r.pattern, r.params)).join("\n");
|
|
72
|
+
|
|
73
|
+
return [
|
|
74
|
+
HEADER,
|
|
75
|
+
"export type AppRoutes =",
|
|
76
|
+
union + ";",
|
|
77
|
+
"",
|
|
78
|
+
paramsTypeLines(routes),
|
|
79
|
+
"",
|
|
80
|
+
"export type TypedLoaderArgs<T extends AppRoutes> = {",
|
|
81
|
+
" request: Request;",
|
|
82
|
+
" params: RouteParams<T>;",
|
|
83
|
+
" context: Record<string, unknown>;",
|
|
84
|
+
"};",
|
|
85
|
+
"export type TypedActionArgs<T extends AppRoutes> =",
|
|
86
|
+
" TypedLoaderArgs<T> & { formData: FormData };",
|
|
87
|
+
"",
|
|
88
|
+
"export const routes = {",
|
|
89
|
+
builderEntries,
|
|
90
|
+
"} as const;",
|
|
91
|
+
"",
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function writeRouteTypes(appDir: string, outPath?: string): Promise<void> {
|
|
96
|
+
const dest = outPath ?? join(appDir, "route-types.gen.ts");
|
|
97
|
+
await Bun.write(dest, await generateRouteTypes(appDir));
|
|
98
|
+
console.log("[bract] codegen →", dest);
|
|
99
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-injected error overlay script.
|
|
3
|
+
* Listens for assignments to window.__BRACTJS_ERROR__ and renders a full-screen overlay.
|
|
4
|
+
*/
|
|
5
|
+
export const errorOverlayScript: string = `
|
|
6
|
+
(function () {
|
|
7
|
+
Object.defineProperty(window, '__BRACTJS_ERROR__', {
|
|
8
|
+
set: function (err) {
|
|
9
|
+
var existing = document.getElementById('__bractjs_overlay__');
|
|
10
|
+
if (existing) existing.remove();
|
|
11
|
+
var msg = err && err.message ? err.message : String(err);
|
|
12
|
+
var stack = err && err.stack ? err.stack : '';
|
|
13
|
+
var overlay = document.createElement('div');
|
|
14
|
+
overlay.id = '__bractjs_overlay__';
|
|
15
|
+
overlay.style.cssText = [
|
|
16
|
+
'position:fixed', 'inset:0', 'background:#1a1a1a', 'color:#fff',
|
|
17
|
+
'padding:2rem', 'font-family:monospace', 'font-size:14px',
|
|
18
|
+
'border:4px solid #e74c3c', 'z-index:99999', 'overflow:auto'
|
|
19
|
+
].join(';');
|
|
20
|
+
var h2 = document.createElement('h2');
|
|
21
|
+
h2.style.cssText = 'color:#e74c3c;margin:0 0 1rem';
|
|
22
|
+
h2.textContent = 'BractJS Error';
|
|
23
|
+
var pre = document.createElement('pre');
|
|
24
|
+
pre.style.cssText = 'white-space:pre-wrap';
|
|
25
|
+
pre.textContent = msg + (stack ? '\\n\\n' + stack : '');
|
|
26
|
+
overlay.appendChild(h2);
|
|
27
|
+
overlay.appendChild(pre);
|
|
28
|
+
document.body.appendChild(overlay);
|
|
29
|
+
},
|
|
30
|
+
configurable: true,
|
|
31
|
+
});
|
|
32
|
+
})();
|
|
33
|
+
`.trim();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side HMR client script, embedded as a string and injected by LiveReload.
|
|
3
|
+
*
|
|
4
|
+
* Message types:
|
|
5
|
+
* hmr:route — swap a single route module without full page reload
|
|
6
|
+
* hmr:reload — full page reload (root/layout/non-route file changed)
|
|
7
|
+
*
|
|
8
|
+
* Module swap flow:
|
|
9
|
+
* 1. Receive { type:"hmr:route", pattern, file }
|
|
10
|
+
* 2. Fetch /_hmr/module?file=<file>&t=<now> — server compiles it fresh
|
|
11
|
+
* 3. Call window.__BRACTJS_HMR_ACCEPT__(pattern, module)
|
|
12
|
+
* 4. ClientRouter swaps currentModule → React re-renders <Outlet>
|
|
13
|
+
*/
|
|
14
|
+
export const hmrClientScript: string = `
|
|
15
|
+
(function () {
|
|
16
|
+
function connect() {
|
|
17
|
+
var ws = new WebSocket("ws://localhost:3001");
|
|
18
|
+
ws.onmessage = function (event) {
|
|
19
|
+
try {
|
|
20
|
+
var msg = JSON.parse(event.data);
|
|
21
|
+
if (msg.type === "hmr:reload") {
|
|
22
|
+
location.reload();
|
|
23
|
+
} else if (msg.type === "hmr:route" && msg.pattern != null && msg.chunkUrl) {
|
|
24
|
+
// Cache-bust so the browser re-fetches the rebuilt chunk.
|
|
25
|
+
// The chunk was built with splitting, so it shares the same React
|
|
26
|
+
// instance as client.js — no dual-React issue.
|
|
27
|
+
var url = msg.chunkUrl + "?t=" + Date.now();
|
|
28
|
+
import(url).then(function (mod) {
|
|
29
|
+
var accept = window.__BRACTJS_HMR_ACCEPT__;
|
|
30
|
+
if (typeof accept === "function") {
|
|
31
|
+
accept(msg.pattern, mod);
|
|
32
|
+
} else {
|
|
33
|
+
location.reload();
|
|
34
|
+
}
|
|
35
|
+
}).catch(function () { location.reload(); });
|
|
36
|
+
}
|
|
37
|
+
} catch (_) {}
|
|
38
|
+
};
|
|
39
|
+
ws.onclose = function () { setTimeout(connect, 1000); };
|
|
40
|
+
}
|
|
41
|
+
connect();
|
|
42
|
+
})();
|
|
43
|
+
`.trim();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { resolve, join } from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dev-only HTTP handler for /_hmr/module?file=routes/about.tsx
|
|
5
|
+
*
|
|
6
|
+
* Compiles the requested route file on-demand (in-memory, no outdir) and
|
|
7
|
+
* returns it as ESM so the browser can dynamically import it for module swap.
|
|
8
|
+
*
|
|
9
|
+
* Security: rejects any path that resolves outside appDir.
|
|
10
|
+
*/
|
|
11
|
+
export async function handleHmrModuleRequest(
|
|
12
|
+
url: URL,
|
|
13
|
+
appDir: string,
|
|
14
|
+
): Promise<Response> {
|
|
15
|
+
const file = url.searchParams.get("file");
|
|
16
|
+
if (!file) {
|
|
17
|
+
return new Response("Missing file param", { status: 400 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Resolve and guard against path traversal
|
|
21
|
+
const rootDir = resolve(appDir);
|
|
22
|
+
const fullPath = resolve(join(rootDir, file));
|
|
23
|
+
if (!fullPath.startsWith(rootDir + "/") && fullPath !== rootDir) {
|
|
24
|
+
return new Response("Forbidden", { status: 403 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Build in-memory (no outdir → outputs held in memory, no disk write)
|
|
28
|
+
const result = await Bun.build({
|
|
29
|
+
entrypoints: [fullPath],
|
|
30
|
+
target: "browser",
|
|
31
|
+
minify: false,
|
|
32
|
+
sourcemap: "inline",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (!result.success || result.outputs.length === 0) {
|
|
36
|
+
const msgs = result.logs.map((l) => String(l)).join("\n");
|
|
37
|
+
return new Response(`Build failed:\n${msgs}`, { status: 500 });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const js = await result.outputs[0].text();
|
|
41
|
+
return new Response(js, {
|
|
42
|
+
headers: {
|
|
43
|
+
"Content-Type": "text/javascript",
|
|
44
|
+
"Cache-Control": "no-store",
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
|
|
3
|
+
// ── HMR Server ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
interface HmrMessage {
|
|
6
|
+
type: string;
|
|
7
|
+
file?: string;
|
|
8
|
+
duration?: number;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const clients = new Set<ServerWebSocket<unknown>>();
|
|
13
|
+
|
|
14
|
+
export function createHmrServer(port = 3001): {
|
|
15
|
+
broadcast(msg: HmrMessage): void;
|
|
16
|
+
stop(): void;
|
|
17
|
+
} {
|
|
18
|
+
const server = Bun.serve({
|
|
19
|
+
port,
|
|
20
|
+
fetch(req, srv) {
|
|
21
|
+
if (srv.upgrade(req)) return undefined;
|
|
22
|
+
return new Response("HMR WebSocket endpoint", { status: 426 });
|
|
23
|
+
},
|
|
24
|
+
websocket: {
|
|
25
|
+
open(ws) {
|
|
26
|
+
clients.add(ws);
|
|
27
|
+
},
|
|
28
|
+
close(ws) {
|
|
29
|
+
clients.delete(ws);
|
|
30
|
+
},
|
|
31
|
+
message() {
|
|
32
|
+
// clients don't send messages
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
console.log(`HMR server on ws://localhost:${port}`);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
broadcast(msg: HmrMessage) {
|
|
41
|
+
const payload = JSON.stringify(msg);
|
|
42
|
+
for (const ws of clients) {
|
|
43
|
+
ws.send(payload);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
stop() {
|
|
47
|
+
clients.clear();
|
|
48
|
+
server.stop(true);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|