@bractjs/bractjs 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/action-handler.test.ts +47 -0
- package/src/__tests__/action-registry.test.ts +73 -0
- package/src/__tests__/codegen.test.ts +50 -0
- package/src/__tests__/deferred.test.ts +96 -0
- package/src/__tests__/directives.test.ts +52 -0
- package/src/__tests__/env.test.ts +73 -0
- package/src/__tests__/errors.test.ts +113 -0
- package/src/__tests__/hash.test.ts +19 -0
- package/src/__tests__/integration.test.ts +1 -1
- package/src/__tests__/loader.test.ts +5 -2
- package/src/__tests__/manifest.test.ts +60 -0
- package/src/__tests__/middleware.test.ts +216 -0
- package/src/__tests__/response.test.ts +106 -0
- package/src/__tests__/security.test.ts +348 -0
- package/src/__tests__/session.test.ts +3 -3
- package/src/adapters/cloudflare.ts +65 -0
- package/src/build/bundler.ts +17 -6
- package/src/build/directives.ts +30 -3
- package/src/build/env-plugin.ts +8 -0
- package/src/build/hash.ts +0 -20
- package/src/build/plugins/css-modules.ts +110 -0
- package/src/client/ClientRouter.tsx +121 -13
- package/src/client/cache.ts +69 -0
- package/src/client/components/Link.tsx +16 -2
- package/src/client/components/LiveReload.tsx +4 -0
- package/src/client/hooks/useBlocker.ts +44 -0
- package/src/client/hooks/useFetcher.ts +66 -6
- package/src/client/hooks/useLocale.ts +12 -0
- package/src/client/hooks/useLocalizedLink.ts +18 -0
- package/src/client/hooks/useSearchParams.ts +74 -0
- package/src/client/rpc.ts +70 -0
- package/src/codegen/route-codegen.ts +96 -10
- package/src/dev/devtools.ts +144 -0
- package/src/dev/hmr-client.ts +14 -0
- package/src/dev/hmr-module-handler.ts +31 -5
- package/src/dev/hmr-server.ts +16 -0
- package/src/image/cache.ts +28 -8
- package/src/image/handler.ts +31 -13
- package/src/image/optimizer.ts +51 -14
- package/src/image/types.ts +1 -0
- package/src/index.ts +27 -0
- package/src/middleware/cors.ts +28 -8
- package/src/middleware/requestLogger.ts +4 -0
- package/src/server/action-handler.ts +45 -2
- package/src/server/action-registry.ts +14 -1
- package/src/server/adapter.ts +57 -0
- package/src/server/api-route.ts +127 -0
- package/src/server/context.ts +22 -0
- package/src/server/csrf.ts +17 -0
- package/src/server/env.ts +26 -4
- package/src/server/i18n.ts +63 -0
- package/src/server/loader.ts +61 -1
- package/src/server/middleware.ts +11 -7
- package/src/server/render.ts +14 -5
- package/src/server/request-handler.ts +77 -18
- package/src/server/response.ts +29 -5
- package/src/server/scanner.ts +6 -2
- package/src/server/serve.ts +102 -55
- package/src/server/session.ts +17 -5
- package/src/server/static.ts +31 -8
- package/src/server/stream-handler.ts +111 -0
- package/src/server/validate.ts +89 -0
- package/src/shared/route-types.ts +11 -0
- package/types/index.d.ts +94 -1
- package/types/route.d.ts +11 -0
|
@@ -8,6 +8,10 @@ import { hmrClientScript } from "../../dev/hmr-client.ts";
|
|
|
8
8
|
export function LiveReload(): ReactElement | null {
|
|
9
9
|
if (process.env.NODE_ENV === "production") return null;
|
|
10
10
|
|
|
11
|
+
// SECURITY(low): dangerouslySetInnerHTML is safe here — hmrClientScript is a
|
|
12
|
+
// build-time constant string with no user input. The NODE_ENV gate above
|
|
13
|
+
// ensures this is never rendered in production. If hmrClientScript ever
|
|
14
|
+
// accepts dynamic content, audit for XSS.
|
|
11
15
|
return (
|
|
12
16
|
<script
|
|
13
17
|
dangerouslySetInnerHTML={{ __html: hmrClientScript }}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Intercepts browser back/forward and <Link> clicks when `shouldBlock()` returns true.
|
|
5
|
+
* Shows a native confirm() dialog; the user must confirm to continue navigating.
|
|
6
|
+
*
|
|
7
|
+
* Note: The Link component calls NavigationContext.navigate(), which bypasses this
|
|
8
|
+
* hook's popstate listener. The hook also patches window.history.pushState so
|
|
9
|
+
* programmatic navigation (including <Link>) is also intercepted.
|
|
10
|
+
*/
|
|
11
|
+
export function useBlocker(shouldBlock: () => boolean): void {
|
|
12
|
+
// Keep a stable ref so listeners always call the latest version.
|
|
13
|
+
const shouldBlockRef = useRef(shouldBlock);
|
|
14
|
+
useEffect(() => { shouldBlockRef.current = shouldBlock; });
|
|
15
|
+
|
|
16
|
+
// Intercept popstate (browser back/forward).
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
function onPopState(e: PopStateEvent) {
|
|
19
|
+
if (!shouldBlockRef.current()) return;
|
|
20
|
+
// The browser already moved back — push the user back to the current
|
|
21
|
+
// page before asking, then confirm.
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
if (!window.confirm("Leave page? Changes you made may not be saved.")) {
|
|
24
|
+
// Re-push the current URL so the address bar doesn't change.
|
|
25
|
+
history.pushState(null, "", window.location.href);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
window.addEventListener("popstate", onPopState);
|
|
29
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
// Patch history.pushState so <Link> navigations (which call pushState) are
|
|
33
|
+
// intercepted. Restore on cleanup.
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const original = history.pushState.bind(history);
|
|
36
|
+
history.pushState = (state: unknown, title: string, url?: string | URL | null) => {
|
|
37
|
+
if (shouldBlockRef.current()) {
|
|
38
|
+
if (!window.confirm("Leave page? Changes you made may not be saved.")) return;
|
|
39
|
+
}
|
|
40
|
+
original(state, title, url);
|
|
41
|
+
};
|
|
42
|
+
return () => { history.pushState = original; };
|
|
43
|
+
}, []);
|
|
44
|
+
}
|
|
@@ -16,10 +16,70 @@ interface FetcherResult {
|
|
|
16
16
|
submit(path: string, opts: SubmitOptions): Promise<void>;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
interface StreamFetcherResult<T = unknown> {
|
|
20
|
+
events: AsyncGenerator<T>;
|
|
21
|
+
connect(actionId: string): AsyncGenerator<T>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── SSE async generator ────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
async function* sseStream<T>(actionId: string): AsyncGenerator<T> {
|
|
27
|
+
// Send X-BractJS-Action so the server's CSRF gate accepts this same-origin
|
|
28
|
+
// GET. Cross-origin <script>/<img>/<link rel=prefetch> tags cannot set this
|
|
29
|
+
// header, so the gate blocks CSRF invocations of server actions.
|
|
30
|
+
const res = await fetch(`/_stream?id=${encodeURIComponent(actionId)}`, {
|
|
31
|
+
headers: { "X-BractJS-Action": "1" },
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok || !res.body) {
|
|
34
|
+
throw new Error(`[bractjs] /_stream ${res.status}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const reader = res.body.getReader();
|
|
38
|
+
const decoder = new TextDecoder();
|
|
39
|
+
let buf = "";
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
while (true) {
|
|
43
|
+
const { done, value } = await reader.read();
|
|
44
|
+
if (done) break;
|
|
45
|
+
buf += decoder.decode(value, { stream: true });
|
|
46
|
+
const parts = buf.split("\n\n");
|
|
47
|
+
buf = parts.pop() ?? "";
|
|
48
|
+
for (const part of parts) {
|
|
49
|
+
const lines = part.trim().split("\n");
|
|
50
|
+
let event = "data";
|
|
51
|
+
let data = "";
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
if (line.startsWith("event: ")) event = line.slice(7).trim();
|
|
54
|
+
else if (line.startsWith("data: ")) data = line.slice(6);
|
|
55
|
+
}
|
|
56
|
+
if (event === "done") return;
|
|
57
|
+
if (event === "error") throw new Error((JSON.parse(data) as { message: string }).message);
|
|
58
|
+
if (event === "data" && data) yield JSON.parse(data) as T;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
reader.releaseLock();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
19
66
|
// ── Hook ───────────────────────────────────────────────────────────────────
|
|
20
67
|
|
|
21
|
-
export function useFetcher(): FetcherResult
|
|
68
|
+
export function useFetcher(): FetcherResult;
|
|
69
|
+
export function useFetcher<T>(opts: { stream: true }): StreamFetcherResult<T>;
|
|
70
|
+
export function useFetcher<T>(opts?: { stream?: boolean }): FetcherResult | StreamFetcherResult<T> {
|
|
71
|
+
if (opts?.stream) {
|
|
72
|
+
return {
|
|
73
|
+
events: (null as unknown) as AsyncGenerator<T>,
|
|
74
|
+
connect(actionId: string): AsyncGenerator<T> {
|
|
75
|
+
return sseStream<T>(actionId);
|
|
76
|
+
},
|
|
77
|
+
} satisfies StreamFetcherResult<T>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
22
81
|
const [data, setData] = useState<unknown>(undefined);
|
|
82
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
23
83
|
const [state, setState] = useState<FetcherState>("idle");
|
|
24
84
|
|
|
25
85
|
async function load(path: string): Promise<void> {
|
|
@@ -33,14 +93,14 @@ export function useFetcher(): FetcherResult {
|
|
|
33
93
|
}
|
|
34
94
|
}
|
|
35
95
|
|
|
36
|
-
async function submit(path: string,
|
|
96
|
+
async function submit(path: string, submitOpts: SubmitOptions): Promise<void> {
|
|
37
97
|
setState("submitting");
|
|
38
98
|
try {
|
|
39
99
|
const body =
|
|
40
|
-
|
|
41
|
-
?
|
|
42
|
-
: new URLSearchParams(
|
|
43
|
-
const res = await fetch(path, { method:
|
|
100
|
+
submitOpts.body instanceof FormData
|
|
101
|
+
? submitOpts.body
|
|
102
|
+
: new URLSearchParams(submitOpts.body as Record<string, string>);
|
|
103
|
+
const res = await fetch(path, { method: submitOpts.method, body });
|
|
44
104
|
setData(await res.json());
|
|
45
105
|
} finally {
|
|
46
106
|
setState("idle");
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useParams } from "./useParams.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns the current locale from URL params.
|
|
5
|
+
* Works when the router is configured with i18n prefix routes (`/:locale/...`).
|
|
6
|
+
*
|
|
7
|
+
* Falls back to `defaultLocale` when no locale param is present (e.g. SSR without locale prefix).
|
|
8
|
+
*/
|
|
9
|
+
export function useLocale(defaultLocale = "en"): string {
|
|
10
|
+
const params = useParams<{ locale?: string }>();
|
|
11
|
+
return params.locale ?? defaultLocale;
|
|
12
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useLocale } from "./useLocale.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns a helper that prepends the current locale to a path.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const localizedTo = useLocalizedLink();
|
|
8
|
+
* <Link to={localizedTo('/about')} /> // → /en/about
|
|
9
|
+
*/
|
|
10
|
+
export function useLocalizedLink(defaultLocale = "en"): (path: string) => string {
|
|
11
|
+
const locale = useLocale(defaultLocale);
|
|
12
|
+
return (path: string) => {
|
|
13
|
+
// Don't double-prefix if the path already starts with /<locale>.
|
|
14
|
+
const alreadyPrefixed = path.startsWith(`/${locale}/`) || path === `/${locale}`;
|
|
15
|
+
if (alreadyPrefixed) return path;
|
|
16
|
+
return `/${locale}${path.startsWith("/") ? path : "/" + path}`;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect, useRef, startTransition } from "react";
|
|
2
|
+
import { NavigationContext } from "../router.tsx";
|
|
3
|
+
import { useContext } from "react";
|
|
4
|
+
|
|
5
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
type SetSearchParams = (
|
|
8
|
+
updater: Record<string, string> | ((prev: URLSearchParams) => URLSearchParams),
|
|
9
|
+
) => void;
|
|
10
|
+
|
|
11
|
+
export interface SearchParamsResult<T extends Record<string, string>> {
|
|
12
|
+
searchParams: URLSearchParams;
|
|
13
|
+
getParam<K extends keyof T & string>(key: K): T[K] | null;
|
|
14
|
+
setSearchParams: SetSearchParams;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Hook ───────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reads and writes URL search params, typed per-route via generic T.
|
|
21
|
+
* Triggers a loader re-run (soft-nav fetch) when params change.
|
|
22
|
+
*
|
|
23
|
+
* T is the route's SearchParams shape (e.g. { page: string; sort: string }).
|
|
24
|
+
* This hook is SSR-safe: on the server window is absent, so it returns empty params.
|
|
25
|
+
*/
|
|
26
|
+
export function useSearchParams<T extends Record<string, string> = Record<string, string>>(): SearchParamsResult<T> {
|
|
27
|
+
const navCtx = useContext(NavigationContext);
|
|
28
|
+
|
|
29
|
+
function readCurrent(): URLSearchParams {
|
|
30
|
+
if (typeof window === "undefined") return new URLSearchParams();
|
|
31
|
+
return new URLSearchParams(window.location.search);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const [searchParams, setSearchParamsState] = useState<URLSearchParams>(readCurrent);
|
|
35
|
+
|
|
36
|
+
// Track whether we triggered the change ourselves to avoid double re-run.
|
|
37
|
+
const selfTriggerRef = useRef(false);
|
|
38
|
+
|
|
39
|
+
// Sync when the browser's history changes (back/forward, external pushState).
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
function onPopState() {
|
|
42
|
+
setSearchParamsState(new URLSearchParams(window.location.search));
|
|
43
|
+
}
|
|
44
|
+
window.addEventListener("popstate", onPopState);
|
|
45
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const setSearchParams: SetSearchParams = useCallback((updater) => {
|
|
49
|
+
const next =
|
|
50
|
+
typeof updater === "function"
|
|
51
|
+
? updater(new URLSearchParams(window.location.search))
|
|
52
|
+
: new URLSearchParams(updater);
|
|
53
|
+
|
|
54
|
+
const newSearch = next.toString();
|
|
55
|
+
const newUrl = window.location.pathname + (newSearch ? "?" + newSearch : "") + window.location.hash;
|
|
56
|
+
|
|
57
|
+
// Update browser URL without pushing a new history entry if only params changed.
|
|
58
|
+
history.pushState({}, "", newUrl);
|
|
59
|
+
selfTriggerRef.current = true;
|
|
60
|
+
startTransition(() => setSearchParamsState(next));
|
|
61
|
+
|
|
62
|
+
// Trigger a loader re-run via the NavigationContext navigate so the full
|
|
63
|
+
// soft-nav fetch path is exercised (meta update, module swap, etc.).
|
|
64
|
+
if (navCtx) {
|
|
65
|
+
void navCtx.navigate(window.location.pathname + (newSearch ? "?" + newSearch : ""));
|
|
66
|
+
}
|
|
67
|
+
}, [navCtx]);
|
|
68
|
+
|
|
69
|
+
const getParam = useCallback(<K extends keyof T & string>(key: K): T[K] | null => {
|
|
70
|
+
return (searchParams.get(key) as T[K] | null);
|
|
71
|
+
}, [searchParams]);
|
|
72
|
+
|
|
73
|
+
return { searchParams, getParam, setSearchParams };
|
|
74
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ── createClient ───────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a fully-typed fetch client for BractJS API routes.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import type { AppApiRoutes } from 'bractjs';
|
|
8
|
+
* const client = createClient<AppApiRoutes>();
|
|
9
|
+
* const users = await client['/api/users'].GET();
|
|
10
|
+
*
|
|
11
|
+
* The proxy builds the fetch URL from the property access chain and HTTP method,
|
|
12
|
+
* so `client['/api/users'].GET()` calls `GET /api/users`.
|
|
13
|
+
*
|
|
14
|
+
* This is intentionally minimal (no batching, no retries) — add wrapping as needed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
|
|
18
|
+
|
|
19
|
+
// Given a union of route definitions (method/path/input/output), extract
|
|
20
|
+
// the output type for a specific method + path pair.
|
|
21
|
+
type RouteOutput<
|
|
22
|
+
TRoutes extends { method: string; path: string; input: unknown; output: unknown },
|
|
23
|
+
TMethod extends string,
|
|
24
|
+
TPath extends string,
|
|
25
|
+
> = Extract<TRoutes, { method: TMethod; path: TPath }>["output"];
|
|
26
|
+
|
|
27
|
+
type RouteInput<
|
|
28
|
+
TRoutes extends { method: string; path: string; input: unknown; output: unknown },
|
|
29
|
+
TMethod extends string,
|
|
30
|
+
TPath extends string,
|
|
31
|
+
> = Extract<TRoutes, { method: TMethod; path: TPath }>["input"];
|
|
32
|
+
|
|
33
|
+
type ApiClient<TRoutes extends { method: string; path: string; input: unknown; output: unknown }> = {
|
|
34
|
+
[TPath in TRoutes["path"]]: {
|
|
35
|
+
[TMethod in Extract<TRoutes, { path: TPath }>["method"]]: (
|
|
36
|
+
input?: RouteInput<TRoutes, TMethod, TPath>,
|
|
37
|
+
) => Promise<UnwrapPromise<RouteOutput<TRoutes, TMethod, TPath>>>;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function createClient<
|
|
42
|
+
TRoutes extends { method: string; path: string; input: unknown; output: unknown },
|
|
43
|
+
>(baseUrl = ""): ApiClient<TRoutes> {
|
|
44
|
+
return new Proxy({} as ApiClient<TRoutes>, {
|
|
45
|
+
get(_target, path: string) {
|
|
46
|
+
return new Proxy({} as Record<string, unknown>, {
|
|
47
|
+
get(_t, method: string) {
|
|
48
|
+
return async (input?: unknown) => {
|
|
49
|
+
const httpMethod = method.toUpperCase();
|
|
50
|
+
const url = baseUrl + path;
|
|
51
|
+
const hasBody = httpMethod !== "GET" && httpMethod !== "DELETE" && input !== undefined;
|
|
52
|
+
const res = await fetch(url, {
|
|
53
|
+
method: httpMethod,
|
|
54
|
+
headers: hasBody ? { "Content-Type": "application/json" } : undefined,
|
|
55
|
+
body: hasBody ? JSON.stringify(input) : undefined,
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
59
|
+
throw Object.assign(new Error((err as { error?: string }).error ?? res.statusText), {
|
|
60
|
+
status: res.status,
|
|
61
|
+
response: res,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return res.json();
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -29,23 +29,44 @@ function substituteParams(pattern: string, params: string[]): string {
|
|
|
29
29
|
).join("/");
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// Allowed pattern: "/" + (segment | ":ident") (segments are filename-derived).
|
|
33
|
+
// Restricting upfront removes any chance that a hostile filename injects a
|
|
34
|
+
// backtick, ${ }, or quote into the generated TS source.
|
|
35
|
+
const SAFE_PATTERN_RE = /^\/(?:[A-Za-z0-9_\-]+|:[A-Za-z_][A-Za-z0-9_]*)(?:\/(?:[A-Za-z0-9_\-]+|:[A-Za-z_][A-Za-z0-9_]*))*$|^\/$/;
|
|
36
|
+
const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
37
|
+
|
|
38
|
+
function assertSafePattern(pattern: string): void {
|
|
39
|
+
if (!SAFE_PATTERN_RE.test(pattern)) {
|
|
40
|
+
throw new Error(`[bractjs] codegen: refusing to emit unsafe route pattern: ${JSON.stringify(pattern)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function assertSafeParam(name: string): void {
|
|
44
|
+
if (!SAFE_IDENT_RE.test(name)) {
|
|
45
|
+
throw new Error(`[bractjs] codegen: refusing to emit unsafe param name: ${JSON.stringify(name)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
function builderEntry(pattern: string, params: string[]): string {
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
assertSafePattern(pattern);
|
|
51
|
+
params.forEach(assertSafeParam);
|
|
52
|
+
const key = JSON.stringify(pattern);
|
|
53
|
+
if (params.length === 0) return " " + key + ": () => " + key + " as const,";
|
|
35
54
|
const paramType = params.map((p) => p + ": string").join("; ");
|
|
36
55
|
const body = substituteParams(pattern, params);
|
|
37
|
-
return "
|
|
56
|
+
return " " + key + ": (params: { " + paramType + " }) => `" + body + "` as const,";
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
function paramsTypeLines(routes: Array<{ pattern: string; params: string[] }>): string {
|
|
41
60
|
const dynamic = routes.filter((r) => r.params.length > 0);
|
|
42
61
|
if (dynamic.length === 0) return "export type RouteParams<_T extends AppRoutes> = Record<never, never>;";
|
|
43
62
|
const branches = dynamic
|
|
44
|
-
.map((r) =>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
+ "
|
|
48
|
-
|
|
63
|
+
.map((r) => {
|
|
64
|
+
assertSafePattern(r.pattern);
|
|
65
|
+
r.params.forEach(assertSafeParam);
|
|
66
|
+
return " T extends " + JSON.stringify(r.pattern) + " ? { "
|
|
67
|
+
+ r.params.map((p) => p + ": string").join("; ")
|
|
68
|
+
+ " } :";
|
|
69
|
+
})
|
|
49
70
|
.join("\n");
|
|
50
71
|
return "export type RouteParams<T extends AppRoutes> =\n" + branches + "\n Record<never, never>;";
|
|
51
72
|
}
|
|
@@ -57,6 +78,61 @@ const HEADER =
|
|
|
57
78
|
"\n" +
|
|
58
79
|
"/* eslint-disable */\n";
|
|
59
80
|
|
|
81
|
+
function searchParamsTypeLines(routes: Array<{ pattern: string }>): string {
|
|
82
|
+
// Route files may declare `export type SearchParams = { page: string }`.
|
|
83
|
+
// We emit a mapped type that falls back to Record<string,string> per route.
|
|
84
|
+
// Users augment via module augmentation or via their route file's export.
|
|
85
|
+
if (routes.length === 0) {
|
|
86
|
+
return "export type SearchParams<_T extends AppRoutes> = Record<string, string>;";
|
|
87
|
+
}
|
|
88
|
+
const branches = routes
|
|
89
|
+
.map((r) => {
|
|
90
|
+
assertSafePattern(r.pattern);
|
|
91
|
+
return " T extends " + JSON.stringify(r.pattern) + " ? RouteSearchParamsMap[" + JSON.stringify(r.pattern) + "] :";
|
|
92
|
+
})
|
|
93
|
+
.join("\n");
|
|
94
|
+
const mapEntries = routes
|
|
95
|
+
.map((r) => " " + JSON.stringify(r.pattern) + ": Record<string, string>;")
|
|
96
|
+
.join("\n");
|
|
97
|
+
return [
|
|
98
|
+
"// Augment RouteSearchParamsMap to type search params per route:",
|
|
99
|
+
"// declare module 'bractjs' { interface RouteSearchParamsMap { '/blog': { page: string } } }",
|
|
100
|
+
"export interface RouteSearchParamsMap {",
|
|
101
|
+
mapEntries,
|
|
102
|
+
"}",
|
|
103
|
+
"",
|
|
104
|
+
"export type SearchParams<T extends AppRoutes> =",
|
|
105
|
+
branches,
|
|
106
|
+
" Record<string, string>;",
|
|
107
|
+
].join("\n");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function contextTypeLines(routes: Array<{ pattern: string }>): string {
|
|
111
|
+
if (routes.length === 0) {
|
|
112
|
+
return "export type Context<_T extends AppRoutes> = Record<string, unknown>;";
|
|
113
|
+
}
|
|
114
|
+
const branches = routes
|
|
115
|
+
.map((r) => {
|
|
116
|
+
assertSafePattern(r.pattern);
|
|
117
|
+
return " T extends " + JSON.stringify(r.pattern) + " ? RouteContextMap[" + JSON.stringify(r.pattern) + "] :";
|
|
118
|
+
})
|
|
119
|
+
.join("\n");
|
|
120
|
+
const mapEntries = routes
|
|
121
|
+
.map((r) => " " + JSON.stringify(r.pattern) + ": Record<string, unknown>;")
|
|
122
|
+
.join("\n");
|
|
123
|
+
return [
|
|
124
|
+
"// Augment RouteContextMap to type context per route:",
|
|
125
|
+
"// declare module 'bractjs' { interface RouteContextMap { '/blog': { user: User } } }",
|
|
126
|
+
"export interface RouteContextMap {",
|
|
127
|
+
mapEntries,
|
|
128
|
+
"}",
|
|
129
|
+
"",
|
|
130
|
+
"export type Context<T extends AppRoutes> =",
|
|
131
|
+
branches,
|
|
132
|
+
" Record<string, unknown>;",
|
|
133
|
+
].join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
60
136
|
export async function generateRouteTypes(appDir: string): Promise<string> {
|
|
61
137
|
const routeFiles = await scanRoutes(appDir);
|
|
62
138
|
const routes = routeFiles.map((r) => ({
|
|
@@ -65,7 +141,10 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
|
|
|
65
141
|
}));
|
|
66
142
|
|
|
67
143
|
const union = routes.length > 0
|
|
68
|
-
? routes.map((r) =>
|
|
144
|
+
? routes.map((r) => {
|
|
145
|
+
assertSafePattern(r.pattern);
|
|
146
|
+
return " | " + JSON.stringify(r.pattern);
|
|
147
|
+
}).join("\n")
|
|
69
148
|
: " never";
|
|
70
149
|
|
|
71
150
|
const builderEntries = routes.map((r) => builderEntry(r.pattern, r.params)).join("\n");
|
|
@@ -77,14 +156,21 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
|
|
|
77
156
|
"",
|
|
78
157
|
paramsTypeLines(routes),
|
|
79
158
|
"",
|
|
159
|
+
searchParamsTypeLines(routes),
|
|
160
|
+
"",
|
|
161
|
+
contextTypeLines(routes),
|
|
162
|
+
"",
|
|
80
163
|
"export type TypedLoaderArgs<T extends AppRoutes> = {",
|
|
81
164
|
" request: Request;",
|
|
82
165
|
" params: RouteParams<T>;",
|
|
83
|
-
" context:
|
|
166
|
+
" context: Context<T>;",
|
|
84
167
|
"};",
|
|
85
168
|
"export type TypedActionArgs<T extends AppRoutes> =",
|
|
86
169
|
" TypedLoaderArgs<T> & { formData: FormData };",
|
|
87
170
|
"",
|
|
171
|
+
"/** A locale-prefixed variant of a route (E2 i18n routing). */",
|
|
172
|
+
"export type LocalizedRoute<T extends AppRoutes> = `/${string}${T}`;",
|
|
173
|
+
"",
|
|
88
174
|
"export const routes = {",
|
|
89
175
|
builderEntries,
|
|
90
176
|
"} as const;",
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BractJS DevTools Panel (E3).
|
|
3
|
+
*
|
|
4
|
+
* In dev mode this module is imported by the HMR client and registers a
|
|
5
|
+
* `<bractjs-devtools>` custom element. The element reads shared state from
|
|
6
|
+
* `window.__BRACTJS_DEVTOOLS__` which is populated by ClientRouter.
|
|
7
|
+
*
|
|
8
|
+
* Ctrl+Shift+B toggles the panel.
|
|
9
|
+
* Zero production overhead — this file is never imported in production because
|
|
10
|
+
* it is only loaded via `if (__BRACT_DEV__)` in the HMR client.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface DevtoolsState {
|
|
14
|
+
route: string | null;
|
|
15
|
+
loaderData: Record<string, unknown>;
|
|
16
|
+
navState: string;
|
|
17
|
+
cacheEntries: Array<{ key: string; age: number; staleTime: number; gcTime: number }>;
|
|
18
|
+
beforeLoadTrace: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
declare global {
|
|
22
|
+
interface Window {
|
|
23
|
+
__BRACTJS_DEVTOOLS__?: DevtoolsState;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PANEL_ID = "bractjs-devtools-panel";
|
|
28
|
+
|
|
29
|
+
class BractJSDevtools extends HTMLElement {
|
|
30
|
+
private open = false;
|
|
31
|
+
private panel: HTMLDivElement | null = null;
|
|
32
|
+
|
|
33
|
+
connectedCallback() {
|
|
34
|
+
this.style.cssText = "position:fixed;bottom:0;right:0;z-index:2147483647;font-family:monospace;";
|
|
35
|
+
|
|
36
|
+
const toggle = document.createElement("button");
|
|
37
|
+
toggle.textContent = "⚡ BractJS";
|
|
38
|
+
toggle.style.cssText =
|
|
39
|
+
"background:#1e1e1e;color:#61dafb;border:none;padding:4px 10px;cursor:pointer;font-size:12px;";
|
|
40
|
+
toggle.onclick = () => this.togglePanel();
|
|
41
|
+
this.appendChild(toggle);
|
|
42
|
+
|
|
43
|
+
// Keyboard shortcut
|
|
44
|
+
document.addEventListener("keydown", (e) => {
|
|
45
|
+
if (e.ctrlKey && e.shiftKey && e.key === "B") {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
this.togglePanel();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private togglePanel() {
|
|
53
|
+
if (this.panel) {
|
|
54
|
+
this.panel.remove();
|
|
55
|
+
this.panel = null;
|
|
56
|
+
this.open = false;
|
|
57
|
+
} else {
|
|
58
|
+
this.open = true;
|
|
59
|
+
this.renderPanel();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private renderPanel() {
|
|
64
|
+
const state = window.__BRACTJS_DEVTOOLS__ ?? {
|
|
65
|
+
route: null,
|
|
66
|
+
loaderData: {},
|
|
67
|
+
navState: "idle",
|
|
68
|
+
cacheEntries: [],
|
|
69
|
+
beforeLoadTrace: [],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const panel = document.createElement("div");
|
|
73
|
+
panel.id = PANEL_ID;
|
|
74
|
+
panel.style.cssText =
|
|
75
|
+
"background:#1e1e1e;color:#ccc;width:480px;max-height:60vh;overflow:auto;" +
|
|
76
|
+
"border-top:2px solid #61dafb;border-left:2px solid #61dafb;padding:12px;font-size:11px;";
|
|
77
|
+
|
|
78
|
+
const header = document.createElement("div");
|
|
79
|
+
header.style.cssText = "color:#61dafb;font-weight:bold;margin-bottom:8px;font-size:13px;";
|
|
80
|
+
header.textContent = "BractJS DevTools";
|
|
81
|
+
panel.appendChild(header);
|
|
82
|
+
|
|
83
|
+
this.section(panel, "Route", state.route ?? "(none)");
|
|
84
|
+
this.section(panel, "Navigation state", state.navState);
|
|
85
|
+
this.section(panel, "Loader data", JSON.stringify(state.loaderData, null, 2));
|
|
86
|
+
|
|
87
|
+
if (state.cacheEntries.length > 0) {
|
|
88
|
+
const cacheText = state.cacheEntries
|
|
89
|
+
.map((e) => `${e.key}\n age=${e.age}ms stale=${e.staleTime}ms gc=${e.gcTime}ms`)
|
|
90
|
+
.join("\n");
|
|
91
|
+
this.section(panel, `Cache (${state.cacheEntries.length})`, cacheText);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (state.beforeLoadTrace.length > 0) {
|
|
95
|
+
this.section(panel, "beforeLoad trace", state.beforeLoadTrace.join("\n"));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.panel = panel;
|
|
99
|
+
this.appendChild(panel);
|
|
100
|
+
|
|
101
|
+
// Auto-refresh every second while open.
|
|
102
|
+
const timer = setInterval(() => {
|
|
103
|
+
if (!this.open) { clearInterval(timer); return; }
|
|
104
|
+
panel.remove();
|
|
105
|
+
this.panel = null;
|
|
106
|
+
this.renderPanel();
|
|
107
|
+
}, 1000);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private section(parent: HTMLElement, title: string, content: string) {
|
|
111
|
+
const h = document.createElement("div");
|
|
112
|
+
h.style.cssText = "color:#61dafb;margin-top:8px;margin-bottom:2px;";
|
|
113
|
+
h.textContent = title;
|
|
114
|
+
parent.appendChild(h);
|
|
115
|
+
|
|
116
|
+
const pre = document.createElement("pre");
|
|
117
|
+
pre.style.cssText = "margin:0;white-space:pre-wrap;word-break:break-all;color:#ccc;";
|
|
118
|
+
pre.textContent = content;
|
|
119
|
+
parent.appendChild(pre);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (typeof customElements !== "undefined" && !customElements.get("bractjs-devtools")) {
|
|
124
|
+
customElements.define("bractjs-devtools", BractJSDevtools);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Inject the `<bractjs-devtools>` element into the document body.
|
|
129
|
+
* Called by the HMR client in dev mode.
|
|
130
|
+
*/
|
|
131
|
+
export function injectDevtools(): void {
|
|
132
|
+
if (typeof document === "undefined") return;
|
|
133
|
+
if (document.querySelector("bractjs-devtools")) return;
|
|
134
|
+
const el = document.createElement("bractjs-devtools");
|
|
135
|
+
document.body.appendChild(el);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Update the shared devtools state object.
|
|
140
|
+
* Called by ClientRouter on every navigation.
|
|
141
|
+
*/
|
|
142
|
+
export function updateDevtoolsState(state: Partial<DevtoolsState>): void {
|
|
143
|
+
window.__BRACTJS_DEVTOOLS__ = { ...window.__BRACTJS_DEVTOOLS__, ...state } as DevtoolsState;
|
|
144
|
+
}
|
package/src/dev/hmr-client.ts
CHANGED
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
*/
|
|
14
14
|
export const hmrClientScript: string = `
|
|
15
15
|
(function () {
|
|
16
|
+
// Inject DevTools panel in dev mode (E3).
|
|
17
|
+
if (typeof customElements !== 'undefined') {
|
|
18
|
+
import('/_bractjs/devtools.js').then(function(m) {
|
|
19
|
+
if (typeof m.injectDevtools === 'function') m.injectDevtools();
|
|
20
|
+
}).catch(function() {
|
|
21
|
+
// DevTools module not available — skip silently.
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
function connect() {
|
|
17
26
|
var ws = new WebSocket("ws://localhost:3001");
|
|
18
27
|
ws.onmessage = function (event) {
|
|
@@ -21,6 +30,11 @@ export const hmrClientScript: string = `
|
|
|
21
30
|
if (msg.type === "hmr:reload") {
|
|
22
31
|
location.reload();
|
|
23
32
|
} else if (msg.type === "hmr:route" && msg.pattern != null && msg.chunkUrl) {
|
|
33
|
+
// Validate chunk URL is a same-origin relative path before importing.
|
|
34
|
+
// Prevents a compromised/MITM'd dev WS from executing arbitrary URLs.
|
|
35
|
+
if (typeof msg.chunkUrl !== 'string' || !/^\/build\//.test(msg.chunkUrl)) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
24
38
|
// Cache-bust so the browser re-fetches the rebuilt chunk.
|
|
25
39
|
// The chunk was built with splitting, so it shares the same React
|
|
26
40
|
// instance as client.js — no dual-React issue.
|