@jk2908/solas 0.15.1 → 0.16.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.1
4
+
5
+ ### Fixes
6
+
7
+ - The build warns when `script-src` uses `'strict-dynamic'` and the build contains full prerendered routes. Their hashed bootstrap cannot load its dynamic import under `'strict-dynamic'`, so those routes need `'self'`, dynamic rendering, or PPR.
8
+
9
+ ## 0.16.0
10
+
11
+ ### Breaking
12
+
13
+ - Raised `engines.node` to `^22.23.4 || >=24.22.1`. Node 20 is end of life and does not receive the fix for CVE-2026-111497, a Brotli decoder flaw in Node that allows memory disclosure or code execution when a process decodes attacker-controlled Brotli data. Solas never decodes Brotli itself, but apps on `@jk2908/solas/runtime/node` should run a patched Node, particularly when they fetch responses an attacker can influence, since Node's `fetch` decodes `content-encoding: br` automatically.
14
+
15
+ ### Features
16
+
17
+ - Middleware can pass a replacement request to `next(request)`, so it can add headers for downstream middleware and rendering.
18
+ - A structured `csp` config generates nonce and hash-based policies for dynamic, full, and PPR routes. CSP keywords can be written without their surrounding quotes. It cannot be combined with a `Content-Security-Policy` header in the `headers` config; a request CSP still takes precedence. The static adapter writes the generated policy as a meta tag in each prerendered page.
19
+ - Nonce-based CSP is supported for dynamically rendered HTML. Solas reads the nonce from the effective `Content-Security-Policy` set by middleware, an upstream proxy, or the `headers` config, applies it to the scripts it generates, and exposes it as `x-nonce` from `headers()`. Full routes do not receive nonces; PPR uses a nonce only for its resumed holes.
20
+ - Fully prerendered routes served by Solas receive build-time SHA-256 hashes for their inline scripts and styles. Solas appends the hashes to the effective CSP, so the HTML can stay cacheable without a nonce.
21
+ - PPR responses use hashes for the cached prelude and a request nonce for the resumed holes. Solas puts the nonce on the prelude bootstrap, so `strict-dynamic` and client-side nonce access keep working for the dynamic parts.
22
+ - Client navigations, prefetches, and server actions reuse the document nonce, so inline styles and scripts rendered after the initial load keep matching the document CSP. Hoistable `<style href precedence>` tags, including `@jk2908/cxx`, receive the nonce when the app passes it to the tag.
23
+
3
24
  ## 0.15.1
4
25
 
5
26
  ### Fixes
package/README.md CHANGED
@@ -6,7 +6,7 @@ Solas is experimental and under active development, so expect rough edges.
6
6
 
7
7
  ## Install
8
8
 
9
- Solas requires **Vite 8** (which uses Rolldown as the bundler).
9
+ Solas requires **Vite 8** (which uses Rolldown as the bundler) and **Node.js 22.23.4, 24.22.1 or newer** when using the Node runtime. Node 20 is end of life, and these are the first releases with the fix for [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases), a Brotli decoder flaw in Node. Solas does not decode Brotli, but your app might, including through `fetch`, which decodes `content-encoding: br` for you.
10
10
 
11
11
  ```sh
12
12
  npm install @jk2908/solas react react-dom react-server-dom-webpack vite@^8
@@ -71,7 +71,7 @@ Use these filename conventions:
71
71
  - `+layout.tsx`: shared layout for a route branch.
72
72
  - `+page.tsx`: page component for a route.
73
73
  - `+endpoint.ts`: request handler. Can be placed in any folder and responds to all HTTP methods for its route path.
74
- - `+middleware.ts`: middleware that runs for the current route branch and is inherited by child routes. Parent and child middleware stack together.
74
+ - `+middleware.ts`: middleware that runs for the current route branch and is inherited by child routes. Parent and child middleware stack together. Call `next()` to continue, or `next(request)` to pass a replacement request with different headers downstream.
75
75
  - `+loading.tsx`: loading fallback inherited by child routes.
76
76
  - `+401.tsx`: boundary for unauthorised responses in the current route branch and its children.
77
77
  - `+403.tsx`: boundary for forbidden responses in the current route branch and its children.
@@ -478,17 +478,7 @@ solas({
478
478
  })
479
479
  ```
480
480
 
481
- Pass a function to vary the headers per request, for example a per-request CSP nonce:
482
-
483
- ```ts
484
- solas({
485
- headers: req => ({
486
- 'Content-Security-Policy': `default-src 'self'; script-src 'nonce-${nonce(req)}'`,
487
- }),
488
- })
489
- ```
490
-
491
- The function can be async. Configured headers win over any header Solas sets.
481
+ Pass a function to vary the headers per request. The function can be async. Configured headers win over any header Solas sets, except that a `Content-Security-Policy` on the request takes precedence over a configured one. See [Security > CSP](#csp) for the nonce pattern.
492
482
 
493
483
  ### `sitemap`
494
484
 
@@ -814,6 +804,49 @@ If you run Vite through Bun, use the Bun-backed commands instead:
814
804
 
815
805
  ## Security
816
806
 
807
+ ### CSP
808
+
809
+ Use the `csp` config to generate a policy:
810
+
811
+ ```ts
812
+ solas({
813
+ csp: {
814
+ directives: {
815
+ 'default-src': ['self'],
816
+ 'script-src': ['self'],
817
+ 'style-src': ['self'],
818
+ 'img-src': ['self', 'data:'],
819
+ 'object-src': ['none'],
820
+ 'base-uri': ['self'],
821
+ },
822
+ },
823
+ })
824
+ ```
825
+
826
+ CSP keywords such as `self` and `none` can be written without their surrounding quotes. Solas adds a per-request nonce to dynamic responses, build-time hashes to full prerenders, and both to PPR. It also keeps client navigations aligned with the document policy.
827
+
828
+ Read the nonce in a server component for any inline `<style>` or `<script>` you render:
829
+
830
+ ```tsx
831
+ const nonce = (await headers()).get('x-nonce')
832
+
833
+ return <style nonce={nonce}>{`body { margin: 0 }`}</style>
834
+ ```
835
+
836
+ Hoistable `<style href precedence>` tags, including `@jk2908/cxx`, need the same nonce prop. Add `'unsafe-eval'` in development for React's error stacks.
837
+
838
+ Full hash-only pages should use `script-src 'self'` rather than `'strict-dynamic'`. Browsers do not propagate hash trust from the bootstrap to its dynamic `import()`. Dynamic and PPR responses can use `'strict-dynamic'` because their bootstrap has a nonce.
839
+
840
+ To use a completely custom policy, set CSP in middleware or a `headers` function. A CSP on the request wins. When `csp` is configured, it owns the generated HTML policy; a CSP returned from a `headers` function is not used for HTML. Use one source.
841
+
842
+ With the static adapter, the generated policy is written as a `<meta http-equiv="Content-Security-Policy">` tag in each prerendered page. `frame-ancestors`, `report-uri`, and `sandbox` are ignored in meta; use host-level headers if you need them.
843
+
844
+ Solas expects one effective CSP policy. If middleware or a proxy sets multiple policies, combine them into a single header before Solas sees the response. The Fetch API merges multiple headers, and Solas cannot safely add hashes to that merged value.
845
+
846
+ The build warns when the generated CSP header for a route exceeds 8 KB because of the prerendered style hash union. Reduce hash-bearing prerendered styles or raise the server header limit in that case.
847
+
848
+ Hashes cover inline `<script>` and `<style>` elements, not `style` attributes. If full static pages can insert inline styles during client navigation, keep `style-src 'unsafe-inline'` for those pages.
849
+
817
850
  ### CSRF
818
851
 
819
852
  Solas protects server actions and `+endpoint` handlers against CSRF.
@@ -20,5 +20,6 @@ export type SolasCloudflareOptions = {
20
20
  export declare function adapter(options?: SolasCloudflareOptions): {
21
21
  name: string;
22
22
  plugins: PluginOption[];
23
+ postbuild(config: import("vite").ResolvedConfig): import("../../internal/postbuild.js").PrerenderImport | undefined;
23
24
  };
24
25
  export {};
@@ -3,7 +3,6 @@ import path from 'node:path';
3
3
  import { cloudflare as cloudflareVitePlugin } from '@cloudflare/vite-plugin';
4
4
  import * as Config from '../../config.js';
5
5
  import { writeCloudflareEntry } from '../../internal/codegen/environments.js';
6
- import { setPrerenderImport, setRscEntry } from '../../internal/postbuild.js';
7
6
  import { NAMESPACE_PREFIX } from '../../runtime/cloudflare/assets.js';
8
7
  import { withBindingsShim } from './bindings.js';
9
8
  const VIRTUAL_ENTRY = 'virtual:solas-cloudflare-entry';
@@ -120,18 +119,6 @@ export function adapter(options = {}) {
120
119
  const adapter = {
121
120
  name: 'solas-cloudflare',
122
121
  enforce: 'post',
123
- configResolved(config) {
124
- // the Cloudflare plugin points the `rsc` environment's build output at
125
- // the Worker bundle during its `config` hook, so by now we know where
126
- // Solas's prerender step should import the Worker from
127
- const rscOutDir = config.environments.rsc?.build.outDir;
128
- if (rscOutDir) {
129
- setRscEntry(path.resolve(cwd, rscOutDir, 'index.js'));
130
- }
131
- // the Worker bundle imports `cloudflare:workers`, which only exists in
132
- // workerd, so shim it around the Node import
133
- setPrerenderImport(withBindingsShim);
134
- },
135
122
  configEnvironment(name, environment) {
136
123
  // Pre-bundle the Worker environments so workerd never has to re-optimize
137
124
  // dependencies in the middle of a request. Scanning the app directory
@@ -168,5 +155,15 @@ export function adapter(options = {}) {
168
155
  },
169
156
  };
170
157
  const plugins = [adapter, ...cfPlugins];
171
- return { name: 'cloudflare', plugins };
158
+ return {
159
+ name: 'cloudflare',
160
+ plugins,
161
+ postbuild(config) {
162
+ const rscOutDir = config.environments.rsc?.build.outDir;
163
+ if (!rscOutDir)
164
+ return;
165
+ const entry = path.resolve(cwd, rscOutDir, 'index.js');
166
+ return withBindingsShim(() => import(/* @vite-ignore */ entry));
167
+ },
168
+ };
172
169
  }
@@ -1,7 +1,10 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import * as Config from '../config.js';
4
+ import { buildPolicy, injectMetaTag } from '../internal/csp.js';
4
5
  import { Features, guards } from '../internal/guards.js';
6
+ import { staticRoutePath } from '../internal/prerender.js';
7
+ import { loadManifest } from '../manifest.js';
5
8
  const ROOT_FILES = ['sitemap.xml', 'robots.txt'];
6
9
  /**
7
10
  * Static site adapter for Solas.
@@ -41,6 +44,35 @@ function adapter(options = {}) {
41
44
  await fs.copyFile(source, path.join(target, file));
42
45
  }
43
46
  }
47
+ const manifest = await loadManifest();
48
+ const csp = manifest?.csp;
49
+ if (!csp)
50
+ return;
51
+ const mode = manifest.trailingSlash === 'always' ? 'always' : 'never';
52
+ const styleHashes = [
53
+ ...new Set(Object.values(manifest.artifacts).flatMap(artifact => artifact.csp?.style ?? [])),
54
+ ];
55
+ await Promise.all(Object.entries(manifest.artifacts).map(async ([route, artifact]) => {
56
+ if (artifact.mode !== 'full')
57
+ return;
58
+ const file = path.join(target, staticRoutePath(route, mode));
59
+ let html;
60
+ try {
61
+ html = await fs.readFile(file, 'utf-8');
62
+ }
63
+ catch {
64
+ return;
65
+ }
66
+ const policy = buildPolicy(csp.directives, {
67
+ hashes: {
68
+ script: artifact.csp?.script ?? [],
69
+ style: [...new Set([...(artifact.csp?.style ?? []), ...styleHashes])],
70
+ },
71
+ });
72
+ if (!policy)
73
+ return;
74
+ await fs.writeFile(file, injectMetaTag(html, policy));
75
+ }));
44
76
  },
45
77
  },
46
78
  };
package/dist/config.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isRecord, isStringArray } from './utils/validation.js';
1
2
  export const NAME = 'Solas';
2
3
  export const SLUG = NAME.toLowerCase();
3
4
  export const PKG_NAME = `@jk2908/${SLUG}`;
@@ -18,6 +19,7 @@ export const TRAILING_SLASH_MODES = ['always', 'never', 'ignore'];
18
19
  export const RUNTIME_MANIFEST = 'runtime-manifest.json';
19
20
  const CONFIG_KEYS = new Set([
20
21
  'adapter',
22
+ 'csp',
21
23
  'headers',
22
24
  'logger',
23
25
  'metadata',
@@ -85,6 +87,36 @@ export function validate(input, envUrl) {
85
87
  errors.push('headers must be an object or a function that returns one');
86
88
  }
87
89
  }
90
+ if ('csp' in input && input.csp !== undefined) {
91
+ const csp = input.csp;
92
+ if (!isRecord(csp)) {
93
+ errors.push('csp must be an object');
94
+ }
95
+ else {
96
+ for (const key of Object.keys(csp)) {
97
+ if (key !== 'directives') {
98
+ errors.push(`Unknown csp key: ${key}`);
99
+ }
100
+ }
101
+ if (!isRecord(csp.directives)) {
102
+ errors.push('csp.directives must be an object of string arrays');
103
+ }
104
+ else {
105
+ for (const [name, values] of Object.entries(csp.directives)) {
106
+ if (!isStringArray(values)) {
107
+ errors.push(`csp.directives.${name} must be an array of strings`);
108
+ }
109
+ }
110
+ }
111
+ }
112
+ if (isRecord(input.headers)) {
113
+ for (const key of Object.keys(input.headers)) {
114
+ if (key.toLowerCase() === 'content-security-policy') {
115
+ errors.push('csp cannot be combined with a Content-Security-Policy header');
116
+ }
117
+ }
118
+ }
119
+ }
88
120
  if ('trustedOrigins' in input && input.trustedOrigins !== undefined) {
89
121
  if (!Array.isArray(input.trustedOrigins)) {
90
122
  errors.push('trustedOrigins must be an array of origins');
@@ -159,6 +191,3 @@ export function validate(input, envUrl) {
159
191
  }
160
192
  return input;
161
193
  }
162
- function isRecord(value) {
163
- return typeof value === 'object' && value !== null && !Array.isArray(value);
164
- }
package/dist/index.js CHANGED
@@ -28,7 +28,7 @@ const DEFAULT_CONFIG = {
28
28
  */
29
29
  function normaliseOrigin(value) {
30
30
  if (!value)
31
- return undefined;
31
+ return;
32
32
  return new URL(value).origin;
33
33
  }
34
34
  function solas(c) {
@@ -356,7 +356,13 @@ function solas(c) {
356
356
  buildApp: {
357
357
  order: 'post',
358
358
  async handler() {
359
- await postbuild(resolvedViteConfig?.root ?? process.cwd());
359
+ if (!resolvedViteConfig)
360
+ throw new Error('Vite config not resolved yet');
361
+ const load = await config.adapter?.postbuild?.(resolvedViteConfig);
362
+ await postbuild(resolvedViteConfig.root, {
363
+ csp: config.csp,
364
+ load,
365
+ });
360
366
  },
361
367
  },
362
368
  };
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { createContext, useCallback, useEffect, useMemo, useRef } from 'react';
4
4
  import { createFromFetch } from '@vitejs/plugin-rsc/browser';
5
+ import { getDocumentNonce } from '../../utils/get-document-nonce.js';
5
6
  import { Logger } from '../../utils/logger.js';
6
7
  import * as Events from '../../events.js';
7
8
  import { BrowserRouterHistory } from './history.js';
@@ -43,9 +44,14 @@ function getPayloadUrl(path) {
43
44
  return url;
44
45
  }
45
46
  function performNavigationFetch(path, init) {
46
- return STATIC
47
- ? fetch(getPayloadUrl(path), init)
48
- : fetch(path, { ...init, headers: { ...init?.headers, accept: 'text/x-component' } });
47
+ if (STATIC)
48
+ return fetch(getPayloadUrl(path), init);
49
+ const headers = new Headers(init?.headers);
50
+ headers.set('accept', 'text/x-component');
51
+ const nonce = getDocumentNonce();
52
+ if (nonce)
53
+ headers.set('x-solas-nonce', nonce);
54
+ return fetch(path, { ...init, headers });
49
55
  }
50
56
  const MAX_HASH_ATTEMPTS = 20;
51
57
  function nextFrame(callback) {
@@ -0,0 +1,37 @@
1
+ import type { CspDirectives } from '../types.js';
2
+ type PolicyInput = {
3
+ nonce?: string;
4
+ hashes?: CspHashes;
5
+ };
6
+ export type CspHashes = {
7
+ script: string[];
8
+ style: string[];
9
+ };
10
+ /**
11
+ * Resolve the nonce for a request.
12
+ *
13
+ * On RSC and action requests a document nonce supplied by the client wins.
14
+ * Otherwise the request CSP is authoritative, even when it has no nonce, and
15
+ * configured headers are the fallback.
16
+ */
17
+ export declare function resolveNonce(requestCsp: string | null | undefined, configuredHeaders: Record<string, string>, clientNonce?: string | null): string | undefined;
18
+ /**
19
+ * Build a policy from structured directives, adding a nonce and any prerender
20
+ * hashes that apply to the response.
21
+ */
22
+ export declare function buildPolicy(directives: CspDirectives, { nonce, hashes }?: PolicyInput): string;
23
+ /**
24
+ * Add hash sources to the directives that control inline scripts and styles.
25
+ * A policy that already allows everything through `'unsafe-inline'` or
26
+ * `'none'` is left alone, so adding a hash cannot silently tighten it.
27
+ */
28
+ export declare function addHashesToPolicy(policy: string, hashes: CspHashes): string;
29
+ /**
30
+ * Add the request nonce to the PPR bootstrap script.
31
+ */
32
+ export declare function addNonceToBootstrap(html: string, nonce: string): string;
33
+ /**
34
+ * Add a CSP meta tag to a static document.
35
+ */
36
+ export declare function injectMetaTag(html: string, policy: string): string;
37
+ export {};
@@ -0,0 +1,145 @@
1
+ const KEYWORDS = new Set([
2
+ 'self',
3
+ 'none',
4
+ 'unsafe-inline',
5
+ 'unsafe-eval',
6
+ 'unsafe-hashes',
7
+ 'strict-dynamic',
8
+ 'report-sample',
9
+ 'wasm-unsafe-eval',
10
+ 'inline-speculation-rules',
11
+ 'unsafe-allow-redirects',
12
+ ]);
13
+ /**
14
+ * Resolve the nonce for a request.
15
+ *
16
+ * On RSC and action requests a document nonce supplied by the client wins.
17
+ * Otherwise the request CSP is authoritative, even when it has no nonce, and
18
+ * configured headers are the fallback.
19
+ */
20
+ export function resolveNonce(requestCsp, configuredHeaders, clientNonce) {
21
+ if (clientNonce)
22
+ return clientNonce;
23
+ if (requestCsp)
24
+ return extractNonce(requestCsp);
25
+ return extractNonce(new Headers(configuredHeaders).get('content-security-policy'));
26
+ }
27
+ /**
28
+ * Build a policy from structured directives, adding a nonce and any prerender
29
+ * hashes that apply to the response.
30
+ */
31
+ export function buildPolicy(directives, { nonce, hashes } = {}) {
32
+ const parsed = Object.entries(directives).map(([name, values]) => ({
33
+ name: name.toLowerCase(),
34
+ // keywords such as `self` can be written without quotes; hosts and schemes
35
+ // stay as written.
36
+ tokens: values.map(value => {
37
+ const token = value.trim();
38
+ if (token.length >= 2 && token.startsWith("'") && token.endsWith("'")) {
39
+ return token;
40
+ }
41
+ if (KEYWORDS.has(token.toLowerCase()))
42
+ return `'${token.toLowerCase()}'`;
43
+ if (/^(sha256|sha384|sha512|nonce)-/.test(token))
44
+ return `'${token}'`;
45
+ return token;
46
+ }),
47
+ }));
48
+ if (nonce) {
49
+ const token = `'nonce-${nonce}'`;
50
+ addNonceToDirective(parsed, ['script-src-elem', 'script-src', 'default-src'], token);
51
+ addNonceToDirective(parsed, ['style-src-elem', 'style-src', 'default-src'], token);
52
+ }
53
+ const policy = parsed.map(formatDirective).join('; ');
54
+ return hashes ? addHashesToPolicy(policy, hashes) : policy;
55
+ }
56
+ /**
57
+ * Add hash sources to the directives that control inline scripts and styles.
58
+ * A policy that already allows everything through `'unsafe-inline'` or
59
+ * `'none'` is left alone, so adding a hash cannot silently tighten it.
60
+ */
61
+ export function addHashesToPolicy(policy, hashes) {
62
+ const directives = policy
63
+ .split(';')
64
+ .map(part => part.trim())
65
+ .filter(Boolean)
66
+ .map(parseDirective);
67
+ addHashesToDirective(directives, 'script', hashes);
68
+ addHashesToDirective(directives, 'style', hashes);
69
+ return directives.map(formatDirective).join('; ');
70
+ }
71
+ /**
72
+ * Add the request nonce to the PPR bootstrap script.
73
+ */
74
+ export function addNonceToBootstrap(html, nonce) {
75
+ const match = /<script\b[^>]*\bid="_R_"[^>]*>/i.exec(html);
76
+ if (!match || /\bnonce\s*=/i.test(match[0]))
77
+ return html;
78
+ const tag = match[0].replace(/>$/, ` nonce="${escapeAttribute(nonce)}">`);
79
+ return html.slice(0, match.index) + tag + html.slice(match.index + match[0].length);
80
+ }
81
+ /**
82
+ * Add a CSP meta tag to a static document.
83
+ */
84
+ export function injectMetaTag(html, policy) {
85
+ if (/<meta\b[^>]*http-equiv=["']?content-security-policy/i.test(html)) {
86
+ return html;
87
+ }
88
+ const head = /<head\b[^>]*>/i.exec(html);
89
+ if (!head)
90
+ return html;
91
+ const headEnd = head.index + head[0].length;
92
+ const charset = /<meta\b[^>]*charset=[^>]*>/i.exec(html.slice(headEnd));
93
+ const tag = `<meta http-equiv="Content-Security-Policy" content="${escapeAttribute(policy)}">`;
94
+ const at = charset ? headEnd + charset.index + charset[0].length : headEnd;
95
+ return html.slice(0, at) + tag + html.slice(at);
96
+ }
97
+ function extractNonce(csp) {
98
+ return csp?.match(/'nonce-([^']+)'/)?.[1] || undefined;
99
+ }
100
+ function addNonceToDirective(directives, names, token) {
101
+ const directive = names
102
+ .map(name => directives.find(candidate => candidate.name === name))
103
+ .find(Boolean);
104
+ if (!directive)
105
+ return;
106
+ if (directive.tokens.some(value => value.toLowerCase() === "'unsafe-inline'" || value.toLowerCase() === "'none'")) {
107
+ return;
108
+ }
109
+ if (!directive.tokens.includes(token))
110
+ directive.tokens.push(token);
111
+ }
112
+ function addHashesToDirective(directives, kind, hashes) {
113
+ const values = hashes[kind];
114
+ if (values.length === 0)
115
+ return;
116
+ const names = kind === 'script'
117
+ ? ['script-src-elem', 'script-src', 'default-src']
118
+ : ['style-src-elem', 'style-src', 'default-src'];
119
+ const directive = names
120
+ .map(name => directives.find(candidate => candidate.name === name))
121
+ .find(Boolean);
122
+ if (!directive)
123
+ return;
124
+ if (directive.tokens.some(token => token.toLowerCase() === "'unsafe-inline'" || token.toLowerCase() === "'none'")) {
125
+ return;
126
+ }
127
+ for (const value of values) {
128
+ if (!directive.tokens.includes(value))
129
+ directive.tokens.push(value);
130
+ }
131
+ }
132
+ function parseDirective(value) {
133
+ const [name, ...tokens] = value.split(/\s+/);
134
+ return { name: name.toLowerCase(), tokens };
135
+ }
136
+ function formatDirective(directive) {
137
+ return [directive.name, ...directive.tokens].join(' ');
138
+ }
139
+ function escapeAttribute(value) {
140
+ return value
141
+ .replace(/&/g, '&amp;')
142
+ .replace(/"/g, '&quot;')
143
+ .replace(/</g, '&lt;')
144
+ .replace(/>/g, '&gt;');
145
+ }
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { StrictMode, Suspense, useCallback, useState, useTransition } from 'react';
3
3
  import { hydrateRoot } from 'react-dom/client';
4
4
  import { createFromFetch, createFromReadableStream, createTemporaryReferenceSet, encodeReply, setServerCallback, } from '@vitejs/plugin-rsc/browser';
5
+ import { getDocumentNonce } from '../../utils/get-document-nonce.js';
5
6
  import { BrowserRouterProvider } from '../browser-router/router.js';
6
7
  import { RedirectBoundary } from '../navigation/redirect-boundary.js';
7
8
  import { Head } from '../render/head.js';
@@ -34,12 +35,14 @@ export async function browser() {
34
35
  setServerCallback(async (id, args) => {
35
36
  const url = new URL(window.location.href);
36
37
  const temporaryReferences = createTemporaryReferenceSet();
38
+ const headers = new Headers({ 'x-rsc-action-id': id });
39
+ const nonce = getDocumentNonce();
40
+ if (nonce)
41
+ headers.set('x-solas-nonce', nonce);
37
42
  const payload = await createFromFetch(fetch(url, {
38
43
  method: 'POST',
39
44
  body: await encodeReply(args, { temporaryReferences }),
40
- headers: {
41
- 'x-rsc-action-id': id,
42
- },
45
+ headers,
43
46
  }), { temporaryReferences });
44
47
  payloadSetter.current(payload);
45
48
  const { ok, data } = payload.returnValue ?? {};
@@ -53,7 +56,11 @@ export async function browser() {
53
56
  if (import.meta.hot) {
54
57
  import.meta.hot.on?.('rsc:update', async () => {
55
58
  try {
56
- const p = await createFromFetch(fetch(window.location.href, { headers: { Accept: 'text/x-component' } }));
59
+ const headers = new Headers({ Accept: 'text/x-component' });
60
+ const nonce = getDocumentNonce();
61
+ if (nonce)
62
+ headers.set('x-solas-nonce', nonce);
63
+ const p = await createFromFetch(fetch(window.location.href, { headers }));
57
64
  payloadSetter.current(p);
58
65
  }
59
66
  catch (err) {
@@ -10,10 +10,12 @@ export declare const RequestContext: {
10
10
  req: SolasRequest;
11
11
  prerender: "full" | "ppr" | null;
12
12
  cache: RequestCache;
13
+ nonce?: string | undefined;
13
14
  };
14
15
  write<R>(value: {
15
16
  req: SolasRequest;
16
17
  prerender: "full" | "ppr" | null;
17
18
  cache: RequestCache;
19
+ nonce?: string | undefined;
18
20
  }, fn: () => R | Promise<R>): R | Promise<R>;
19
21
  };