@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
|
@@ -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
|
};
|
package/dist/internal/env/rsc.js
CHANGED
|
@@ -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
|
|
83
|
+
// mode to `full` so the 404/error shell resolves
|
|
78
84
|
// immediately. In normal request-time rendering
|
|
79
|
-
// we keep mode as null
|
|
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
|
|
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:
|
|
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
|
-
*
|
|
373
|
-
* immutable
|
|
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
|
-
|
|
381
|
-
|
|
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,
|
package/dist/internal/env/ssr.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
56
|
-
|
|
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
|
|
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;
|