@bractjs/bractjs 0.1.25 → 0.1.27

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 (56) hide show
  1. package/README.md +773 -465
  2. package/bin/cli.ts +23 -3
  3. package/package.json +1 -1
  4. package/src/__tests__/build-path.test.ts +29 -0
  5. package/src/__tests__/codegen.test.ts +36 -0
  6. package/src/__tests__/compile-safety.test.ts +163 -0
  7. package/src/__tests__/compile-smoke.test.ts +276 -0
  8. package/src/__tests__/csp.test.ts +80 -0
  9. package/src/__tests__/fixtures/app/routes/_index.tsx +6 -2
  10. package/src/__tests__/fixtures/app/routes/protected.tsx +15 -0
  11. package/src/__tests__/fixtures/app/routes/redirect-action.tsx +13 -0
  12. package/src/__tests__/integration.test.ts +62 -0
  13. package/src/__tests__/layout-registry.test.ts +23 -0
  14. package/src/__tests__/loader.test.ts +23 -0
  15. package/src/__tests__/middleware.test.ts +22 -0
  16. package/src/__tests__/programmatic-api.test.ts +41 -2
  17. package/src/__tests__/response.test.ts +54 -1
  18. package/src/__tests__/security.test.ts +35 -0
  19. package/src/__tests__/server-module-stub.test.ts +145 -0
  20. package/src/__tests__/stream-handler.test.ts +36 -0
  21. package/src/__tests__/typed-routing.test.ts +189 -0
  22. package/src/build/bundler.ts +46 -20
  23. package/src/build/directives.ts +2 -2
  24. package/src/build/env-plugin.ts +63 -0
  25. package/src/build/react-dedupe.ts +41 -0
  26. package/src/client/ClientRouter.tsx +22 -8
  27. package/src/client/build-path.ts +24 -0
  28. package/src/client/components/Form.tsx +10 -1
  29. package/src/client/components/Link.tsx +31 -8
  30. package/src/client/hooks/useFetcher.ts +17 -1
  31. package/src/client/hooks/useNavigate.ts +46 -0
  32. package/src/client/hooks/useParams.ts +15 -4
  33. package/src/client/hooks/useSearchParams.ts +16 -6
  34. package/src/client/nav-utils.ts +54 -3
  35. package/src/client/registry.ts +107 -0
  36. package/src/client/types.ts +3 -0
  37. package/src/codegen/route-codegen.ts +62 -23
  38. package/src/config/load.ts +50 -2
  39. package/src/dev/devtools.ts +72 -39
  40. package/src/dev/hmr-module-handler.ts +6 -4
  41. package/src/dev/rebuilder.ts +16 -1
  42. package/src/dev/server.ts +3 -0
  43. package/src/index.ts +30 -3
  44. package/src/server/csp.ts +92 -0
  45. package/src/server/csrf.ts +44 -6
  46. package/src/server/layout.ts +12 -2
  47. package/src/server/loader.ts +5 -7
  48. package/src/server/render.ts +29 -10
  49. package/src/server/request-handler.ts +15 -4
  50. package/src/server/response.ts +58 -5
  51. package/src/server/serve.ts +10 -0
  52. package/src/server/static.ts +11 -1
  53. package/src/server/stream-handler.ts +8 -7
  54. package/src/server/use-client-runtime.ts +62 -0
  55. package/src/shared/meta-tags.tsx +46 -0
  56. package/types/index.d.ts +67 -5
@@ -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;
package/src/index.ts CHANGED
@@ -25,14 +25,22 @@ export { createCloudflareAdapter, makeCloudflareHandler } from "./adapters/cloud
25
25
  // - `createUseServerProxyPlugin(appDir)` (client bundle): replaces
26
26
  // "use server" exports with fetch proxies. Without it, server-action
27
27
  // bodies — including DB queries and secrets — ship inside the browser JS.
28
- // - `serverOnlyPlugin` (client bundle): hard-fails imports of `*.server.ts`
29
- // files so server-only code can never be tree-walked into the client bundle.
28
+ // - `serverModuleStubPlugin` (client bundle): replaces every export of a
29
+ // `*.server.ts` module with an inert stub. Because BractJS ships the whole
30
+ // route module (loader + action included) to the client, a route that imports
31
+ // a server module inside its loader pulls that module into the client graph;
32
+ // stubbing keeps the import resolvable while guaranteeing zero server source
33
+ // (DB drivers, secrets) reaches the browser. The stubs throw if ever used on
34
+ // the client. This is the plugin the dev and production client builds use.
35
+ // - `serverOnlyPlugin` (client bundle, legacy): the stricter predecessor that
36
+ // *hard-fails* any `*.server.ts` import. Kept for back-compat / opt-in use
37
+ // when you want server-module imports to be a build error rather than a stub.
30
38
  // - `clientEnvPlugin(allowedKeys, env)` (client bundle): allowlists which
31
39
  // `process.env.*` references survive into the browser bundle.
32
40
  // - `cssModulesPlugin` (client bundle): handles `*.module.css` imports.
33
41
  export { cssModulesPlugin, transformCssModule } from "./build/plugins/css-modules.ts";
34
42
  export { useClientStubPlugin, createUseServerProxyPlugin, useServerProxyPlugin } from "./build/directives.ts";
35
- export { serverOnlyPlugin, clientEnvPlugin } from "./build/env-plugin.ts";
43
+ export { serverModuleStubPlugin, serverOnlyPlugin, clientEnvPlugin } from "./build/env-plugin.ts";
36
44
 
37
45
  // Module-registry codegen (drives `bun build --compile` workflow)
38
46
  export {
@@ -78,6 +86,8 @@ export { cors } from "./middleware/cors.ts";
78
86
  export type { CorsOptions } from "./middleware/cors.ts";
79
87
  export { authGuard } from "./middleware/authGuard.ts";
80
88
  export type { AuthGuardOptions, SessionStorageLike, SessionLike } from "./middleware/authGuard.ts";
89
+ export { csp, getCspNonce, CSP_NONCE_KEY } from "./server/csp.ts";
90
+ export type { CspOptions } from "./server/csp.ts";
81
91
 
82
92
  // Session
83
93
  export { createCookieSession } from "./server/session.ts";
@@ -98,6 +108,8 @@ export { useLoaderData } from "./client/hooks/useLoaderData.ts";
98
108
  export { useActionData } from "./client/hooks/useActionData.ts";
99
109
  export { useParams } from "./client/hooks/useParams.ts";
100
110
  export { useNavigation } from "./client/hooks/useNavigation.ts";
111
+ export { useNavigate } from "./client/hooks/useNavigate.ts";
112
+ export type { NavigateFn, NavigateOptions } from "./client/hooks/useNavigate.ts";
101
113
  export { useFetcher } from "./client/hooks/useFetcher.ts";
102
114
  export { useSearchParams } from "./client/hooks/useSearchParams.ts";
103
115
  export type { SearchParamsResult } from "./client/hooks/useSearchParams.ts";
@@ -105,6 +117,21 @@ export { useBlocker } from "./client/hooks/useBlocker.ts";
105
117
  export { useLocale } from "./client/hooks/useLocale.ts";
106
118
  export { useLocalizedLink } from "./client/hooks/useLocalizedLink.ts";
107
119
 
120
+ // Typed-routing registration seam. Augment `Register` (done by `bractjs codegen`
121
+ // in app/route-types.gen.ts) to make <Link>, useNavigate, useParams, and
122
+ // useSearchParams type-safe. Augment RouteSearchParamsMap / RouteContextMap to
123
+ // type a route's search params / context.
124
+ export type {
125
+ Register,
126
+ RouteRegistry,
127
+ RegisteredRoutes,
128
+ ParamsFor,
129
+ SearchFor,
130
+ RouteSearchParamsMap,
131
+ RouteContextMap,
132
+ } from "./client/registry.ts";
133
+ export { buildPath } from "./client/build-path.ts";
134
+
108
135
  // i18n utilities (server-side)
109
136
  export { wrapRoutesWithLocale, stripLocale, localizedDataPath } from "./server/i18n.ts";
110
137
  export type { I18nConfig } from "./server/serve.ts";
@@ -0,0 +1,92 @@
1
+ import type { MiddlewareFn } from "./middleware.ts";
2
+
3
+ /**
4
+ * Context key under which the per-request CSP nonce is stored. The render
5
+ * pipeline reads this and applies it to the inline bootstrap script + the
6
+ * client entry module tags via `renderToReadableStream({ nonce })`, so the
7
+ * scripts BractJS injects satisfy a strict `script-src 'nonce-…'` policy.
8
+ */
9
+ export const CSP_NONCE_KEY = "__bractCspNonce";
10
+
11
+ export interface CspOptions {
12
+ /**
13
+ * Extra directives to merge into the default policy, keyed by directive name.
14
+ * Values are joined with spaces. A value of `null` removes a default
15
+ * directive entirely. Example:
16
+ * { "img-src": "'self' https://cdn.example", "frame-ancestors": "'none'" }
17
+ */
18
+ directives?: Record<string, string | null>;
19
+ /**
20
+ * Emit `Content-Security-Policy-Report-Only` instead of the enforcing header.
21
+ * Useful for staging a policy before turning it on. Default: false.
22
+ */
23
+ reportOnly?: boolean;
24
+ }
25
+
26
+ /**
27
+ * Read the per-request CSP nonce a `csp()` middleware stored on the context.
28
+ * Returns undefined when no CSP middleware ran (CSP is opt-in).
29
+ */
30
+ export function getCspNonce(context: Record<string, unknown>): string | undefined {
31
+ const v = context[CSP_NONCE_KEY];
32
+ return typeof v === "string" ? v : undefined;
33
+ }
34
+
35
+ function generateNonce(): string {
36
+ const bytes = new Uint8Array(16);
37
+ crypto.getRandomValues(bytes);
38
+ return btoa(String.fromCharCode(...bytes)).replace(/=+$/, "");
39
+ }
40
+
41
+ /**
42
+ * Opt-in nonce-based Content-Security-Policy middleware.
43
+ *
44
+ * Generates a fresh random nonce per request, stashes it on `ctx.context` so
45
+ * the SSR render pipeline can attach it to the scripts BractJS injects, and
46
+ * sets the `Content-Security-Policy` response header. The default policy is a
47
+ * sensible strict baseline; override or extend it via `options.directives`.
48
+ *
49
+ * import { pipeline, csp } from "@bractjs/bractjs";
50
+ * pipeline.use(csp({ directives: { "img-src": "'self' data: https:" } }));
51
+ *
52
+ * SECURITY: only the inline bootstrap script and the client entry module —
53
+ * the scripts BractJS itself emits — are nonced. Any inline script an app adds
54
+ * to its own `root.tsx`/components must carry the same nonce (read it via the
55
+ * render context) or it will be blocked, which is the point of CSP.
56
+ */
57
+ export function csp(options: CspOptions = {}): MiddlewareFn {
58
+ const reportOnly = options.reportOnly === true;
59
+ const headerName = reportOnly
60
+ ? "Content-Security-Policy-Report-Only"
61
+ : "Content-Security-Policy";
62
+
63
+ return async (ctx, next) => {
64
+ const nonce = generateNonce();
65
+ ctx.context[CSP_NONCE_KEY] = nonce;
66
+
67
+ const directives: Record<string, string | null> = {
68
+ "default-src": "'self'",
69
+ // 'strict-dynamic' lets the nonced bootstrap script load the chunks it
70
+ // imports without each chunk needing its own nonce. Falls back to 'self'
71
+ // in browsers that don't support it.
72
+ "script-src": `'self' 'nonce-${nonce}' 'strict-dynamic'`,
73
+ "style-src": "'self' 'unsafe-inline'",
74
+ "img-src": "'self' data: blob:",
75
+ "connect-src": "'self'",
76
+ "base-uri": "'self'",
77
+ "frame-ancestors": "'self'",
78
+ "object-src": "'none'",
79
+ ...(options.directives ?? {}),
80
+ };
81
+
82
+ const policy = Object.entries(directives)
83
+ .filter(([, v]) => v !== null)
84
+ .map(([k, v]) => `${k} ${v}`)
85
+ .join("; ");
86
+
87
+ const response = await next();
88
+ // Mutate headers in place so we don't break a single-shot streaming body.
89
+ response.headers.set(headerName, policy);
90
+ return response;
91
+ };
92
+ }
@@ -1,14 +1,52 @@
1
1
  /**
2
- * Cross-origin POST/PUT/DELETE/PATCH protection.
3
- * Allow when: request carries X-BractJS-Action header (client-issued, blocked
4
- * cross-origin by CORS for non-simple requests), OR the Origin header matches
5
- * the request URL's origin.
2
+ * Cross-origin mutation protection for state-changing requests
3
+ * (POST/PUT/DELETE/PATCH and the side-effecting /_action, /_stream endpoints).
4
+ *
5
+ * Defense in depth, in priority order:
6
+ *
7
+ * 1. `Sec-Fetch-Site` — set by the browser, NOT settable from JS (it's a
8
+ * forbidden request header). When present it is authoritative: only
9
+ * `same-origin` and `none` (direct navigation / address bar) are allowed;
10
+ * `cross-site` and `same-site` are rejected. This catches cross-origin
11
+ * forgeries even when the attacker controls the Origin header (non-browser
12
+ * clients) — those won't carry a trustworthy Sec-Fetch-Site.
13
+ *
14
+ * 2. `X-BractJS-Action` — a custom header the client RPC layer sets on every
15
+ * action call. Browsers block custom headers cross-origin without a CORS
16
+ * preflight, so its presence implies a same-origin (or explicitly
17
+ * CORS-allowed) caller.
18
+ *
19
+ * 3. `Origin` — must match the request URL's origin.
20
+ *
21
+ * A request is allowed only when Sec-Fetch-Site does not veto it AND at least
22
+ * one of (2) or (3) holds. Non-browser clients (curl, server-to-server) send
23
+ * none of these headers and are rejected by default — they must set
24
+ * `X-BractJS-Action` or a same-origin `Origin` to mutate.
6
25
  */
7
- // SECURITY(medium): X-BractJS-Action acts as a CSRF token by relying on CORS preflight blocking custom headers cross-origin. This is safe only while the server does NOT emit permissive Access-Control-Allow-Headers for this header. If CORS policy is ever loosened, add a cryptographic CSRF token instead.
26
+ // SECURITY(medium): X-BractJS-Action acts as a CSRF token by relying on CORS
27
+ // preflight blocking custom headers cross-origin. This is safe only while the
28
+ // server does NOT emit a permissive Access-Control-Allow-Headers listing this
29
+ // header. If CORS policy is ever loosened, Sec-Fetch-Site (1) remains as the
30
+ // browser-enforced backstop, and apps that loosen CORS should add a
31
+ // cryptographic double-submit token.
8
32
  export function isAllowedMutation(request: Request): boolean {
33
+ // (1) Browser-enforced signal. If present, it vetoes cross-origin requests
34
+ // regardless of what the Origin/custom headers claim.
35
+ const fetchSite = request.headers.get("Sec-Fetch-Site");
36
+ if (fetchSite && fetchSite !== "same-origin" && fetchSite !== "none") {
37
+ return false;
38
+ }
39
+
40
+ // (2) Client-issued custom header (blocked cross-origin by CORS preflight).
9
41
  if (request.headers.get("X-BractJS-Action")) return true;
42
+
43
+ // (3) Same-origin Origin header.
10
44
  const origin = request.headers.get("Origin");
11
- if (!origin) return false;
45
+ if (!origin) {
46
+ // No Origin header. Allow only when the browser explicitly told us this is
47
+ // a same-origin / direct request via Sec-Fetch-Site; otherwise reject.
48
+ return fetchSite === "same-origin" || fetchSite === "none";
49
+ }
12
50
  try {
13
51
  return new URL(origin).origin === new URL(request.url).origin;
14
52
  } catch {
@@ -96,10 +96,16 @@ export async function importRouteModule(filePath: string): Promise<RouteModule>
96
96
  loader: mod.loader,
97
97
  action: mod.action,
98
98
  meta: mod.meta,
99
+ // SECURITY(high): beforeLoad is the auth/redirect gate and `context` is the
100
+ // per-route context factory. Both MUST be projected here — dropping them
101
+ // turns every beforeLoad() export into a silent no-op, bypassing auth on
102
+ // full-page GET, POST actions, and the /_data soft-nav endpoint alike.
103
+ beforeLoad: mod.beforeLoad,
104
+ context: mod.context,
99
105
  handle: mod.handle,
100
106
  ErrorBoundary: mod.ErrorBoundary,
101
107
  default: mod.default,
102
- };
108
+ } as RouteModule;
103
109
  }
104
110
 
105
111
  /**
@@ -114,10 +120,14 @@ function pickRouteModule(mod: Record<string, unknown> | RouteModule | undefined)
114
120
  loader: m.loader as RouteModule["loader"],
115
121
  action: m.action as RouteModule["action"],
116
122
  meta: m.meta as RouteModule["meta"],
123
+ // SECURITY(high): keep beforeLoad + context in the projection — see the
124
+ // note in importRouteModule. The compiled-binary path goes through here.
125
+ beforeLoad: m.beforeLoad as RouteModule["beforeLoad"],
126
+ context: m.context as unknown,
117
127
  handle: m.handle as RouteModule["handle"],
118
128
  ErrorBoundary: m.ErrorBoundary as RouteModule["ErrorBoundary"],
119
129
  default: m.default as RouteModule["default"],
120
- };
130
+ } as RouteModule;
121
131
  }
122
132
 
123
133
  // ── resolveRouteChain ──────────────────────────────────────────────────────
@@ -80,21 +80,19 @@ export async function runLoaders(
80
80
  args: LoaderArgs,
81
81
  onError?: OnErrorHook,
82
82
  ): Promise<LoaderResults> {
83
+ // Run every loader in the chain concurrently — root, all layouts, and the
84
+ // route loader. The route loader is usually the slowest and most important
85
+ // one, so it must not be serialized behind the layout wave.
83
86
  const layoutLoaders = chain.layouts.map((mod) =>
84
87
  safeRun(mod.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError)
85
88
  );
86
89
 
87
- const [root, ...layoutResults] = await Promise.all([
90
+ const [root, route, ...layoutResults] = await Promise.all([
88
91
  safeRun(chain.root.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError),
92
+ safeRun(chain.route.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError),
89
93
  ...layoutLoaders,
90
94
  ]);
91
95
 
92
- const route = await safeRun(
93
- chain.route.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined,
94
- args,
95
- onError,
96
- );
97
-
98
96
  return { root, layouts: layoutResults, route };
99
97
  }
100
98
 
@@ -1,9 +1,10 @@
1
1
  import { renderToReadableStream } from "react-dom/server";
2
- import type { ReactNode } from "react";
2
+ import { createElement, Fragment, type ReactNode } from "react";
3
3
  import type { MetaDescriptor } from "../shared/route-types.ts";
4
4
  import { safeStringify, isDevRuntime } from "./env.ts";
5
5
  import { errorOverlayScript } from "../dev/error-overlay.ts";
6
- import { mergeMeta, renderMetaTags } from "./meta.ts";
6
+ import { mergeMeta } from "./meta.ts";
7
+ import { MetaTags } from "../shared/meta-tags.tsx";
7
8
 
8
9
  export interface ServerManifest {
9
10
  clientEntry: string;
@@ -22,6 +23,8 @@ export interface RenderOptions {
22
23
  status?: number;
23
24
  /** Path of the matched route file (e.g. "routes/_index.tsx"), used by the client to pre-import the module before hydration. */
24
25
  routeFile?: string;
26
+ /** Per-request CSP nonce (set by the opt-in `csp()` middleware). Applied to the inline bootstrap script + client entry module tags. */
27
+ nonce?: string;
25
28
  }
26
29
 
27
30
  export async function renderRoute(options: RenderOptions): Promise<Response> {
@@ -38,17 +41,32 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
38
41
  const devFlag = isDevRuntime() ? "window.__BRACT_DEV__=true;" : "";
39
42
  const devOverlay = isDevRuntime() ? devFlag + errorOverlayScript + "\n" : "";
40
43
  const mergedMeta = mergeMeta(options.meta ?? []);
41
- // metaHtml is injected into <head> via React (the renderToReadableStream tree
42
- // is expected to use it). The merged descriptor array is what the client
43
- // reads — keep it shaped, not stringified HTML.
44
+ // The merged descriptor array is what the client reads to keep the document
45
+ // head in sync on soft navigation keep it shaped, not stringified HTML.
44
46
  const bootstrapScriptContent =
45
47
  devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, manifest, routeFile: options.routeFile, meta: mergedMeta })};`;
46
48
 
49
+ // Render <title>/<meta> elements alongside the app shell. React 19 hoists
50
+ // document-metadata elements into <head> during streaming SSR, so crawlers
51
+ // and no-JS clients receive real meta tags. The client renders the same
52
+ // <MetaTags> inside ClientRouter, so hydration matches and soft navigation
53
+ // re-renders the head.
54
+ const tree = createElement(
55
+ Fragment,
56
+ null,
57
+ createElement(MetaTags, { meta: mergedMeta }),
58
+ shell,
59
+ );
60
+
47
61
  let renderError: unknown;
48
62
 
49
- const stream = await renderToReadableStream(shell, {
63
+ const stream = await renderToReadableStream(tree, {
50
64
  bootstrapScriptContent,
51
65
  bootstrapModules: [manifest.clientEntry],
66
+ // When the opt-in csp() middleware ran, React stamps this nonce onto the
67
+ // inline bootstrap script and the client entry <script type=module>, so
68
+ // they satisfy a strict `script-src 'nonce-…'` policy.
69
+ nonce: options.nonce,
52
70
  onError(error) {
53
71
  renderError = error;
54
72
  console.error("[bract] renderToReadableStream error:", error);
@@ -62,10 +80,11 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
62
80
  headers: {
63
81
  "Content-Type": "text/html; charset=utf-8",
64
82
  "Transfer-Encoding": "chunked",
65
- // SECURITY(medium): baseline hardening headers. Apps that need a tighter
66
- // CSP (e.g. with nonces for the inline bootstrap script) can override
67
- // via middleware. We omit CSP here because the inline bootstrap script
68
- // injected by safeStringify would require nonce wiring throughout.
83
+ // SECURITY(medium): baseline hardening headers. For a Content-Security-
84
+ // Policy, opt into the nonce-based `csp()` middleware it generates a
85
+ // per-request nonce, applies it to the inline bootstrap script + client
86
+ // entry module here (via renderToReadableStream's `nonce` option), and
87
+ // sets the CSP response header.
69
88
  "X-Content-Type-Options": "nosniff",
70
89
  "X-Frame-Options": "SAMEORIGIN",
71
90
  "Referrer-Policy": "strict-origin-when-cross-origin",
@@ -5,12 +5,13 @@ import { resolveRouteChain, type ModuleRegistry } from "./layout.ts";
5
5
  import { runLoaders, runAction, buildLoaderArgs, runRouteContext, runBeforeLoad } from "./loader.ts";
6
6
  import { renderRoute, type ServerManifest } from "./render.ts";
7
7
  import { resolveMeta } from "./meta.ts";
8
- import { json, error } from "./response.ts";
8
+ import { json, error, sanitizeRedirect } from "./response.ts";
9
9
  import { isRedirect, isHttpError } from "../shared/errors.ts";
10
10
  import { isExplicitDev } from "./env.ts";
11
11
  import { pipeline, type MiddlewareContext } from "./middleware.ts";
12
12
  import { BractJSProvider } from "../shared/context.ts";
13
13
  import { isAllowedMutation } from "./csrf.ts";
14
+ import { getCspNonce } from "./csp.ts";
14
15
  import { fireOnError, type OnErrorHook } from "./lifecycle.ts";
15
16
 
16
17
  export interface HandlerConfig {
@@ -101,7 +102,7 @@ async function route(
101
102
  const results = await runLoaders(chain, args, onError);
102
103
  return json({ root: results.root, layouts: results.layouts, route: results.route, params: match.params });
103
104
  } catch (err) {
104
- if (isRedirect(err)) return err as Response;
105
+ if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
105
106
  if (isHttpError(err)) return json({ error: err.message }, { status: err.status });
106
107
  console.error("[bractjs] /_data error:", err);
107
108
  await fireOnError(onError, err, request);
@@ -145,13 +146,21 @@ async function route(
145
146
  const formData = isFormLike ? await request.formData() : new FormData();
146
147
  actionData = await runAction(chain.route, { ...args, formData });
147
148
  } catch (err) {
148
- if (isRedirect(err)) return err as Response;
149
+ if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
149
150
  if (isHttpError(err)) return error(err.message, err.status);
150
151
  await fireOnError(onError, err, request);
151
152
  if (isExplicitDev()) return error(err instanceof Error ? err.message : String(err), 500);
152
153
  return error("Internal Server Error", 500);
153
154
  }
154
155
 
156
+ // An action may *return* (not just throw) a redirect or any Response —
157
+ // the documented pattern is `return redirect("/")`. Propagate it verbatim
158
+ // so the browser/`<Form>` sees a real 3xx (and follows it) instead of a
159
+ // 200 with the Response serialized into a JSON body. sanitizeRedirect()
160
+ // neutralizes an off-origin Location that didn't go through redirect()'s
161
+ // allowExternal opt-in (e.g. a raw `new Response(…,{Location:"//evil"})`).
162
+ if (actionData instanceof Response) return sanitizeRedirect(actionData, request.url);
163
+
155
164
  // Client-side Form submits with this header — return JSON, not HTML.
156
165
  if (request.headers.get("X-BractJS-Action")) {
157
166
  return json(actionData ?? null);
@@ -163,7 +172,7 @@ async function route(
163
172
  try {
164
173
  loaderResults = await runLoaders(chain, args, onError);
165
174
  } catch (err) {
166
- if (isRedirect(err)) return err as Response;
175
+ if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
167
176
  if (isHttpError(err)) return error(err.message, err.status);
168
177
  await fireOnError(onError, err, request);
169
178
  if (isExplicitDev()) return error(err instanceof Error ? err.message : String(err), 500);
@@ -208,5 +217,7 @@ async function route(
208
217
  manifest,
209
218
  meta,
210
219
  routeFile: match.routeFile.filePath,
220
+ // Set by the opt-in csp() middleware; undefined otherwise.
221
+ nonce: getCspNonce(context),
211
222
  });
212
223
  }