@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.
@@ -5,6 +5,7 @@ import { Logger } from '../utils/logger.js';
5
5
  import * as Config from '../config.js';
6
6
  import * as Solas from '../solas.js';
7
7
  import { nodeAssets } from './assets.js';
8
+ import { addNonceToBootstrap } from './csp.js';
8
9
  import { toPathPattern } from './http-router/utils.js';
9
10
  const logger = new Logger();
10
11
  const DEFAULT_CONCURRENCY = 4;
@@ -174,7 +175,7 @@ const decoder = new TextDecoder();
174
175
  * Compose the prelude HTML and the resume stream into a single HTML stream, by injecting the resume stream
175
176
  * into the prelude at the appropriate location (before </body> or </html>).
176
177
  */
177
- export function composePreludeAndResume(prelude, resumeStream) {
178
+ export function composePreludeAndResume(prelude, resumeStream, nonce) {
178
179
  // `prelude` is the static shell html as one complete string, usually shaped like
179
180
  // `<html>...<body>static shell...</body></html>` or an html fragment with no close tags
180
181
  // search both cases to avoid duplicating the full string with toLowerCase
@@ -186,7 +187,11 @@ export function composePreludeAndResume(prelude, resumeStream) {
186
187
  return new ReadableStream({
187
188
  async start(controller) {
188
189
  // send everything before the closing tags so the resume stream can be injected
189
- controller.enqueue(encoder.encode(prelude.slice(0, splitAt)));
190
+ const head = prelude.slice(0, splitAt);
191
+ // the shell's scripts are hash-authorised, but the bootstrap also needs
192
+ // the request nonce so its dynamic import can load the module graph and
193
+ // so client code can read the document nonce later
194
+ controller.enqueue(encoder.encode(nonce ? addNonceToBootstrap(head, nonce) : head));
190
195
  // resumeStream is the html React emits when it resumes postponed work for this page. Its
191
196
  // first chunk begins with an extra `</body></html>` pair, then continues with the
192
197
  // resumed scripts and markup for the unfinished work. Strip that leading
@@ -1,5 +1,13 @@
1
+ import type { HeaderEntries, HeadersInput } from '../../types.js';
1
2
  /**
2
3
  * Get the request headers as a read-only map.
3
4
  * @returns A Promise resolving to a read-only map of request headers.
4
5
  */
5
6
  export declare function headers(): Promise<ReadonlyMap<string, string>>;
7
+ /**
8
+ * Resolve the configured response headers once per request.
9
+ *
10
+ * The nonce extracted from a configured CSP and the headers applied to the
11
+ * response must come from the same evaluation.
12
+ */
13
+ export declare function createHeaderResolver(input: HeadersInput | undefined): (req: Request) => Promise<HeaderEntries>;
@@ -6,7 +6,7 @@ import { dynamic } from './dynamic.js';
6
6
  */
7
7
  export async function headers() {
8
8
  await dynamic();
9
- const { req, cache } = RequestContext.use();
9
+ const { req, cache, nonce } = RequestContext.use();
10
10
  // use request cache if possible to avoid reconstructing the map
11
11
  if (cache.headers)
12
12
  return cache.headers;
@@ -14,6 +14,34 @@ export async function headers() {
14
14
  req.headers.forEach((value, key) => {
15
15
  map.set(key, value);
16
16
  });
17
+ // expose the effective CSP nonce to server components under the same header
18
+ // name used by the proxy pattern; a request header alone is not enough
19
+ // because the nonce may come from the configured `headers` fallback
20
+ map.delete('x-solas-nonce');
21
+ map.delete('x-nonce');
22
+ if (nonce)
23
+ map.set('x-nonce', nonce);
17
24
  cache.headers = map;
18
25
  return map;
19
26
  }
27
+ /**
28
+ * Resolve the configured response headers once per request.
29
+ *
30
+ * The nonce extracted from a configured CSP and the headers applied to the
31
+ * response must come from the same evaluation.
32
+ */
33
+ export function createHeaderResolver(input) {
34
+ const cache = new WeakMap();
35
+ return (req) => {
36
+ if (!input)
37
+ return Promise.resolve({});
38
+ if (typeof input !== 'function')
39
+ return Promise.resolve(input);
40
+ let pending = cache.get(req);
41
+ if (!pending) {
42
+ pending = Promise.resolve().then(() => input(req));
43
+ cache.set(req, pending);
44
+ }
45
+ return pending;
46
+ };
47
+ }
@@ -1,11 +1,17 @@
1
1
  import type { Assets } from './internal/assets.js';
2
+ import type { CspConfig } from './types.js';
3
+ import * as Config from './config.js';
2
4
  import * as Prerender from './internal/prerender.js';
3
5
  export type Manifest = {
4
6
  artifacts: Prerender.ArtifactManifest;
5
7
  publicFiles: ReadonlySet<string>;
8
+ csp?: CspConfig;
9
+ trailingSlash?: (typeof Config.TRAILING_SLASH_MODES)[number];
6
10
  };
7
11
  export declare function getManifestPath(outDir: string): string;
8
12
  export declare function loadManifest(assets?: Assets): Promise<{
9
13
  artifacts: Prerender.ArtifactManifest;
10
14
  publicFiles: Set<string>;
15
+ csp: CspConfig | undefined;
16
+ trailingSlash: "always" | "ignore" | "never" | undefined;
11
17
  } | null>;
package/dist/manifest.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isRecord, isStringArray } from './utils/validation.js';
1
2
  import * as Config from './config.js';
2
3
  import { nodeAssets } from './internal/assets.js';
3
4
  export function getManifestPath(outDir) {
@@ -24,38 +25,80 @@ export async function loadManifest(assets = nodeAssets) {
24
25
  return null;
25
26
  if (publicFiles !== undefined && !Array.isArray(publicFiles))
26
27
  return null;
27
- for (const entry of Object.values(artifacts)) {
28
+ let trailingSlash;
29
+ const rawTrailingSlash = value.trailingSlash;
30
+ if (rawTrailingSlash !== undefined) {
31
+ if (rawTrailingSlash === 'always' ||
32
+ rawTrailingSlash === 'never' ||
33
+ rawTrailingSlash === 'ignore') {
34
+ trailingSlash = rawTrailingSlash;
35
+ }
36
+ else {
37
+ return null;
38
+ }
39
+ }
40
+ let cspConfig;
41
+ if (value.csp !== undefined) {
42
+ if (!isRecord(value.csp))
43
+ return null;
44
+ if (!isRecord(value.csp.directives))
45
+ return null;
46
+ const directives = {};
47
+ for (const [name, values] of Object.entries(value.csp.directives)) {
48
+ if (!isStringArray(values))
49
+ return null;
50
+ directives[name] = values;
51
+ }
52
+ cspConfig = { directives };
53
+ }
54
+ const parsedArtifacts = {};
55
+ for (const [route, entry] of Object.entries(artifacts)) {
28
56
  if (!isRecord(entry))
29
57
  return null;
30
- const { mode, files } = entry;
58
+ const { mode, files, csp } = entry;
31
59
  if (mode !== 'full' && mode !== 'ppr')
32
60
  return null;
61
+ const artifact = { mode };
33
62
  if (files !== undefined) {
34
63
  if (!Array.isArray(files))
35
64
  return null;
65
+ const parsedFiles = [];
36
66
  for (const file of files) {
37
- if (file !== 'html' &&
38
- file !== 'prelude' &&
39
- file !== 'postponed' &&
40
- file !== 'metadata') {
67
+ if (!isArtifactFile(file))
41
68
  return null;
42
- }
69
+ parsedFiles.push(file);
43
70
  }
71
+ artifact.files = parsedFiles;
72
+ }
73
+ if (csp !== undefined) {
74
+ if (!isRecord(csp))
75
+ return null;
76
+ if (!isStringArray(csp.script) || !isStringArray(csp.style))
77
+ return null;
78
+ artifact.csp = { script: csp.script, style: csp.style };
44
79
  }
80
+ parsedArtifacts[route] = artifact;
45
81
  }
46
- for (const entry of publicFiles ?? []) {
47
- if (typeof entry !== 'string' || !entry.startsWith('/'))
82
+ const parsedPublicFiles = new Set();
83
+ for (const file of publicFiles ?? []) {
84
+ if (typeof file !== 'string' || !file.startsWith('/'))
48
85
  return null;
86
+ parsedPublicFiles.add(file);
49
87
  }
50
88
  return {
51
- artifacts: artifacts,
52
- publicFiles: new Set(publicFiles ?? []),
89
+ artifacts: parsedArtifacts,
90
+ publicFiles: parsedPublicFiles,
91
+ csp: cspConfig,
92
+ trailingSlash,
53
93
  };
54
94
  }
55
95
  catch {
56
96
  return null;
57
97
  }
58
98
  }
59
- function isRecord(value) {
60
- return typeof value === 'object' && value !== null && !Array.isArray(value);
99
+ function isArtifactFile(value) {
100
+ return (value === 'html' ||
101
+ value === 'prelude' ||
102
+ value === 'postponed' ||
103
+ value === 'metadata');
61
104
  }
package/dist/types.d.ts CHANGED
@@ -1,12 +1,10 @@
1
- type BunRequest = Request & {
2
- params?: Record<string, string | string[]>;
3
- };
4
- import type { PluginOption } from 'vite';
1
+ import type { PluginOption, ResolvedConfig } from 'vite';
5
2
  import { ExportReader } from './utils/export-reader.js';
6
3
  import type * as BrowserRouter from './internal/browser-router/shared.js';
7
4
  import type { HttpRouter } from './internal/http-router/router.js';
8
5
  import type { Metadata } from './internal/metadata.js';
9
6
  import type { HttpException } from './internal/navigation/http-exception.js';
7
+ import type { PrerenderImport } from './internal/postbuild.js';
10
8
  import * as Config from './config.js';
11
9
  import * as Build from './internal/build.js';
12
10
  export type LogLevel = (typeof Config.LOG_LEVELS)[number];
@@ -18,6 +16,18 @@ export type Origin = `http://${string}` | `https://${string}`;
18
16
  */
19
17
  export type HeaderEntries = Record<string, string>;
20
18
  export type HeadersInput = HeaderEntries | ((req: Request) => HeaderEntries | Promise<HeaderEntries>);
19
+ /**
20
+ * CSP directives. Keywords such as `self` and `none` may be written without
21
+ * their surrounding quotes.
22
+ */
23
+ export type CspDirectives = Record<string, readonly string[]>;
24
+ /**
25
+ * Structured CSP configuration. Solas uses nonces for dynamic responses,
26
+ * hashes for prerendered responses, and both for PPR.
27
+ */
28
+ export type CspConfig = {
29
+ directives: CspDirectives;
30
+ };
21
31
  /**
22
32
  * A platform deployment adapter. Solas registers `plugins` alongside its own,
23
33
  * so adapters are configured here instead of being spread into the Vite plugins
@@ -31,6 +41,7 @@ export type Adapter = {
31
41
  * option. A static host has no server, so it needs `'full'`.
32
42
  */
33
43
  prerender?: Route.PrerenderMode | false;
44
+ postbuild?: (config: ResolvedConfig) => PrerenderImport | undefined | Promise<PrerenderImport | undefined>;
34
45
  };
35
46
  type PluginConfigBase = {
36
47
  precompress?: boolean;
@@ -39,6 +50,7 @@ type PluginConfigBase = {
39
50
  trailingSlash?: (typeof Config.TRAILING_SLASH_MODES)[number];
40
51
  trustedOrigins?: readonly string[];
41
52
  headers?: HeadersInput;
53
+ csp?: CspConfig;
42
54
  adapter?: Adapter;
43
55
  readonly logger?: {
44
56
  level?: LogLevel;
@@ -116,7 +128,7 @@ export type MapEntry = {
116
128
  '500s'?: readonly (DynamicImport | null)[];
117
129
  loaders?: readonly (DynamicImport | null)[];
118
130
  middlewares?: readonly (HttpRouter.Middleware | null)[];
119
- endpoint?: (req?: BunRequest) => unknown;
131
+ endpoint?: (req?: Request) => unknown;
120
132
  };
121
133
  export type ImportMap = Record<string, MapEntry>;
122
134
  export type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The CSP nonce for the current document.
3
+ *
4
+ * RSC navigations are part of the same document as the initial page load, so
5
+ * they must render with the same nonce. Read it from a framework script or
6
+ * style tag that already carries it.
7
+ *
8
+ * Use the `.nonce` property rather than a normal attribute: browsers hide the
9
+ * nonce content attribute after parsing so CSS cannot read it.
10
+ */
11
+ export declare function getDocumentNonce(): string | undefined;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The CSP nonce for the current document.
3
+ *
4
+ * RSC navigations are part of the same document as the initial page load, so
5
+ * they must render with the same nonce. Read it from a framework script or
6
+ * style tag that already carries it.
7
+ *
8
+ * Use the `.nonce` property rather than a normal attribute: browsers hide the
9
+ * nonce content attribute after parsing so CSS cannot read it.
10
+ */
11
+ export function getDocumentNonce() {
12
+ if (typeof document === 'undefined')
13
+ return;
14
+ const element = document.querySelector('script[nonce], style[nonce]');
15
+ return element?.nonce || element?.getAttribute('nonce') || undefined;
16
+ }
@@ -0,0 +1,2 @@
1
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
2
+ export declare function isStringArray(value: unknown): value is string[];
@@ -0,0 +1,6 @@
1
+ export function isRecord(value) {
2
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3
+ }
4
+ export function isStringArray(value) {
5
+ return Array.isArray(value) && value.every(item => typeof item === 'string');
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jk2908/solas",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "A Vite + React meta-framework exploring streaming, Server Components, and partial prerendering. Designed for simplicity and lightness",
5
5
  "keywords": [
6
6
  "framework",
@@ -140,6 +140,6 @@
140
140
  }
141
141
  },
142
142
  "engines": {
143
- "node": "^20.19.0 || >=22.12.0"
143
+ "node": "^22.23.4 || >=24.22.1"
144
144
  }
145
145
  }