@bractjs/bractjs 0.1.24 → 0.1.26

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 (46) hide show
  1. package/README.md +755 -466
  2. package/bin/cli.ts +23 -3
  3. package/package.json +1 -1
  4. package/src/__tests__/compile-safety.test.ts +163 -0
  5. package/src/__tests__/compile-smoke.test.ts +276 -0
  6. package/src/__tests__/csp.test.ts +80 -0
  7. package/src/__tests__/fixtures/app/routes/_index.tsx +6 -2
  8. package/src/__tests__/fixtures/app/routes/protected.tsx +15 -0
  9. package/src/__tests__/fixtures/app/routes/redirect-action.tsx +13 -0
  10. package/src/__tests__/integration.test.ts +62 -0
  11. package/src/__tests__/layout-registry.test.ts +23 -0
  12. package/src/__tests__/loader.test.ts +23 -0
  13. package/src/__tests__/middleware.test.ts +22 -0
  14. package/src/__tests__/programmatic-api.test.ts +41 -2
  15. package/src/__tests__/response.test.ts +54 -1
  16. package/src/__tests__/security.test.ts +35 -0
  17. package/src/__tests__/server-module-stub.test.ts +145 -0
  18. package/src/__tests__/stream-handler.test.ts +36 -0
  19. package/src/build/bundler.ts +46 -20
  20. package/src/build/directives.ts +2 -2
  21. package/src/build/env-plugin.ts +76 -5
  22. package/src/build/react-dedupe.ts +41 -0
  23. package/src/client/ClientRouter.tsx +22 -8
  24. package/src/client/components/Form.tsx +10 -1
  25. package/src/client/hooks/useFetcher.ts +17 -1
  26. package/src/client/nav-utils.ts +54 -3
  27. package/src/client/types.ts +3 -0
  28. package/src/config/load.ts +50 -2
  29. package/src/dev/devtools.ts +72 -39
  30. package/src/dev/hmr-module-handler.ts +6 -4
  31. package/src/dev/rebuilder.ts +16 -1
  32. package/src/dev/server.ts +3 -0
  33. package/src/index.ts +13 -3
  34. package/src/server/csp.ts +92 -0
  35. package/src/server/csrf.ts +44 -6
  36. package/src/server/layout.ts +12 -2
  37. package/src/server/loader.ts +5 -7
  38. package/src/server/render.ts +29 -10
  39. package/src/server/request-handler.ts +15 -4
  40. package/src/server/response.ts +58 -5
  41. package/src/server/serve.ts +10 -0
  42. package/src/server/static.ts +11 -1
  43. package/src/server/stream-handler.ts +8 -7
  44. package/src/server/use-client-runtime.ts +62 -0
  45. package/src/shared/meta-tags.tsx +46 -0
  46. package/types/index.d.ts +20 -2
@@ -1,10 +1,19 @@
1
1
  import type { BunPlugin } from "bun";
2
2
  import { resolve } from "node:path";
3
+ import { extractExports } from "./directives.ts";
3
4
 
4
- // Resolved at module load so the lookup is cheap (every onLoad call uses it).
5
- // Equivalent to the absolute path of the directory holding this file
6
- // (`<bractjs>/src/build/`). We strip `/src/build` to get the framework root.
7
- const FRAMEWORK_SRC_ROOT = resolve(import.meta.dir, "..");
5
+ // Lazy: this module is re-exported from the package barrel, so it may be
6
+ // statically pulled into client bundles. `import.meta.dir` is undefined in the
7
+ // browser, and a top-level `resolve(import.meta.dir, "..")` would throw
8
+ // "Path must be a string" at module load — before any plugin is even invoked.
9
+ // Defer the resolve until a plugin actually runs (always server-side).
10
+ let frameworkSrcRoot: string | undefined;
11
+ function getFrameworkSrcRoot(): string {
12
+ if (frameworkSrcRoot === undefined) {
13
+ frameworkSrcRoot = resolve(import.meta.dir, "..");
14
+ }
15
+ return frameworkSrcRoot;
16
+ }
8
17
 
9
18
  // ── Server-only import guard ───────────────────────────────────────────────
10
19
 
@@ -33,6 +42,68 @@ export const serverOnlyPlugin: BunPlugin = {
33
42
  },
34
43
  };
35
44
 
45
+ // ── Server-only module stub ────────────────────────────────────────────────
46
+
47
+ const SERVER_FILE_RE = /\.server\.(tsx?|jsx?)$/;
48
+ const DEFAULT_EXPORT_RE = /^export\s+default\b/m;
49
+
50
+ // Runtime stub injected for every named/default export of a `*.server.ts`
51
+ // module on the client. It is a callable Proxy that throws on call AND on
52
+ // property access, so:
53
+ // • the route module's loader/action keep referencing the symbols (the
54
+ // bundle still resolves `import { db } from "./db.server.ts"`), and
55
+ // • the bodies are inert dead code on the client (the server runs them), but
56
+ // • any *accidental* use from real client code throws a clear error instead
57
+ // of silently shipping a broken `undefined`.
58
+ const SERVER_STUB_FACTORY = `const __bractServerStub = (name) => {
59
+ const fail = () => {
60
+ throw new Error(
61
+ "[BractJS] '" + name + "' comes from a *.server.ts module and is not " +
62
+ "available in the browser. Call it only inside a loader() or action()."
63
+ );
64
+ };
65
+ return new Proxy(fail, { get: (_t, prop) => (prop === "name" ? name : fail()), apply: fail });
66
+ };`;
67
+
68
+ /**
69
+ * Client build: replace every export of a `*.server.ts` module with an inert
70
+ * stub instead of hard-failing the build.
71
+ *
72
+ * BractJS ships the *entire* route module — loader and action included — to the
73
+ * client bundle (the server never strips them). A route that legitimately does
74
+ * `import { db } from "./db.server.ts"` inside its loader therefore drags the
75
+ * server module into the client graph. Hard-failing that import (the old
76
+ * `serverOnlyPlugin` behaviour) made the documented "import a server module in
77
+ * a loader" pattern impossible. Stubbing instead:
78
+ * - keeps named/default imports resolvable, so the route module compiles,
79
+ * - guarantees **zero** server source (DB drivers, secrets, `bun:sqlite`,
80
+ * etc.) reaches the browser — the original file is never read for content,
81
+ * - throws loudly if a stub is ever actually used on the client.
82
+ *
83
+ * Loaders/actions are dead code on the client (only the server invokes them),
84
+ * so the stubs are never called in correct usage.
85
+ */
86
+ export const serverModuleStubPlugin: BunPlugin = {
87
+ name: "bractjs-server-module-stub",
88
+ setup(build) {
89
+ build.onLoad({ filter: SERVER_FILE_RE }, async ({ path }) => {
90
+ const src = await Bun.file(path).text();
91
+ const names = extractExports(src);
92
+ const lines = [SERVER_STUB_FACTORY];
93
+ for (const name of names) {
94
+ lines.push(`export const ${name} = __bractServerStub(${JSON.stringify(name)});`);
95
+ }
96
+ if (DEFAULT_EXPORT_RE.test(src)) {
97
+ lines.push(`export default __bractServerStub("default");`);
98
+ }
99
+ // `export {};` guarantees the module is treated as ESM even when the
100
+ // server file had no statically-detectable exports.
101
+ lines.push("export {};");
102
+ return { contents: lines.join("\n"), loader: "ts" };
103
+ });
104
+ },
105
+ };
106
+
36
107
  // ── Client env allowlist ───────────────────────────────────────────────────
37
108
 
38
109
  /**
@@ -56,7 +127,7 @@ export function clientEnvPlugin(
56
127
  // the client. Without this guard, linking the framework via `file:`
57
128
  // produces a build that fails to parse its own source.
58
129
  if (args.path.includes("/node_modules/")) return undefined;
59
- if (args.path.startsWith(FRAMEWORK_SRC_ROOT)) return undefined;
130
+ if (args.path.startsWith(getFrameworkSrcRoot())) return undefined;
60
131
  const src = await Bun.file(args.path).text();
61
132
  // SECURITY(medium): textual regex replace runs over the whole source,
62
133
  // including inside string literals and comments. A bare `process.env.X`
@@ -0,0 +1,41 @@
1
+ import type { BunPlugin } from "bun";
2
+
3
+ /**
4
+ * Force every `react` / `react-dom` import in the CLIENT bundle to resolve to a
5
+ * single physical copy (the app's cwd copy).
6
+ *
7
+ * The client build mixes entrypoints from two roots: the framework's
8
+ * `src/client/entry.tsx` (which resolves react from the framework's
9
+ * node_modules) and the app's route files (which resolve react from the app's
10
+ * node_modules). When the `file:..`-linked framework carries its own react copy
11
+ * — even at the same version — those are two distinct module instances. The
12
+ * result is a dual-React "invalid hook call" (`ReactSharedInternals.H` is null)
13
+ * the moment a `"use client"` component runs a hook during hydration.
14
+ *
15
+ * Pinning all react specifiers to one resolved path eliminates the duplication.
16
+ */
17
+ const REACT_RE = /^(react|react-dom)(\/.*)?$/;
18
+
19
+ export function reactDedupePlugin(appCwd: string = process.cwd()): BunPlugin {
20
+ const cache = new Map<string, string>();
21
+ const resolveOne = (spec: string): string | null => {
22
+ if (cache.has(spec)) return cache.get(spec)!;
23
+ try {
24
+ const resolved = Bun.resolveSync(spec, appCwd);
25
+ cache.set(spec, resolved);
26
+ return resolved;
27
+ } catch {
28
+ return null;
29
+ }
30
+ };
31
+
32
+ return {
33
+ name: "bractjs:react-dedupe",
34
+ setup(build) {
35
+ build.onResolve({ filter: REACT_RE }, (args) => {
36
+ const resolved = resolveOne(args.path);
37
+ return resolved ? { path: resolved } : undefined;
38
+ });
39
+ },
40
+ };
41
+ }
@@ -10,13 +10,16 @@ import {
10
10
  type RouteModuleClient,
11
11
  } from "./router.tsx";
12
12
  import type { ServerManifest } from "../server/render.ts";
13
- import { matchPatternForPath } from "./nav-utils.ts";
13
+ import { matchPatternForPath, toSamePath } from "./nav-utils.ts";
14
14
  import { loaderCache, cacheKey } from "./cache.ts";
15
+ import { MetaTags } from "../shared/meta-tags.tsx";
16
+ import type { MetaDescriptor } from "../shared/route-types.ts";
15
17
 
16
18
  // ── Types ──────────────────────────────────────────────────────────────────
17
19
 
18
20
  export interface BractJSInitialData extends RouteState {
19
21
  manifest: ServerManifest;
22
+ meta?: MetaDescriptor[];
20
23
  }
21
24
 
22
25
  interface ClientRouterProps {
@@ -34,6 +37,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
34
37
  const [pathname, setPathname] = useState(initialData.pathname);
35
38
  const [navState, setNavState] = useState<NavigationState>("idle");
36
39
  const [currentModule, setCurrentModule] = useState<RouteModuleClient | null>(initialModule);
40
+ const [meta, setMeta] = useState<MetaDescriptor[]>(initialData.meta ?? []);
37
41
 
38
42
  const manifest = initialData.manifest;
39
43
 
@@ -50,6 +54,15 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
50
54
  /** Load route data + module without touching history. */
51
55
  const loadRoute = useCallback(async (to: string) => {
52
56
  setNavState("loading");
57
+ // Follow a redirect Location from client-side beforeLoad. Same-origin
58
+ // targets stay in the SPA; an off-origin/protocol-relative Location is NOT
59
+ // fed to the router — we do a full-page navigation so the browser's own
60
+ // cross-origin handling applies and we never open-redirect via pushState.
61
+ const followRedirect = (loc: string) => {
62
+ const safe = toSamePath(loc);
63
+ if (safe) { void navigateRef.current(safe); return; }
64
+ window.location.href = loc;
65
+ };
53
66
  try {
54
67
  const toPathname = to.split("?")[0];
55
68
  const pattern = matchPatternForPath(toPathname, manifest);
@@ -75,12 +88,12 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
75
88
  });
76
89
  if (result instanceof Response) {
77
90
  const loc = result.headers.get("Location");
78
- if (loc) { void navigateRef.current(loc); return; }
91
+ if (loc) { followRedirect(loc); return; }
79
92
  }
80
93
  } catch (err) {
81
94
  if (err instanceof Response) {
82
95
  const loc = (err as Response).headers.get("Location");
83
- if (loc) { void navigateRef.current(loc); return; }
96
+ if (loc) { followRedirect(loc); return; }
84
97
  }
85
98
  throw err;
86
99
  }
@@ -176,11 +189,11 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
176
189
  setPathname(to);
177
190
  setCurrentModule(routeModule);
178
191
  });
179
- const metaList = data.meta as Array<Record<string, unknown>> | undefined;
180
- const titleEntry = metaList?.find((m) => "title" in m);
181
- if (titleEntry && typeof titleEntry.title === "string") {
182
- document.title = titleEntry.title;
183
- }
192
+ // Re-render the document head from the new route's merged meta. React 19
193
+ // hoists the <title>/<meta> elements rendered by <MetaTags> into <head>,
194
+ // so description/OG tags update on soft navigation, not just the title.
195
+ const nextMeta = (data.meta as MetaDescriptor[] | undefined) ?? [];
196
+ startTransition(() => setMeta(nextMeta));
184
197
  } catch (err) {
185
198
  console.error("[bractjs] loadRoute error:", err);
186
199
  } finally {
@@ -229,6 +242,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
229
242
  return (
230
243
  <RouterContext.Provider value={{ loaderData, actionData, params, pathname, manifest, currentModule, setRoute }}>
231
244
  <NavigationContext.Provider value={{ state: navState, navigate, submit }}>
245
+ <MetaTags meta={meta} />
232
246
  {children}
233
247
  </NavigationContext.Provider>
234
248
  </RouterContext.Provider>
@@ -1,6 +1,7 @@
1
1
  import { useContext, type FormEvent, type ReactNode, type FormHTMLAttributes } from "react";
2
2
  import { RouterContext, NavigationContext } from "../router.tsx";
3
3
  import { reloadLoaders } from "../form-utils.ts";
4
+ import { toSamePath } from "../nav-utils.ts";
4
5
 
5
6
  // ── Types ──────────────────────────────────────────────────────────────────
6
7
 
@@ -49,8 +50,16 @@ export function Form({ method = "post", action, children, ...rest }: FormProps)
49
50
  headers: { "X-BractJS-Action": "1" },
50
51
  });
51
52
 
53
+ // The action returned (or threw) a redirect. The browser auto-follows the
54
+ // 3xx, so `response.url` is the *absolute* final URL — normalize it to a
55
+ // same-origin path before handing it to the client router, which matches a
56
+ // route pattern against the pathname (an absolute URL wouldn't match). An
57
+ // off-origin final URL is NOT handed to the SPA router: fall back to a
58
+ // full-page navigation so we don't open-redirect through it.
52
59
  if (response.redirected) {
53
- await navigate(response.url);
60
+ const to = toSamePath(response.url);
61
+ if (to) { await navigate(to); return; }
62
+ window.location.href = response.url;
54
63
  return;
55
64
  }
56
65
 
@@ -1,4 +1,5 @@
1
1
  import { useState } from "react";
2
+ import { toSamePath } from "../nav-utils.ts";
2
3
 
3
4
  // ── Types ──────────────────────────────────────────────────────────────────
4
5
 
@@ -100,7 +101,22 @@ export function useFetcher<T>(opts?: { stream?: boolean }): FetcherResult | Stre
100
101
  submitOpts.body instanceof FormData
101
102
  ? submitOpts.body
102
103
  : new URLSearchParams(submitOpts.body as Record<string, string>);
103
- const res = await fetch(path, { method: submitOpts.method, body });
104
+ // Send the custom header so the server's CSRF gate accepts this
105
+ // same-origin mutation (browsers block it cross-origin without a CORS
106
+ // preflight). Without it every fetcher submit 403s.
107
+ const res = await fetch(path, {
108
+ method: submitOpts.method,
109
+ body,
110
+ headers: { "X-BractJS-Action": "1" },
111
+ });
112
+ // If the action redirected, do a real navigation rather than parsing the
113
+ // redirect target as JSON. Off-origin targets get a full-page nav so we
114
+ // never follow an attacker-controlled Location inside the SPA.
115
+ if (res.redirected) {
116
+ const to = toSamePath(res.url);
117
+ window.location.assign(to ?? res.url);
118
+ return;
119
+ }
104
120
  setData(await res.json());
105
121
  } finally {
106
122
  setState("idle");
@@ -1,5 +1,27 @@
1
1
  import type { ServerManifest } from "../server/render.ts";
2
2
 
3
+ // ── Redirect normalization ─────────────────────────────────────────────────
4
+
5
+ /**
6
+ * Normalize a Location/redirect target to a same-origin path the client router
7
+ * can match. Returns an internal "/path?query#hash" for same-origin targets, or
8
+ * `null` for off-origin, protocol-relative, or malformed values — the caller
9
+ * MUST NOT feed a null result to the SPA router (an off-origin Location should
10
+ * trigger a full-page navigation instead, so the browser applies its own
11
+ * cross-origin protections). This is the client-side complement to the server's
12
+ * `sanitizeRedirect()`: it stops a soft-nav from silently following an
13
+ * attacker-controlled `Location` header.
14
+ */
15
+ export function toSamePath(loc: string): string | null {
16
+ try {
17
+ const u = new URL(loc, window.location.href);
18
+ if (u.origin !== window.location.origin) return null;
19
+ return u.pathname + u.search + u.hash;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
3
25
  // ── Pattern Matching ───────────────────────────────────────────────────────
4
26
 
5
27
  /**
@@ -21,15 +43,44 @@ function patternMatches(pathname: string, pattern: string): boolean {
21
43
  return p === pathSegs.length;
22
44
  }
23
45
 
46
+ /**
47
+ * Specificity score for a matching pattern, used to pick the best match the
48
+ * same way the server's trie does: static > dynamic > catch-all. Higher wins.
49
+ * Object key order is not reliable for priority, so we must score, not
50
+ * first-match (otherwise `[...slug]` can shadow `_index` / static routes).
51
+ */
52
+ function patternScore(pattern: string): number {
53
+ if (pattern === "") return 1_000_000; // index route — most specific for "/"
54
+ let score = 0;
55
+ for (const seg of pattern.split("/")) {
56
+ score *= 10;
57
+ if (seg.startsWith("[...") && seg.endsWith("]")) score += 1; // catch-all
58
+ else if (seg.startsWith("[") && seg.endsWith("]")) score += 2; // dynamic
59
+ else score += 3; // static
60
+ }
61
+ return score;
62
+ }
63
+
24
64
  // ── Export ─────────────────────────────────────────────────────────────────
25
65
 
26
- /** Returns the manifest pattern key that matches pathname, or null. */
66
+ /** Returns the highest-priority manifest pattern that matches pathname, or null. */
27
67
  export function matchPatternForPath(
28
68
  pathname: string,
29
69
  manifest: ServerManifest,
30
70
  ): string | null {
71
+ // Exact static match wins outright (most specific) — also a fast path.
72
+ const normalized = pathname.replace(/^\//, "");
73
+ if (normalized in manifest.routes) return normalized;
74
+
75
+ let best: string | null = null;
76
+ let bestScore = -1;
31
77
  for (const pattern of Object.keys(manifest.routes)) {
32
- if (patternMatches(pathname, pattern)) return pattern;
78
+ if (!patternMatches(pathname, pattern)) continue;
79
+ const score = patternScore(pattern);
80
+ if (score > bestScore) {
81
+ best = pattern;
82
+ bestScore = score;
83
+ }
33
84
  }
34
- return null;
85
+ return best;
35
86
  }
@@ -1,4 +1,5 @@
1
1
  import type { ServerManifest } from "../server/render.ts";
2
+ import type { MetaDescriptor } from "../shared/route-types.ts";
2
3
 
3
4
  // ── BractJSClientData ────────────────────────────────────────────────────
4
5
 
@@ -10,6 +11,8 @@ export interface BractJSClientData {
10
11
  manifest: ServerManifest;
11
12
  /** Path of the matched route file, used to pre-import the module before hydration. */
12
13
  routeFile?: string;
14
+ /** Merged meta descriptors for the current route — keeps <head> in sync. */
15
+ meta?: MetaDescriptor[];
13
16
  }
14
17
 
15
18
  // ── Window augmentation ────────────────────────────────────────────────────
@@ -1,6 +1,54 @@
1
1
  import { resolve } from "node:path";
2
2
  import type { BractJSConfig } from "../server/serve.ts";
3
3
 
4
+ /**
5
+ * Shallow shape check for a user-supplied config object. We don't validate
6
+ * exhaustively (plugins/adapters/hooks are opaque), but we catch the common
7
+ * mistakes early — a string `port`, a non-array `clientEnv`, etc. — so the
8
+ * failure surfaces here with a clear message instead of deep inside the build
9
+ * or the request path.
10
+ */
11
+ export function validateUserConfig(cfg: unknown): Partial<BractJSConfig> {
12
+ if (cfg === null || typeof cfg !== "object" || Array.isArray(cfg)) {
13
+ throw new Error(
14
+ `bractjs.config: default export must be a config object, got ${
15
+ Array.isArray(cfg) ? "array" : typeof cfg
16
+ }`,
17
+ );
18
+ }
19
+ const c = cfg as Record<string, unknown>;
20
+
21
+ const check = (key: string, ok: boolean, expected: string): void => {
22
+ if (key in c && c[key] !== undefined && !ok) {
23
+ throw new Error(`bractjs.config: "${key}" must be ${expected}`);
24
+ }
25
+ };
26
+
27
+ check("port", typeof c.port === "number" && Number.isFinite(c.port), "a finite number");
28
+ check("appDir", typeof c.appDir === "string", "a string");
29
+ check("publicDir", typeof c.publicDir === "string", "a string");
30
+ check("buildDir", typeof c.buildDir === "string", "a string");
31
+ check("imageCacheDir", typeof c.imageCacheDir === "string", "a string");
32
+ check("minify", typeof c.minify === "boolean", "a boolean");
33
+ check(
34
+ "sourcemap",
35
+ typeof c.sourcemap === "string" &&
36
+ ["none", "linked", "inline", "external"].includes(c.sourcemap as string),
37
+ 'one of "none" | "linked" | "inline" | "external"',
38
+ );
39
+ check(
40
+ "clientEnv",
41
+ Array.isArray(c.clientEnv) && c.clientEnv.every((k) => typeof k === "string"),
42
+ "an array of strings",
43
+ );
44
+ check("plugins", Array.isArray(c.plugins), "an array of Bun plugins");
45
+ check("onStart", typeof c.onStart === "function", "a function");
46
+ check("onShutdown", typeof c.onShutdown === "function", "a function");
47
+ check("onError", typeof c.onError === "function", "a function");
48
+
49
+ return c as Partial<BractJSConfig>;
50
+ }
51
+
4
52
  /**
5
53
  * Load `bractjs.config.ts` (or `.js`) from the user's cwd if present.
6
54
  * Returns an empty object when no file exists — callers fall back to defaults.
@@ -10,8 +58,8 @@ export async function loadUserConfig(): Promise<Partial<BractJSConfig>> {
10
58
  const path = resolve(process.cwd(), name);
11
59
  if (!(await Bun.file(path).exists())) continue;
12
60
  const mod = await import(path);
13
- const cfg = (mod.default ?? mod) as Partial<BractJSConfig>;
14
- return cfg ?? {};
61
+ const cfg = mod.default ?? mod;
62
+ return validateUserConfig(cfg ?? {});
15
63
  }
16
64
  return {};
17
65
  }
@@ -25,49 +25,68 @@ declare global {
25
25
  }
26
26
 
27
27
  const PANEL_ID = "bractjs-devtools-panel";
28
+ const REFRESH_MS = 1000;
29
+
30
+ function readState(): DevtoolsState {
31
+ return window.__BRACTJS_DEVTOOLS__ ?? {
32
+ route: null,
33
+ loaderData: {},
34
+ navState: "idle",
35
+ cacheEntries: [],
36
+ beforeLoadTrace: [],
37
+ };
38
+ }
28
39
 
29
40
  class BractJSDevtools extends HTMLElement {
30
41
  private open = false;
31
42
  private panel: HTMLDivElement | null = null;
43
+ private refreshTimer: ReturnType<typeof setInterval> | null = null;
44
+ private readonly handleKeydown = (e: KeyboardEvent) => {
45
+ if (e.ctrlKey && e.shiftKey && e.key === "B") {
46
+ e.preventDefault();
47
+ this.togglePanel();
48
+ }
49
+ };
32
50
 
33
51
  connectedCallback() {
34
52
  this.style.cssText = "position:fixed;bottom:0;right:0;z-index:2147483647;font-family:monospace;";
35
53
 
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
- });
54
+ if (!this.querySelector("button")) {
55
+ const toggle = document.createElement("button");
56
+ toggle.textContent = "⚡ BractJS";
57
+ toggle.style.cssText =
58
+ "background:#1e1e1e;color:#61dafb;border:none;padding:4px 10px;cursor:pointer;font-size:12px;";
59
+ toggle.onclick = () => this.togglePanel();
60
+ this.appendChild(toggle);
61
+ }
62
+
63
+ document.addEventListener("keydown", this.handleKeydown);
64
+ }
65
+
66
+ disconnectedCallback() {
67
+ document.removeEventListener("keydown", this.handleKeydown);
68
+ this.stopRefresh();
50
69
  }
51
70
 
52
71
  private togglePanel() {
53
- if (this.panel) {
54
- this.panel.remove();
55
- this.panel = null;
72
+ if (this.open) {
56
73
  this.open = false;
57
- } else {
58
- this.open = true;
59
- this.renderPanel();
74
+ this.stopRefresh();
75
+ if (this.panel) {
76
+ this.panel.remove();
77
+ this.panel = null;
78
+ }
79
+ return;
60
80
  }
81
+
82
+ this.open = true;
83
+ this.ensurePanel();
84
+ this.renderPanel();
85
+ this.startRefresh();
61
86
  }
62
87
 
63
- private renderPanel() {
64
- const state = window.__BRACTJS_DEVTOOLS__ ?? {
65
- route: null,
66
- loaderData: {},
67
- navState: "idle",
68
- cacheEntries: [],
69
- beforeLoadTrace: [],
70
- };
88
+ private ensurePanel() {
89
+ if (this.panel) return;
71
90
 
72
91
  const panel = document.createElement("div");
73
92
  panel.id = PANEL_ID;
@@ -75,6 +94,31 @@ class BractJSDevtools extends HTMLElement {
75
94
  "background:#1e1e1e;color:#ccc;width:480px;max-height:60vh;overflow:auto;" +
76
95
  "border-top:2px solid #61dafb;border-left:2px solid #61dafb;padding:12px;font-size:11px;";
77
96
 
97
+ this.panel = panel;
98
+ this.appendChild(panel);
99
+ }
100
+
101
+ private startRefresh() {
102
+ this.stopRefresh();
103
+ this.refreshTimer = setInterval(() => {
104
+ if (!this.open || !this.panel) return;
105
+ this.renderPanel();
106
+ }, REFRESH_MS);
107
+ }
108
+
109
+ private stopRefresh() {
110
+ if (this.refreshTimer) {
111
+ clearInterval(this.refreshTimer);
112
+ this.refreshTimer = null;
113
+ }
114
+ }
115
+
116
+ private renderPanel() {
117
+ if (!this.panel) return;
118
+ const state = readState();
119
+ const panel = this.panel;
120
+ panel.replaceChildren();
121
+
78
122
  const header = document.createElement("div");
79
123
  header.style.cssText = "color:#61dafb;font-weight:bold;margin-bottom:8px;font-size:13px;";
80
124
  header.textContent = "BractJS DevTools";
@@ -94,17 +138,6 @@ class BractJSDevtools extends HTMLElement {
94
138
  if (state.beforeLoadTrace.length > 0) {
95
139
  this.section(panel, "beforeLoad trace", state.beforeLoadTrace.join("\n"));
96
140
  }
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
141
  }
109
142
 
110
143
  private section(parent: HTMLElement, title: string, content: string) {
@@ -1,6 +1,6 @@
1
1
  import { resolve, join, sep } from "node:path";
2
2
  import { realpath } from "node:fs/promises";
3
- import { serverOnlyPlugin } from "../build/env-plugin.ts";
3
+ import { serverModuleStubPlugin } from "../build/env-plugin.ts";
4
4
  import { createUseServerProxyPlugin } from "../build/directives.ts";
5
5
 
6
6
  /**
@@ -48,14 +48,16 @@ export async function handleHmrModuleRequest(
48
48
  // build uses. Without these, a route module that imports `*.server.ts` or
49
49
  // contains "use server" exports would have that server source compiled and
50
50
  // shipped to the browser as JavaScript over /_hmr/module — leaking
51
- // credentials, DB code, etc. The serverOnlyPlugin hard-fails such imports
52
- // and useServerProxyPlugin rewrites "use server" exports to fetch stubs.
51
+ // credentials, DB code, etc. The serverModuleStubPlugin replaces every
52
+ // `*.server.ts` export with an inert stub (zero server source reaches the
53
+ // client) and useServerProxyPlugin rewrites "use server" exports to fetch
54
+ // stubs.
53
55
  const result = await Bun.build({
54
56
  entrypoints: [fullPath],
55
57
  target: "browser",
56
58
  minify: false,
57
59
  sourcemap: "inline",
58
- plugins: [serverOnlyPlugin, createUseServerProxyPlugin(rootDir)],
60
+ plugins: [serverModuleStubPlugin, createUseServerProxyPlugin(rootDir)],
59
61
  });
60
62
 
61
63
  if (!result.success || result.outputs.length === 0) {
@@ -1,5 +1,8 @@
1
1
  import type { BractJSConfig } from "../server/serve.ts";
2
2
  import { createUseServerProxyPlugin } from "../build/directives.ts";
3
+ import { serverModuleStubPlugin, clientEnvPlugin } from "../build/env-plugin.ts";
4
+ import { cssModulesPlugin } from "../build/plugins/css-modules.ts";
5
+ import { reactDedupePlugin } from "../build/react-dedupe.ts";
3
6
  import { scanRoutes } from "../server/scanner.ts";
4
7
  import { generateManifest, writeManifest } from "../build/manifest.ts";
5
8
  import { mkdir, rename, rm } from "node:fs/promises";
@@ -48,7 +51,19 @@ export async function rebuildClient(
48
51
  // structure. publicPath + ../ traversals produce wrong absolute URLs.
49
52
  minify: false,
50
53
  sourcemap: "inline",
51
- plugins: [createUseServerProxyPlugin(appDir), ...(config?.plugins ?? [])],
54
+ // SECURITY: mirror the production client-bundle guard plugins
55
+ // (src/build/bundler.ts). Without `serverModuleStubPlugin` a route that
56
+ // imports a `*.server.ts` module would have that server source compiled
57
+ // and served to the browser over /build/client in dev; without
58
+ // `clientEnvPlugin` server env vars would leak the same way.
59
+ plugins: [
60
+ reactDedupePlugin(process.cwd()),
61
+ serverModuleStubPlugin,
62
+ createUseServerProxyPlugin(appDir),
63
+ clientEnvPlugin(config?.clientEnv ?? [], Bun.env as Record<string, string>),
64
+ cssModulesPlugin,
65
+ ...(config?.plugins ?? []),
66
+ ],
52
67
  });
53
68
  } finally {
54
69
  await rm(shimPath, { force: true });
package/src/dev/server.ts CHANGED
@@ -34,6 +34,9 @@ export async function createDevServer(options?: DevServerOptions): Promise<DevSe
34
34
 
35
35
  const userConfig = options?.skipUserConfig ? {} : await loadUserConfig();
36
36
  const merged: Partial<BractJSConfig> = { ...userConfig, ...options?.config };
37
+ // Note: the `"use client"` SSR stub is installed by buildFetchHandler (it runs
38
+ // for any source-import path, dev or `bractjs start`), so no separate dev hook
39
+ // is needed here.
37
40
 
38
41
  const hmrPort = options?.hmrPort ?? 3001;
39
42
  const appPort = options?.port ?? merged.port ?? 3000;