@jk2908/solas 0.15.0 → 0.16.0

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.0
4
+
5
+ ### Breaking
6
+
7
+ - 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.
8
+
9
+ ### Features
10
+
11
+ - Middleware can pass a replacement request to `next(request)`, so it can add headers for downstream middleware and rendering.
12
+ - 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.
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+
18
+ ## 0.15.1
19
+
20
+ ### Fixes
21
+
22
+ - The README separates build adapters from runtime helpers, so the Node runtime is no longer presented as an adapter.
23
+
3
24
  ## 0.15.0
4
25
 
5
26
  ### Features
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
 
@@ -566,7 +556,11 @@ export default defineConfig({
566
556
  })
567
557
  ```
568
558
 
569
- ## Cloudflare
559
+ ## Adapters
560
+
561
+ Adapters run at build time. Pass one to `solas({ adapter })`; it configures the build output for a platform.
562
+
563
+ ### Cloudflare
570
564
 
571
565
  Solas ships a Cloudflare adapter that runs the RSC server as a Worker and serves prerender artifacts and client assets through the Cloudflare assets binding.
572
566
 
@@ -608,7 +602,7 @@ export default defineConfig({
608
602
 
609
603
  > `cloudflare.config.ts` is an open beta and must be loaded with Node 22.18+ (Bun is not supported), so the Cloudflare build and deploy commands run under Node. See the [`cf` documentation](https://developers.cloudflare.com/cf/) for the format and the `cf dev` / `cf build` / `cf deploy` commands.
610
604
 
611
- ### Bindings
605
+ #### Bindings
612
606
 
613
607
  Bindings are declared in `cloudflare.config.ts` and read with the standard Cloudflare global. The adapter does not manage them:
614
608
 
@@ -654,17 +648,38 @@ The adapter:
654
648
 
655
649
  `htmlHandling: none` stops the asset layer serving a prerendered document at its route path. That matters because the asset layer ignores `Accept`, so an RSC navigation (`Accept: text/x-component`) to a fully-prerendered route would otherwise receive HTML and fail to parse. Routing documents through the Worker lets Solas serve the prerender for `text/html` and render RSC otherwise, while non-HTML assets (JS, CSS, fonts, images) are still served straight from the asset layer.
656
650
 
657
- Because workers have no filesystem, the Worker reads its own output through the `ASSETS` binding. Reads use logical namespaces rather than filesystem paths: `client` maps onto asset-root URLs, and `static` (prerenders) and `artifact` (manifest, ppr) map onto the artifact prefix. During the build-time prerender pass the Worker is invoked without bindings, so the Node asset store is used and reads/writes artifacts on disk.
651
+ `cloudflare()` forwards its `cloudflare` option to `@cloudflare/vite-plugin`.
658
652
 
659
- `cloudflare()` forwards its `cloudflare` option to `@cloudflare/vite-plugin`. To build a Worker manually, `createWorker` composes the entry behaviour, and `createCloudflareAssets` builds the store directly:
653
+ ### Static
654
+
655
+ The static adapter sets `prerender: 'full'` so every route is rendered to HTML at build time, then assembles a directory any static host can serve. There is no server.
660
656
 
661
657
  ```ts
662
- import { createCloudflareAssets } from '@jk2908/solas/runtime/cloudflare'
658
+ // vite.config.ts
659
+ import { defineConfig } from 'vite'
663
660
 
664
- const assets = createCloudflareAssets(env)
661
+ import solas from '@jk2908/solas'
662
+ import staticAdapter from '@jk2908/solas/adapters/static'
663
+ import react from '@vitejs/plugin-react'
664
+
665
+ export default defineConfig({
666
+ plugins: [solas({ adapter: staticAdapter() }), react()],
667
+ })
665
668
  ```
666
669
 
667
- ## Node
670
+ `vite build` writes the site to `build/` (override with `outDir`): the client assets, each route as `<route>.html` (or `<route>/index.html` under `trailingSlash: 'always'`), its RSC payload as `<route>.rsc.txt`, plus `sitemap.xml` and `robots.txt`. Serve that directory with anything — `npx serve build`, GitHub Pages, Netlify, an S3 bucket, nginx.
671
+
672
+ Client-side navigation works with no server: the browser router fetches each route's `<route>.rsc.txt` payload instead of asking a server for `text/x-component`.
673
+
674
+ Because it forces full prerendering, routes that need a server are not a fit: per-route PPR or SSR opt-ins and dynamic routes without `params()`.
675
+
676
+ Server actions and runtime endpoints also need a server, so the build warns when the graph contains any (a `'use server'` module or a `+endpoint` file).
677
+
678
+ ## Runtime helpers
679
+
680
+ Runtime helpers run at request time. They live under `@jk2908/solas/runtime/*` and are never passed to `solas({ adapter })`.
681
+
682
+ ### Node
668
683
 
669
684
  `vite dev` and `vite preview` already run the built handler in Node (via `@vitejs/plugin-rsc`'s default server handler), so nothing extra is needed locally. For a production Node server, use `@jk2908/solas/runtime/node`:
670
685
 
@@ -690,8 +705,6 @@ Set `NODE_ENV=production` before starting. React reads it when the built handler
690
705
 
691
706
  Set `precompress: true` in the Solas config to emit brotli variants of client and prerendered assets at build time. `serve()` negotiates them when the client sends `accept-encoding: br` and falls back to the original file otherwise. Dynamic HTML and RSC responses are streamed per request and are not precompressed, so compress those at a proxy or CDN if you need it.
692
707
 
693
- `serve` is a runtime helper, not a build adapter: it starts an HTTP server and returns it, so it belongs in `server.ts`. Do not pass it to `solas({ adapter })`; that option expects an `Adapter` with `name` and `plugins`, such as `cloudflare()` or the static adapter.
694
-
695
708
  For TLS, mount the handler on your own `https` server:
696
709
 
697
710
  ```ts
@@ -741,32 +754,19 @@ Deno.serve(request => handler.fetch(request))
741
754
 
742
755
  The handler reads `dist` through `node:fs`, which Bun and Deno (2.x, via Node compat) provide. Run from the project root. Bun can also run `@jk2908/solas/runtime/node` as-is, since it implements `node:http`.
743
756
 
744
- ## Static
757
+ ### Cloudflare runtime
745
758
 
746
- The static adapter sets `prerender: 'full'` so every route is rendered to HTML at build time, then assembles a directory any static host can serve. There is no server.
759
+ Because workers have no filesystem, the Worker reads its own output through the `ASSETS` binding. Reads use logical namespaces rather than filesystem paths: `client` maps onto asset-root URLs, and `static` (prerenders) and `artifact` (manifest, ppr) map onto the artifact prefix. During the build-time prerender pass the Worker is invoked without bindings, so the Node asset store is used and reads/writes artifacts on disk.
747
760
 
748
- ```ts
749
- // vite.config.ts
750
- import { defineConfig } from 'vite'
761
+ To build a Worker manually, `createWorker` composes the entry behaviour, and `createCloudflareAssets` builds the store directly:
751
762
 
752
- import solas from '@jk2908/solas'
753
- import staticAdapter from '@jk2908/solas/adapters/static'
754
- import react from '@vitejs/plugin-react'
763
+ ```ts
764
+ import { createCloudflareAssets } from '@jk2908/solas/runtime/cloudflare'
755
765
 
756
- export default defineConfig({
757
- plugins: [solas({ adapter: staticAdapter() }), react()],
758
- })
766
+ const assets = createCloudflareAssets(env)
759
767
  ```
760
768
 
761
- `vite build` writes the site to `build/` (override with `outDir`): the client assets, each route as `<route>.html` (or `<route>/index.html` under `trailingSlash: 'always'`), its RSC payload as `<route>.rsc.txt`, plus `sitemap.xml` and `robots.txt`. Serve that directory with anything — `npx serve build`, GitHub Pages, Netlify, an S3 bucket, nginx.
762
-
763
- Client-side navigation works with no server: the browser router fetches each route's `<route>.rsc.txt` payload instead of asking a server for `text/x-component`.
764
-
765
- Because it forces full prerendering, routes that need a server are not a fit: per-route PPR or SSR opt-ins and dynamic routes without `params()`.
766
-
767
- Server actions and runtime endpoints also need a server, so the build warns when the graph contains any (a `'use server'` module or a `+endpoint` file).
768
-
769
- ## Runtime
769
+ ### Asset store
770
770
 
771
771
  At request time Solas reads its output through an `Assets` interface (`exists`/`readText`/`readBuffer`/`mimeType`) injected into `createHandler`, defaulting to the Node standard library (which Bun runs natively). References are logical (`{ namespace, path }`), where the namespaces are `client` (Vite client output), `static` (full prerenders), and `artifact` (runtime manifest and ppr files). Platforms without a filesystem provide their own store; `@jk2908/solas/runtime/cloudflare` builds one around the `ASSETS` binding via `createWorker`. Build-time reads and writes use `node:fs` directly and are not abstracted.
772
772
 
@@ -804,6 +804,49 @@ If you run Vite through Bun, use the Bun-backed commands instead:
804
804
 
805
805
  ## Security
806
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
+
807
850
  ### CSRF
808
851
 
809
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) {