@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 +21 -0
- package/README.md +85 -42
- package/dist/adapters/cloudflare/vite.d.ts +1 -0
- package/dist/adapters/cloudflare/vite.js +11 -14
- package/dist/adapters/static.js +32 -0
- package/dist/config.js +32 -3
- package/dist/index.js +8 -2
- package/dist/internal/browser-router/router.js +9 -3
- package/dist/internal/csp.d.ts +37 -0
- package/dist/internal/csp.js +145 -0
- package/dist/internal/env/browser.js +11 -4
- package/dist/internal/env/request-context.d.ts +2 -0
- package/dist/internal/env/rsc.js +95 -15
- package/dist/internal/env/ssr.js +8 -2
- package/dist/internal/hash-inline-assets.d.ts +11 -0
- package/dist/internal/hash-inline-assets.js +103 -0
- package/dist/internal/http-router/router.d.ts +1 -1
- package/dist/internal/http-router/router.js +18 -6
- package/dist/internal/postbuild.d.ts +14 -8
- package/dist/internal/postbuild.js +41 -23
- package/dist/internal/prerender.d.ts +3 -1
- package/dist/internal/prerender.js +7 -2
- package/dist/internal/server/headers.d.ts +8 -0
- package/dist/internal/server/headers.js +29 -1
- package/dist/manifest.d.ts +6 -0
- package/dist/manifest.js +56 -13
- package/dist/types.d.ts +17 -5
- package/dist/utils/get-document-nonce.d.ts +11 -0
- package/dist/utils/get-document-nonce.js +16 -0
- package/dist/utils/validation.d.ts +2 -0
- package/dist/utils/validation.js +6 -0
- package/package.json +2 -2
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,
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
651
|
+
`cloudflare()` forwards its `cloudflare` option to `@cloudflare/vite-plugin`.
|
|
658
652
|
|
|
659
|
-
|
|
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
|
-
|
|
658
|
+
// vite.config.ts
|
|
659
|
+
import { defineConfig } from 'vite'
|
|
663
660
|
|
|
664
|
-
|
|
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
|
-
|
|
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
|
-
|
|
757
|
+
### Cloudflare runtime
|
|
745
758
|
|
|
746
|
-
|
|
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
|
-
|
|
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
|
-
|
|
753
|
-
import
|
|
754
|
-
import react from '@vitejs/plugin-react'
|
|
763
|
+
```ts
|
|
764
|
+
import { createCloudflareAssets } from '@jk2908/solas/runtime/cloudflare'
|
|
755
765
|
|
|
756
|
-
|
|
757
|
-
plugins: [solas({ adapter: staticAdapter() }), react()],
|
|
758
|
-
})
|
|
766
|
+
const assets = createCloudflareAssets(env)
|
|
759
767
|
```
|
|
760
768
|
|
|
761
|
-
|
|
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 {
|
|
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
|
}
|
package/dist/adapters/static.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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, '&')
|
|
142
|
+
.replace(/"/g, '"')
|
|
143
|
+
.replace(/</g, '<')
|
|
144
|
+
.replace(/>/g, '>');
|
|
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
|
|
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) {
|