@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.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/action-handler.test.ts +47 -0
  3. package/src/__tests__/action-registry.test.ts +73 -0
  4. package/src/__tests__/codegen.test.ts +50 -0
  5. package/src/__tests__/deferred.test.ts +96 -0
  6. package/src/__tests__/directives.test.ts +52 -0
  7. package/src/__tests__/env.test.ts +73 -0
  8. package/src/__tests__/errors.test.ts +113 -0
  9. package/src/__tests__/hash.test.ts +19 -0
  10. package/src/__tests__/integration.test.ts +1 -1
  11. package/src/__tests__/loader.test.ts +5 -2
  12. package/src/__tests__/manifest.test.ts +60 -0
  13. package/src/__tests__/middleware.test.ts +216 -0
  14. package/src/__tests__/response.test.ts +106 -0
  15. package/src/__tests__/security.test.ts +348 -0
  16. package/src/__tests__/session.test.ts +3 -3
  17. package/src/adapters/cloudflare.ts +65 -0
  18. package/src/build/bundler.ts +17 -6
  19. package/src/build/directives.ts +30 -3
  20. package/src/build/env-plugin.ts +8 -0
  21. package/src/build/hash.ts +0 -20
  22. package/src/build/plugins/css-modules.ts +110 -0
  23. package/src/client/ClientRouter.tsx +121 -13
  24. package/src/client/cache.ts +69 -0
  25. package/src/client/components/Link.tsx +16 -2
  26. package/src/client/components/LiveReload.tsx +4 -0
  27. package/src/client/hooks/useBlocker.ts +44 -0
  28. package/src/client/hooks/useFetcher.ts +66 -6
  29. package/src/client/hooks/useLocale.ts +12 -0
  30. package/src/client/hooks/useLocalizedLink.ts +18 -0
  31. package/src/client/hooks/useSearchParams.ts +74 -0
  32. package/src/client/rpc.ts +70 -0
  33. package/src/codegen/route-codegen.ts +96 -10
  34. package/src/dev/devtools.ts +144 -0
  35. package/src/dev/hmr-client.ts +14 -0
  36. package/src/dev/hmr-module-handler.ts +31 -5
  37. package/src/dev/hmr-server.ts +16 -0
  38. package/src/image/cache.ts +28 -8
  39. package/src/image/handler.ts +31 -13
  40. package/src/image/optimizer.ts +51 -14
  41. package/src/image/types.ts +1 -0
  42. package/src/index.ts +27 -0
  43. package/src/middleware/cors.ts +28 -8
  44. package/src/middleware/requestLogger.ts +4 -0
  45. package/src/server/action-handler.ts +45 -2
  46. package/src/server/action-registry.ts +14 -1
  47. package/src/server/adapter.ts +57 -0
  48. package/src/server/api-route.ts +127 -0
  49. package/src/server/context.ts +22 -0
  50. package/src/server/csrf.ts +17 -0
  51. package/src/server/env.ts +26 -4
  52. package/src/server/i18n.ts +63 -0
  53. package/src/server/loader.ts +61 -1
  54. package/src/server/middleware.ts +11 -7
  55. package/src/server/render.ts +14 -5
  56. package/src/server/request-handler.ts +77 -18
  57. package/src/server/response.ts +29 -5
  58. package/src/server/scanner.ts +6 -2
  59. package/src/server/serve.ts +102 -55
  60. package/src/server/session.ts +17 -5
  61. package/src/server/static.ts +31 -8
  62. package/src/server/stream-handler.ts +111 -0
  63. package/src/server/validate.ts +89 -0
  64. package/src/shared/route-types.ts +11 -0
  65. package/types/index.d.ts +94 -1
  66. 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, opts: SubmitOptions): Promise<void> {
96
+ async function submit(path: string, submitOpts: SubmitOptions): Promise<void> {
37
97
  setState("submitting");
38
98
  try {
39
99
  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 });
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
- if (params.length === 0)
34
- return " \"" + pattern + "\": () => \"" + pattern + "\" as const,";
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 " \"" + pattern + "\": (params: { " + paramType + " }) => `" + body + "` as const,";
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
- " T extends \"" + r.pattern + "\" ? { "
46
- + r.params.map((p) => p + ": string").join("; ")
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) => " | \"" + r.pattern + "\"").join("\n")
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: Record<string, unknown>;",
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
+ }
@@ -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.