@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.
@@ -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
  };
@@ -5,6 +5,7 @@ import { Logger } from '../../utils/logger.js';
5
5
  import * as Config from '../../config.js';
6
6
  import * as RuntimeManifest from '../../manifest.js';
7
7
  import { nodeAssets } from '../assets.js';
8
+ import { addHashesToPolicy, buildPolicy, resolveNonce } from '../csp.js';
8
9
  import { createHttpRouter } from '../http-router/create-http-router.js';
9
10
  import { HttpRouter } from '../http-router/router.js';
10
11
  import { isRscRequest, normaliseTrailingSlash, resolveRequestPath, } from '../http-router/utils.js';
@@ -15,6 +16,7 @@ import * as Prerender from '../prerender.js';
15
16
  import { Tree } from '../render/tree.js';
16
17
  import { Resolver } from '../resolver.js';
17
18
  import { processActionRequest } from '../server/actions.js';
19
+ import { createHeaderResolver } from '../server/headers.js';
18
20
  import DefaultErr from '../ui/defaults/error.js';
19
21
  import { RequestContext } from './request-context.js';
20
22
  import { getKnownDigest, isKnownError } from './utils.js';
@@ -44,13 +46,17 @@ function toClientAsset(relativePath) {
44
46
  * Resolves the route match, collects metadata, and returns the stream,
45
47
  * status code, and prerender mode needed by the response layer.
46
48
  */
47
- async function createPayload(req, manifest, importMap, baseMetadata, returnValue, formState, temporaryReferences) {
49
+ async function createPayload(req, manifest, importMap, baseMetadata, returnValue, formState, temporaryReferences, nonce) {
48
50
  const resolver = new Resolver(manifest, importMap);
49
51
  const prerender = req.headers.get(`x-${Config.SLUG}-prerender`) === '1';
50
52
  const url = new URL(req.url);
51
53
  const routedPath = stripBasePath(url.pathname, BASE_PATH) ?? url.pathname;
52
54
  const pathname = routedPath.endsWith('/') && routedPath !== '/' ? routedPath.slice(0, -1) : routedPath;
53
55
  const match = resolver.enhance(resolver.reconcile(pathname, req[Config.REQUEST_META_KEY].match, req[Config.REQUEST_META_KEY].error));
56
+ // build-time prerender requests have no request nonce, and full routes are
57
+ // served from static HTML. PPR responses are dynamic: the cached prelude uses
58
+ // hashes, while the resumed holes need the request nonce.
59
+ const effectiveNonce = prerender || match?.prerender === 'full' ? undefined : nonce;
54
60
  // if there's no match then no user supplied error boundary
55
61
  // has been found, and we should server render a default
56
62
  // error screen
@@ -74,19 +80,21 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
74
80
  return {
75
81
  // this path is a safety fallback when a prerender request
76
82
  // hits an unmatched route. In build prerender we force
77
- // mode to 'full' so the 404/error shell resolves
83
+ // mode to `full` so the 404/error shell resolves
78
84
  // immediately. In normal request-time rendering
79
- // we keep mode as null (obvi)
85
+ // we keep mode as null
80
86
  stream: RequestContext.write({
81
87
  req,
82
88
  prerender: prerender ? 'full' : null,
83
89
  cache: {},
90
+ nonce: effectiveNonce,
84
91
  }, () => renderToReadableStream(rscPayload, {
85
92
  temporaryReferences,
86
93
  onError,
87
94
  })),
88
95
  status: 404,
89
96
  ppr: false,
97
+ nonce: effectiveNonce,
90
98
  };
91
99
  }
92
100
  // check if this route is a candidate for ppr
@@ -115,11 +123,12 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
115
123
  req,
116
124
  prerender: prerender ? (ppr ? 'ppr' : 'full') : null,
117
125
  cache: {},
126
+ nonce: effectiveNonce,
118
127
  }, () => renderToReadableStream(rscPayload, {
119
128
  temporaryReferences,
120
129
  onError,
121
130
  }));
122
- return { stream, status, ppr };
131
+ return { stream, status, ppr, nonce: effectiveNonce };
123
132
  }
124
133
  catch (err) {
125
134
  logger.error('[rsc:render]', err);
@@ -140,6 +149,7 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
140
149
  req,
141
150
  prerender: prerender ? 'full' : null,
142
151
  cache: {},
152
+ nonce: effectiveNonce,
143
153
  }, () => renderToReadableStream({
144
154
  root: (_jsxs("html", { lang: "en", children: [
145
155
  _jsxs("head", { children: [
@@ -157,6 +167,7 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
157
167
  })),
158
168
  status: 500,
159
169
  ppr: false,
170
+ nonce: effectiveNonce,
160
171
  };
161
172
  }
162
173
  }
@@ -171,6 +182,11 @@ export function createHandler(config, manifest, importMap, options = {}) {
171
182
  // requests for /_solas and /_solas/* are reserved
172
183
  const SOLAS_ASSETS_URL_ROOT = `/${Config.ASSETS_DIR}`;
173
184
  const prerenderPathMode = config.trailingSlash === 'always' ? 'always' : 'never';
185
+ const resolveHeaders = createHeaderResolver(config.headers);
186
+ const responseNonce = new WeakMap();
187
+ const clientStyleHashes = [
188
+ ...new Set(Object.values(runtimeManifest?.artifacts ?? {}).flatMap(artifact => artifact.csp?.style ?? [])),
189
+ ];
174
190
  /**
175
191
  * Create the HTTP response for a single incoming request. Runs actions when needed,
176
192
  * converts the payload into component, HTML, or prerender artifact responses, and
@@ -188,7 +204,19 @@ export function createHandler(config, manifest, importMap, options = {}) {
188
204
  url: config.url,
189
205
  });
190
206
  }
191
- const { stream: rscStream, status, ppr, } = await createPayload(req, manifest, importMap, config.metadata, opts.returnValue, opts.formState, opts.temporaryReferences);
207
+ const requestCsp = req.headers.get('content-security-policy');
208
+ const configuredHeaders = await resolveHeaders(req);
209
+ const clientNonce = isRscRequest(req) || req[Config.REQUEST_META_KEY].action
210
+ ? req.headers.get('x-solas-nonce')
211
+ : null;
212
+ const generatedNonce = config.csp && !requestCsp ? clientNonce || crypto.randomUUID() : undefined;
213
+ if (config.csp && generatedNonce) {
214
+ responseNonce.set(req, generatedNonce);
215
+ }
216
+ const nonce = requestCsp
217
+ ? resolveNonce(requestCsp, configuredHeaders, clientNonce)
218
+ : (generatedNonce ?? resolveNonce(null, configuredHeaders, clientNonce));
219
+ const { stream: rscStream, status, ppr, nonce: effectiveNonce, } = await createPayload(req, manifest, importMap, config.metadata, opts.returnValue, opts.formState, opts.temporaryReferences, nonce);
192
220
  const stream = await rscStream;
193
221
  if (isRscRequest(req)) {
194
222
  return new Response(stream, {
@@ -219,7 +247,7 @@ export function createHandler(config, manifest, importMap, options = {}) {
219
247
  // rebuild the route through the nearest matching status boundary
220
248
  req[Config.REQUEST_META_KEY].error = toHttpException(err);
221
249
  try {
222
- const { stream: retriedRscStream, status: retriedStatus } = await createPayload(req, manifest, importMap, config.metadata, opts.returnValue, opts.formState, opts.temporaryReferences);
250
+ const { stream: retriedRscStream, status: retriedStatus } = await createPayload(req, manifest, importMap, config.metadata, opts.returnValue, opts.formState, opts.temporaryReferences, nonce);
223
251
  const retriedStream = await retriedRscStream;
224
252
  return {
225
253
  retriedStatus,
@@ -285,11 +313,11 @@ export function createHandler(config, manifest, importMap, options = {}) {
285
313
  // the cached prelude already carries the static payload, only needs to
286
314
  // stream the html completions for postponed boundaries
287
315
  const resumeStream = await mod.resume(stream, postponedState, {
288
- nonce: undefined,
316
+ nonce: effectiveNonce,
289
317
  injectPayload: false,
290
318
  });
291
319
  const body = prelude
292
- ? Prerender.composePreludeAndResume(prelude, resumeStream)
320
+ ? Prerender.composePreludeAndResume(prelude, resumeStream, effectiveNonce)
293
321
  : resumeStream;
294
322
  return new Response(body, {
295
323
  headers: {
@@ -303,6 +331,7 @@ export function createHandler(config, manifest, importMap, options = {}) {
303
331
  }
304
332
  const htmlStream = await mod.ssr(stream, {
305
333
  formState: opts.formState,
334
+ nonce: effectiveNonce,
306
335
  ppr: runtimePpr,
307
336
  });
308
337
  return new Response(htmlStream, {
@@ -321,6 +350,7 @@ export function createHandler(config, manifest, importMap, options = {}) {
321
350
  if (recovered) {
322
351
  const retriedHtmlStream = await mod.ssr(recovered.retriedStream, {
323
352
  formState: opts.formState,
353
+ nonce: effectiveNonce,
324
354
  ppr: false,
325
355
  });
326
356
  return new Response(retriedHtmlStream, {
@@ -369,16 +399,66 @@ export function createHandler(config, manifest, importMap, options = {}) {
369
399
  }
370
400
  const httpRouter = createHttpRouter(config, manifest, importMap, createResponse, serveFullPrerender);
371
401
  /**
372
- * Add the configured `headers` to a response. Clones the response so
373
- * immutable ones (redirects, cached assets) can still be augmented.
402
+ * Hash sources for a prerendered document. The route's own scripts are
403
+ * immutable, while the union of prerendered styles also covers style tags a
404
+ * client navigation can insert from another route.
405
+ */
406
+ function getCspHashes(req, res) {
407
+ if (!runtimeManifest)
408
+ return;
409
+ const contentType = res.headers.get('content-type');
410
+ if (!contentType?.toLowerCase().startsWith('text/html'))
411
+ return;
412
+ const routedPath = stripBasePath(new URL(req.url).pathname, BASE_PATH);
413
+ if (routedPath == null)
414
+ return;
415
+ const lookupPath = normaliseTrailingSlash(routedPath, prerenderPathMode);
416
+ const artifact = runtimeManifest.artifacts[lookupPath];
417
+ if (!artifact?.csp)
418
+ return;
419
+ return {
420
+ script: artifact.csp.script,
421
+ style: [...new Set([...artifact.csp.style, ...clientStyleHashes])],
422
+ };
423
+ }
424
+ /**
425
+ * Add the configured `headers` and any prerender CSP hashes to a response.
426
+ * Clones the response so immutable ones (redirects, cached assets) can still
427
+ * be augmented.
374
428
  */
375
429
  async function withHeaders(res, req) {
376
- if (!config.headers)
377
- return res;
378
- const entries = typeof config.headers === 'function' ? await config.headers(req) : config.headers;
379
430
  const headers = new Headers(res.headers);
380
- for (const [key, value] of Object.entries(entries))
381
- headers.set(key, value);
431
+ let changed = false;
432
+ if (config.headers) {
433
+ const entries = await resolveHeaders(req);
434
+ const requestCsp = req.headers.get('content-security-policy');
435
+ for (const [key, value] of Object.entries(entries)) {
436
+ if (requestCsp && key.toLowerCase() === 'content-security-policy')
437
+ continue;
438
+ headers.set(key, value);
439
+ changed = true;
440
+ }
441
+ }
442
+ const hashes = getCspHashes(req, res);
443
+ const isHtml = res.headers.get('content-type')?.toLowerCase().startsWith('text/html');
444
+ const nonce = responseNonce.get(req);
445
+ if (config.csp && !req.headers.has('content-security-policy') && isHtml) {
446
+ const built = buildPolicy(config.csp.directives, { nonce, hashes });
447
+ if (built) {
448
+ headers.set('content-security-policy', built);
449
+ changed = true;
450
+ }
451
+ }
452
+ const csp = headers.get('content-security-policy');
453
+ if (hashes && csp) {
454
+ const updated = addHashesToPolicy(csp, hashes);
455
+ if (updated !== csp) {
456
+ headers.set('content-security-policy', updated);
457
+ changed = true;
458
+ }
459
+ }
460
+ if (!changed)
461
+ return res;
382
462
  return new Response(res.body, {
383
463
  status: res.status,
384
464
  statusText: res.statusText,
@@ -32,6 +32,8 @@ async function ssr(rscStream, opts = {}) {
32
32
  if (ppr) {
33
33
  const { prelude } = await reactPrerender(_jsx(A, { payloadPromise: payloadPromise }), {
34
34
  bootstrapScriptContent,
35
+ // @ts-expect-error React 19 accepts the object form; @types/react-dom declares only string
36
+ nonce: nonce ? { script: nonce, style: nonce } : undefined,
35
37
  onError(err) {
36
38
  const digest = getKnownDigest(err);
37
39
  if (digest)
@@ -45,7 +47,8 @@ async function ssr(rscStream, opts = {}) {
45
47
  }
46
48
  const htmlStream = await renderToReadableStream(_jsx(A, { payloadPromise: payloadPromise }), {
47
49
  bootstrapScriptContent,
48
- nonce,
50
+ // @ts-expect-error React 19 accepts the object form; @types/react-dom declares only string
51
+ nonce: { script: nonce, style: nonce },
49
52
  formState,
50
53
  onError(err) {
51
54
  const digest = getKnownDigest(err);
@@ -119,6 +122,8 @@ async function prerender(rscStream, opts = {}) {
119
122
  }
120
123
  const stream = await renderToReadableStream(_jsx(A, { payloadPromise: payloadPromise }), {
121
124
  bootstrapScriptContent,
125
+ // @ts-expect-error React 19 accepts the object form; @types/react-dom declares only string
126
+ nonce: { script: nonce, style: nonce },
122
127
  onError(err) {
123
128
  const digest = getKnownDigest(err);
124
129
  if (digest)
@@ -144,7 +149,8 @@ async function resume(rscStream, postponedState, opts = {}) {
144
149
  const [s1, s2] = rscStream.tee();
145
150
  const payloadPromise = createFromReadableStream(s1);
146
151
  const htmlStream = await reactResume(_jsx(A, { payloadPromise: payloadPromise }), postponedState, {
147
- nonce: opts.nonce,
152
+ // @ts-expect-error React 19 accepts the object form; @types/react-dom declares only string
153
+ nonce: opts.nonce ? { script: opts.nonce, style: opts.nonce } : undefined,
148
154
  onError(err) {
149
155
  const digest = getKnownDigest(err);
150
156
  if (digest)
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Collect CSP hash sources for every non-empty inline script and style in a
3
+ * finished HTML document.
4
+ *
5
+ * The document must be complete: hashes are over the text the browser actually
6
+ * parses, including any escaping already applied to inline scripts.
7
+ */
8
+ export declare function hashInlineAssets(html: string): {
9
+ script: string[];
10
+ style: string[];
11
+ };
@@ -0,0 +1,103 @@
1
+ import { createHash } from 'node:crypto';
2
+ const NAME_CHAR = /[a-zA-Z0-9:_-]/;
3
+ /**
4
+ * Collect CSP hash sources for every non-empty inline script and style in a
5
+ * finished HTML document.
6
+ *
7
+ * The document must be complete: hashes are over the text the browser actually
8
+ * parses, including any escaping already applied to inline scripts.
9
+ */
10
+ export function hashInlineAssets(html) {
11
+ const scripts = new Set();
12
+ const styles = new Set();
13
+ const lower = html.toLowerCase();
14
+ let index = 0;
15
+ while (index < html.length) {
16
+ const open = html.indexOf('<', index);
17
+ if (open === -1)
18
+ break;
19
+ if (html.startsWith('<!--', open)) {
20
+ const end = html.indexOf('-->', open + 4);
21
+ if (end === -1)
22
+ break;
23
+ index = end + 3;
24
+ continue;
25
+ }
26
+ if (html[open + 1] === '!' || html[open + 1] === '?') {
27
+ const end = getTagEnd(html, open + 2);
28
+ if (end === -1)
29
+ break;
30
+ index = end + 1;
31
+ continue;
32
+ }
33
+ const closing = html[open + 1] === '/';
34
+ const nameStart = open + (closing ? 2 : 1);
35
+ if (!NAME_CHAR.test(html[nameStart] ?? '')) {
36
+ index = open + 1;
37
+ continue;
38
+ }
39
+ let nameEnd = nameStart;
40
+ while (nameEnd < html.length && NAME_CHAR.test(html[nameEnd]))
41
+ nameEnd++;
42
+ const name = lower.slice(nameStart, nameEnd);
43
+ const tagEnd = getTagEnd(html, nameEnd);
44
+ if (tagEnd === -1)
45
+ break;
46
+ if (!closing && (name === 'script' || name === 'style')) {
47
+ const end = getRawTextEnd(lower, tagEnd + 1, name);
48
+ if (!end)
49
+ break;
50
+ const content = html.slice(tagEnd + 1, end.start);
51
+ if (content) {
52
+ const source = `'sha256-${createHash('sha256').update(content.replace(/\r\n?/g, '\n')).digest('base64')}'`;
53
+ const hashes = name === 'script' ? scripts : styles;
54
+ hashes.add(source);
55
+ }
56
+ index = end.end;
57
+ continue;
58
+ }
59
+ index = tagEnd + 1;
60
+ }
61
+ return {
62
+ script: [...scripts],
63
+ style: [...styles],
64
+ };
65
+ }
66
+ /**
67
+ * Find the closing `>` of a tag while ignoring brackets inside quoted values.
68
+ */
69
+ function getTagEnd(html, start) {
70
+ let quote;
71
+ for (let index = start; index < html.length; index++) {
72
+ const char = html[index];
73
+ if (quote) {
74
+ if (char === quote)
75
+ quote = undefined;
76
+ continue;
77
+ }
78
+ if (char === '"' || char === "'") {
79
+ quote = char;
80
+ continue;
81
+ }
82
+ if (char === '>')
83
+ return index;
84
+ }
85
+ return -1;
86
+ }
87
+ /**
88
+ * Find the end tag for a raw text element. Script and style bodies are not
89
+ * parsed as HTML, so the first valid `</script` or `</style` wins.
90
+ */
91
+ function getRawTextEnd(lower, start, name) {
92
+ const needle = `</${name}`;
93
+ let index = lower.indexOf(needle, start);
94
+ while (index !== -1) {
95
+ const next = lower[index + needle.length];
96
+ if (next === undefined || next === '>' || next === '/' || /\s/.test(next)) {
97
+ const end = getTagEnd(lower, index + needle.length);
98
+ if (end !== -1)
99
+ return { start: index, end: end + 1 };
100
+ }
101
+ index = lower.indexOf(needle, index + needle.length);
102
+ }
103
+ }
@@ -5,7 +5,7 @@ export declare namespace HttpRouter {
5
5
  type Params = Record<string, string | string[]>;
6
6
  type Handler = (req: SolasRequest) => Response | Promise<Response>;
7
7
  type ErrorHandler = (err: Error, req: SolasRequest) => Response | Promise<Response>;
8
- type Middleware = (req: SolasRequest, next: () => Promise<Response>) => Response | Promise<Response>;
8
+ type Middleware = (req: SolasRequest, next: (request?: Request) => Promise<Response>) => Response | Promise<Response>;
9
9
  type Token = {
10
10
  kind: 'static' | 'dynamic' | 'wildcard';
11
11
  value: string;
@@ -6,6 +6,18 @@ import { maybeAction } from '../server/actions.js';
6
6
  import { enforce } from '../server/csrf.js';
7
7
  import { getAlternatePathname, normaliseTrailingSlash, resolveRequestPath, toPathPattern, } from './utils.js';
8
8
  const BASE_PATH = normaliseBasePath(import.meta.env.BASE_URL);
9
+ /**
10
+ * Copy Solas request metadata onto a request a middleware passes to `next()`.
11
+ * The route match was resolved before middleware ran, so the metadata must
12
+ * survive a request replacement.
13
+ */
14
+ function withRequestMeta(request, source) {
15
+ if (request === source)
16
+ return source;
17
+ return Object.assign(request, {
18
+ [Config.REQUEST_META_KEY]: source[Config.REQUEST_META_KEY],
19
+ });
20
+ }
9
21
  /**
10
22
  * Handle routing and matching for server requests.
11
23
  */
@@ -211,7 +223,7 @@ export class HttpRouter {
211
223
  enforce(request, this.opts.csrf);
212
224
  // keep global middleware outside route middleware and preserve registration order
213
225
  const stack = [...this.#middleware.global, ...matched.route.middleware];
214
- return this.#run(stack, request, () => matched.route.handler?.(request) ?? new Response('Not found', { status: 404 }));
226
+ return this.#run(stack, request, request => matched.route.handler?.(request) ?? new Response('Not found', { status: 404 }));
215
227
  }
216
228
  catch (err) {
217
229
  // turn unknown throws into Error objects so the error hook sees one shape
@@ -232,24 +244,24 @@ export class HttpRouter {
232
244
  */
233
245
  #run(stack, req, next) {
234
246
  // build the middleware chain from the inside out
235
- let run = () => Promise.resolve(next());
247
+ let run = (request) => Promise.resolve(next(request));
236
248
  // wrap each middleware around the current chain
237
249
  for (let i = stack.length - 1; i >= 0; i -= 1) {
238
250
  const handler = stack[i];
239
251
  const prev = run;
240
- run = () => {
252
+ run = (request) => {
241
253
  let called = false;
242
- return Promise.resolve(handler(req, () => {
254
+ return Promise.resolve(handler(request, (replacement) => {
243
255
  // next() can only be used once per middleware call
244
256
  if (called)
245
257
  throw new Error('next() called more than once');
246
258
  called = true;
247
- return prev();
259
+ return prev(withRequestMeta(replacement ?? request, request));
248
260
  }));
249
261
  };
250
262
  }
251
263
  // start the middleware chain
252
- return run();
264
+ return run(req);
253
265
  }
254
266
  /**
255
267
  * Serve a file with optional compression content negotiation.
@@ -1,16 +1,22 @@
1
+ import type { CspConfig } from '../types.js';
1
2
  import * as Prerender from './prerender.js';
2
- export declare function setRscEntry(entry: string): void;
3
3
  type App = Parameters<typeof Prerender.run>[0];
4
- /** Imports the server bundle `postbuild` prerenders with. */
4
+ /**
5
+ * Imports the server bundle `postbuild` prerenders with.
6
+ */
5
7
  export type PrerenderImport = () => Promise<{
6
8
  default: App;
7
9
  }>;
10
+ export type PostbuildOptions = {
11
+ csp?: CspConfig;
12
+ load?: PrerenderImport;
13
+ };
8
14
  /**
9
- * Set a wrapper for the import `postbuild` makes to prerender. It receives the
10
- * plain import and returns one that sets up and tears down platform-specific
11
- * module resolution around it, for example shimming a runtime module that Node
12
- * cannot resolve (such as `cloudflare:workers`).
15
+ * Prerender `full` and `ppr` routes, write their artifacts, collect inline CSP
16
+ * hashes, and write the runtime manifest.
17
+ *
18
+ * @param cwd - Project root. Defaults to the current working directory.
19
+ * @param options - Optional CSP config and RSC bundle loader.
13
20
  */
14
- export declare function setPrerenderImport(wrap: (next: PrerenderImport) => PrerenderImport): void;
15
- export declare function postbuild(cwd?: string): Promise<void>;
21
+ export declare function postbuild(cwd?: string, options?: PostbuildOptions): Promise<void>;
16
22
  export {};
@@ -4,28 +4,20 @@ import * as Compress from '../utils/compress.js';
4
4
  import { Logger } from '../utils/logger.js';
5
5
  import * as Config from '../config.js';
6
6
  import * as Manifest from '../manifest.js';
7
+ import { buildPolicy } from './csp.js';
8
+ import { hashInlineAssets } from './hash-inline-assets.js';
7
9
  import * as Prerender from './prerender.js';
8
10
  const logger = new Logger();
11
+ const CSP_HEADER_SIZE_LIMIT = 8 * 1024;
9
12
  /**
10
- * Overrides the RSC bundle that postbuild imports to prerender routes. Defaults
11
- * to `dist/rsc/index.js`; platform adapters that relocate the server bundle (for
12
- * example Cloudflare, whose Worker bundle lives under `.cloudflare`) set this.
13
+ * Prerender `full` and `ppr` routes, write their artifacts, collect inline CSP
14
+ * hashes, and write the runtime manifest.
15
+ *
16
+ * @param cwd - Project root. Defaults to the current working directory.
17
+ * @param options - Optional CSP config and RSC bundle loader.
13
18
  */
14
- let rscEntryOverride;
15
- export function setRscEntry(entry) {
16
- rscEntryOverride = entry;
17
- }
18
- let prerenderImport;
19
- /**
20
- * Set a wrapper for the import `postbuild` makes to prerender. It receives the
21
- * plain import and returns one that sets up and tears down platform-specific
22
- * module resolution around it, for example shimming a runtime module that Node
23
- * cannot resolve (such as `cloudflare:workers`).
24
- */
25
- export function setPrerenderImport(wrap) {
26
- prerenderImport = wrap;
27
- }
28
- export async function postbuild(cwd = process.cwd()) {
19
+ export async function postbuild(cwd = process.cwd(), options = {}) {
20
+ const { csp, load: customLoad } = options;
29
21
  const manifestPath = path.join(cwd, Config.GENERATED_DIR, 'build.json');
30
22
  let manifest;
31
23
  try {
@@ -52,11 +44,8 @@ export async function postbuild(cwd = process.cwd()) {
52
44
  const concurrency = Prerender.getConcurrency();
53
45
  const pendingWrites = new Set();
54
46
  logger.info('[prerender]', `prerendering ${manifest.prerenderRoutes.length} routes (concurrency: ${concurrency})...`);
55
- const rscEntry = rscEntryOverride ?? path.join(rscDir, 'index.js');
56
- rscEntryOverride = undefined;
57
- const base = () => import(/* @vite-ignore */ rscEntry);
58
- const load = prerenderImport ? prerenderImport(base) : base;
59
- prerenderImport = undefined;
47
+ const entry = path.join(rscDir, 'index.js');
48
+ const load = customLoad ?? (() => import(/* @vite-ignore */ entry));
60
49
  const { default: app } = await load();
61
50
  async function enqueueWrite(task) {
62
51
  const write = task().finally(() => {
@@ -83,6 +72,8 @@ export async function postbuild(cwd = process.cwd()) {
83
72
  }
84
73
  const artifact = result.artifact;
85
74
  const artifactDir = Prerender.getArtifactPath(outDir, route);
75
+ const csp = hashInlineAssets(artifact.html);
76
+ const hashes = csp.script.length > 0 || csp.style.length > 0 ? { csp } : {};
86
77
  await enqueueWrite(async () => {
87
78
  try {
88
79
  if (artifact.mode === 'ppr') {
@@ -105,6 +96,7 @@ export async function postbuild(cwd = process.cwd()) {
105
96
  files: artifact.postponed !== undefined
106
97
  ? ['metadata', 'prelude', 'postponed']
107
98
  : ['metadata', 'prelude'],
99
+ ...hashes,
108
100
  };
109
101
  logger.info('[prerender]', `${route} (ppr)`);
110
102
  return;
@@ -125,6 +117,7 @@ export async function postbuild(cwd = process.cwd()) {
125
117
  artfifactManifest[route] = {
126
118
  mode: artifact.mode,
127
119
  files: ['html'],
120
+ ...hashes,
128
121
  };
129
122
  logger.info(`[prerender]: ${route} (full)`);
130
123
  }
@@ -135,6 +128,29 @@ export async function postbuild(cwd = process.cwd()) {
135
128
  }
136
129
  await Promise.all(pendingWrites);
137
130
  }
131
+ if (csp) {
132
+ const styleHashes = [
133
+ ...new Set(Object.values(artfifactManifest).flatMap(artifact => artifact.csp?.style ?? [])),
134
+ ];
135
+ let largestRoute = '';
136
+ let largestSize = 0;
137
+ for (const [route, artifact] of Object.entries(artfifactManifest)) {
138
+ const policy = buildPolicy(csp.directives, {
139
+ nonce: artifact.mode === 'ppr' ? 'x'.repeat(36) : undefined,
140
+ hashes: {
141
+ script: artifact.csp?.script ?? [],
142
+ style: styleHashes,
143
+ },
144
+ });
145
+ if (policy.length > largestSize) {
146
+ largestRoute = route;
147
+ largestSize = policy.length;
148
+ }
149
+ }
150
+ if (largestSize > CSP_HEADER_SIZE_LIMIT) {
151
+ logger.warn('[csp]', `Generated CSP header for ${largestRoute} is about ${largestSize} bytes. ${CSP_HEADER_SIZE_LIMIT} bytes is a conservative warning threshold; check the response-header limit of your server or proxy. Reduce prerendered style hashes or raise that limit if needed.`);
152
+ }
153
+ }
138
154
  await fs.mkdir(artifactRoot, { recursive: true });
139
155
  // generated files served at the root: written to the output root (for the
140
156
  // asset layer) and into the client output (for the Node server), then listed
@@ -173,6 +189,8 @@ export async function postbuild(cwd = process.cwd()) {
173
189
  const runtimeManifest = {
174
190
  artifacts: artfifactManifest,
175
191
  publicFiles: [...manifest.publicFiles, ...generatedPublicFiles],
192
+ csp,
193
+ trailingSlash: manifest.trailingSlash,
176
194
  };
177
195
  await fs.writeFile(Manifest.getManifestPath(outDir), JSON.stringify(runtimeManifest));
178
196
  if (manifest.precompress) {
@@ -1,5 +1,6 @@
1
1
  import type { BuildContext } from '../types.js';
2
2
  import type { Assets } from './assets.js';
3
+ import type { CspHashes } from './csp.js';
3
4
  export type Mode = 'full' | 'ppr';
4
5
  export type File = 'html' | 'prelude' | 'postponed' | 'metadata';
5
6
  export type Value = {
@@ -14,6 +15,7 @@ export type Metadata = Pick<Value, 'schema' | 'route' | 'createdAt' | 'mode'>;
14
15
  export type Artifact = {
15
16
  mode: Mode;
16
17
  files?: readonly File[];
18
+ csp?: CspHashes;
17
19
  };
18
20
  export type ArtifactManifest = Record<string, Artifact>;
19
21
  /**
@@ -61,7 +63,7 @@ export declare function isCompatible(artifactMetadata: Metadata, pathname: strin
61
63
  * Compose the prelude HTML and the resume stream into a single HTML stream, by injecting the resume stream
62
64
  * into the prelude at the appropriate location (before </body> or </html>).
63
65
  */
64
- export declare function composePreludeAndResume(prelude: string, resumeStream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array<ArrayBufferLike>>;
66
+ export declare function composePreludeAndResume(prelude: string, resumeStream: ReadableStream<Uint8Array>, nonce?: string): ReadableStream<Uint8Array<ArrayBufferLike>>;
65
67
  export type Result = {
66
68
  route: string;
67
69
  artifact: Value;