@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.
- package/README.md +773 -465
- package/bin/cli.ts +23 -3
- package/package.json +1 -1
- package/src/__tests__/build-path.test.ts +29 -0
- package/src/__tests__/codegen.test.ts +36 -0
- 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/__tests__/typed-routing.test.ts +189 -0
- package/src/build/bundler.ts +46 -20
- package/src/build/directives.ts +2 -2
- package/src/build/env-plugin.ts +63 -0
- package/src/build/react-dedupe.ts +41 -0
- package/src/client/ClientRouter.tsx +22 -8
- package/src/client/build-path.ts +24 -0
- package/src/client/components/Form.tsx +10 -1
- package/src/client/components/Link.tsx +31 -8
- package/src/client/hooks/useFetcher.ts +17 -1
- package/src/client/hooks/useNavigate.ts +46 -0
- package/src/client/hooks/useParams.ts +15 -4
- package/src/client/hooks/useSearchParams.ts +16 -6
- package/src/client/nav-utils.ts +54 -3
- package/src/client/registry.ts +107 -0
- package/src/client/types.ts +3 -0
- package/src/codegen/route-codegen.ts +62 -23
- 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 +30 -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 +67 -5
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;
|
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
|
-
// - `
|
|
29
|
-
//
|
|
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
|
+
}
|
package/src/server/csrf.ts
CHANGED
|
@@ -1,14 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cross-origin
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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)
|
|
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 {
|
package/src/server/layout.ts
CHANGED
|
@@ -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 ──────────────────────────────────────────────────────
|
package/src/server/loader.ts
CHANGED
|
@@ -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
|
|
package/src/server/render.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { renderToReadableStream } from "react-dom/server";
|
|
2
|
-
import type
|
|
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
|
|
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
|
-
//
|
|
42
|
-
//
|
|
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(
|
|
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.
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
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
|
}
|