@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.
- package/README.md +755 -466
- package/bin/cli.ts +23 -3
- package/package.json +1 -1
- package/src/__tests__/compile-safety.test.ts +163 -0
- package/src/__tests__/compile-smoke.test.ts +276 -0
- package/src/__tests__/csp.test.ts +80 -0
- package/src/__tests__/fixtures/app/routes/_index.tsx +6 -2
- package/src/__tests__/fixtures/app/routes/protected.tsx +15 -0
- package/src/__tests__/fixtures/app/routes/redirect-action.tsx +13 -0
- package/src/__tests__/integration.test.ts +62 -0
- package/src/__tests__/layout-registry.test.ts +23 -0
- package/src/__tests__/loader.test.ts +23 -0
- package/src/__tests__/middleware.test.ts +22 -0
- package/src/__tests__/programmatic-api.test.ts +41 -2
- package/src/__tests__/response.test.ts +54 -1
- package/src/__tests__/security.test.ts +35 -0
- package/src/__tests__/server-module-stub.test.ts +145 -0
- package/src/__tests__/stream-handler.test.ts +36 -0
- package/src/build/bundler.ts +46 -20
- package/src/build/directives.ts +2 -2
- package/src/build/env-plugin.ts +76 -5
- package/src/build/react-dedupe.ts +41 -0
- package/src/client/ClientRouter.tsx +22 -8
- package/src/client/components/Form.tsx +10 -1
- package/src/client/hooks/useFetcher.ts +17 -1
- package/src/client/nav-utils.ts +54 -3
- package/src/client/types.ts +3 -0
- package/src/config/load.ts +50 -2
- package/src/dev/devtools.ts +72 -39
- package/src/dev/hmr-module-handler.ts +6 -4
- package/src/dev/rebuilder.ts +16 -1
- package/src/dev/server.ts +3 -0
- package/src/index.ts +13 -3
- package/src/server/csp.ts +92 -0
- package/src/server/csrf.ts +44 -6
- package/src/server/layout.ts +12 -2
- package/src/server/loader.ts +5 -7
- package/src/server/render.ts +29 -10
- package/src/server/request-handler.ts +15 -4
- package/src/server/response.ts +58 -5
- package/src/server/serve.ts +10 -0
- package/src/server/static.ts +11 -1
- package/src/server/stream-handler.ts +8 -7
- package/src/server/use-client-runtime.ts +62 -0
- package/src/shared/meta-tags.tsx +46 -0
- package/types/index.d.ts +20 -2
package/src/build/env-plugin.ts
CHANGED
|
@@ -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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
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(
|
|
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) {
|
|
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) {
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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");
|
package/src/client/nav-utils.ts
CHANGED
|
@@ -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
|
|
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))
|
|
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
|
|
85
|
+
return best;
|
|
35
86
|
}
|
package/src/client/types.ts
CHANGED
|
@@ -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 ────────────────────────────────────────────────────
|
package/src/config/load.ts
CHANGED
|
@@ -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 =
|
|
14
|
-
return cfg ?? {};
|
|
61
|
+
const cfg = mod.default ?? mod;
|
|
62
|
+
return validateUserConfig(cfg ?? {});
|
|
15
63
|
}
|
|
16
64
|
return {};
|
|
17
65
|
}
|
package/src/dev/devtools.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
54
|
-
this.panel.remove();
|
|
55
|
-
this.panel = null;
|
|
72
|
+
if (this.open) {
|
|
56
73
|
this.open = false;
|
|
57
|
-
|
|
58
|
-
this.
|
|
59
|
-
|
|
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
|
|
64
|
-
|
|
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 {
|
|
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
|
|
52
|
-
//
|
|
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: [
|
|
60
|
+
plugins: [serverModuleStubPlugin, createUseServerProxyPlugin(rootDir)],
|
|
59
61
|
});
|
|
60
62
|
|
|
61
63
|
if (!result.success || result.outputs.length === 0) {
|
package/src/dev/rebuilder.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|