@webjsdev/server 0.8.10 → 0.8.12
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/index.js +4 -1
- package/package.json +4 -2
- package/src/actions.js +21 -3
- package/src/auth.js +8 -1
- package/src/base-path.js +149 -0
- package/src/build-info.js +59 -0
- package/src/cache-fn.js +40 -0
- package/src/cache-tags.js +147 -0
- package/src/conditional-get.js +183 -0
- package/src/context.js +74 -1
- package/src/dev.js +449 -49
- package/src/html-cache.js +305 -0
- package/src/importmap.js +54 -3
- package/src/redirects.js +389 -0
- package/src/route-types.js +176 -0
- package/src/session.js +4 -0
- package/src/ssr.js +210 -9
- package/src/vendor.js +9 -6
- package/webjs-config.schema.json +147 -0
package/src/ssr.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { renderToString, isNotFound, isRedirect, lookupModuleUrl, isLazy, cspNonce } from '@webjsdev/core';
|
|
4
|
-
import { importMapTag, vendorIntegrityFor, publishedBuildId } from './importmap.js';
|
|
4
|
+
import { importMapTag, vendorIntegrityFor, publishedBuildId, basePath } from './importmap.js';
|
|
5
|
+
import { withBasePath } from './base-path.js';
|
|
5
6
|
import { jsonForScriptTag } from './script-tag-json.js';
|
|
6
7
|
import { readToken, newToken, cookieHeader } from './csrf.js';
|
|
7
8
|
import { transitiveDeps } from './module-graph.js';
|
|
9
|
+
import { BUFFERED_MARKER, STREAM_MARKER } from './conditional-get.js';
|
|
10
|
+
import {
|
|
11
|
+
readRevalidate,
|
|
12
|
+
readHtmlCache,
|
|
13
|
+
HTML_CACHE_MARKER,
|
|
14
|
+
} from './html-cache.js';
|
|
8
15
|
|
|
9
16
|
/**
|
|
10
17
|
* SSR a matched page route to a Response.
|
|
@@ -20,10 +27,43 @@ import { transitiveDeps } from './module-graph.js';
|
|
|
20
27
|
* @param {import('./router.js').PageRoute} route
|
|
21
28
|
* @param {Record<string,string>} params
|
|
22
29
|
* @param {URL} url
|
|
23
|
-
* @param {{ dev: boolean, appDir: string, req?: Request, moduleGraph?: import('./module-graph.js').ModuleGraph, serverFiles?: Map<string,string> | Set<string>, actionData?: unknown, status?: number, pageModule?: Record<string, unknown
|
|
30
|
+
* @param {{ dev: boolean, appDir: string, req?: Request, moduleGraph?: import('./module-graph.js').ModuleGraph, serverFiles?: Map<string,string> | Set<string>, actionData?: unknown, status?: number, pageModule?: Record<string, unknown>, cspEnabled?: boolean }} opts
|
|
24
31
|
* @returns {Promise<Response>}
|
|
25
32
|
*/
|
|
26
33
|
export async function ssrPage(route, params, url, opts) {
|
|
34
|
+
// Server HTML response cache (ISR for no-build, #241). OPT-IN: only a page
|
|
35
|
+
// that declares `export const revalidate = N` is ever cached (the page
|
|
36
|
+
// module export is the single trigger). The page module is loaded ONCE up
|
|
37
|
+
// front to read that window
|
|
38
|
+
// and is threaded back through `opts.pageModule` so renderChain reuses the
|
|
39
|
+
// same evaluation (no double-load). A cache HIT serves the stored HTML
|
|
40
|
+
// without re-running the page function. Skipped entirely (no opt-in read,
|
|
41
|
+
// no double behaviour) for the page-action re-render (actionData / a non-200
|
|
42
|
+
// status) and for a partial-nav request (X-Webjs-Have), whose bytes depend
|
|
43
|
+
// on the request and must not be shared under the full-URL key.
|
|
44
|
+
const cacheEligible =
|
|
45
|
+
!opts.actionData &&
|
|
46
|
+
!opts.status &&
|
|
47
|
+
!opts.pageModule &&
|
|
48
|
+
!(opts.req && opts.req.headers.get('x-webjs-have'));
|
|
49
|
+
let revalidateSeconds = null;
|
|
50
|
+
if (cacheEligible) {
|
|
51
|
+
try {
|
|
52
|
+
const pageMod = await loadModule(route.file, opts.dev);
|
|
53
|
+
opts = { ...opts, pageModule: pageMod };
|
|
54
|
+
revalidateSeconds = readRevalidate(pageMod);
|
|
55
|
+
if (revalidateSeconds !== null) {
|
|
56
|
+
const hit = await readHtmlCache(url);
|
|
57
|
+
if (hit) return cachedHtmlResponse(hit, opts.req, url);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// A load / store failure falls through to a normal fresh render: the
|
|
61
|
+
// cache is an optimization, never a correctness dependency. Leave
|
|
62
|
+
// revalidateSeconds as read so the write path still applies when the
|
|
63
|
+
// page loaded but only the store lookup failed.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
27
67
|
const ctx = {
|
|
28
68
|
params,
|
|
29
69
|
searchParams: Object.fromEntries(url.searchParams.entries()),
|
|
@@ -103,7 +143,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
103
143
|
// shell. Either way the returned `prefix` ends just past the open <body>
|
|
104
144
|
// and `closer` is the matching `</body></html>`.
|
|
105
145
|
const { prefix, streamBody, closer } = buildDocumentParts(body, wrapOpts);
|
|
106
|
-
|
|
146
|
+
const res = streamingHtmlResponse(
|
|
107
147
|
prefix,
|
|
108
148
|
streamBody,
|
|
109
149
|
closer,
|
|
@@ -117,6 +157,17 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
117
157
|
metadata,
|
|
118
158
|
nonce,
|
|
119
159
|
);
|
|
160
|
+
// Server HTML cache write (#241). The page opted in via `revalidate`, so
|
|
161
|
+
// FLAG this candidate for the response funnel rather than writing here: the
|
|
162
|
+
// store decision must see the FINAL response (after segment middleware,
|
|
163
|
+
// which may append a per-user Set-Cookie this code can't see yet). The
|
|
164
|
+
// funnel re-checks every guard via isCacheableResponse, writes the cache,
|
|
165
|
+
// and strips this internal marker. The CSP guard is decided here (the SSR
|
|
166
|
+
// side knows whether a nonce was stamped into the body).
|
|
167
|
+
if (revalidateSeconds !== null && !opts.cspEnabled) {
|
|
168
|
+
res.headers.set(HTML_CACHE_MARKER, String(revalidateSeconds));
|
|
169
|
+
}
|
|
170
|
+
return res;
|
|
120
171
|
} catch (err) {
|
|
121
172
|
if (isRedirect(err)) {
|
|
122
173
|
const e = /** @type any */ (err);
|
|
@@ -126,6 +177,15 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
126
177
|
const html = await ssrNotFoundHtml(null, opts);
|
|
127
178
|
return htmlResponse(html, 404, opts.req, url);
|
|
128
179
|
}
|
|
180
|
+
// APM / Sentry sink (issue #239): a page render error that becomes a 500
|
|
181
|
+
// (an error.js boundary OR the default 500 page) is an unhandled error the
|
|
182
|
+
// app should see in its error tracker. Report it best-effort BEFORE
|
|
183
|
+
// rendering the boundary, so the sink gets the ORIGINAL error even if the
|
|
184
|
+
// boundary itself swallows or transforms it. notFound / redirect are
|
|
185
|
+
// sentinels (control flow), not errors, so they are excluded above.
|
|
186
|
+
if (typeof opts.onError === 'function') {
|
|
187
|
+
try { opts.onError(err); } catch { /* a throwing sink must not affect the response */ }
|
|
188
|
+
}
|
|
129
189
|
// Error paths still need to honor the request's CSP nonce so the
|
|
130
190
|
// error page's boot scripts (when moduleUrls is non-empty) and
|
|
131
191
|
// the meta csp-nonce tag both pass strict-CSP enforcement.
|
|
@@ -194,9 +254,41 @@ function htmlResponse(html, status, req, url, metadata) {
|
|
|
194
254
|
const secure = url ? url.protocol === 'https:' : false;
|
|
195
255
|
headers.append('set-cookie', cookieHeader(newToken(), { secure }));
|
|
196
256
|
}
|
|
257
|
+
// Buffered (string) body: opt into the conditional-GET funnel so a
|
|
258
|
+
// PUBLIC-cacheable page (metadata.cacheControl) gets a weak ETag + 304.
|
|
259
|
+
// The funnel still excludes the no-store default, so a private page is
|
|
260
|
+
// never ETagged. See conditional-get.js.
|
|
261
|
+
headers.set(BUFFERED_MARKER, '1');
|
|
197
262
|
return new Response(html, { status, headers });
|
|
198
263
|
}
|
|
199
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Rebuild a Response from a cached HTML record (#241). The stored body is
|
|
267
|
+
* the stable per-page HTML; the per-response varying bits are re-minted
|
|
268
|
+
* here so a new visitor still gets them: the CSRF cookie is freshly issued
|
|
269
|
+
* when the request lacks one (it is a Set-Cookie header, never part of the
|
|
270
|
+
* cached body), and the published build id is re-read so a post-deploy
|
|
271
|
+
* client sees the current id. The BUFFERED marker opts the cached body into
|
|
272
|
+
* the conditional-GET funnel exactly as a fresh render does, so a cached
|
|
273
|
+
* PUBLIC-cacheable page still 304s. Output is observably identical to the
|
|
274
|
+
* fresh render of the same route within the window.
|
|
275
|
+
*
|
|
276
|
+
* @param {{ body: string, contentType: string, cacheControl: string, status: number }} rec
|
|
277
|
+
* @param {Request | undefined} req
|
|
278
|
+
* @param {URL | undefined} url
|
|
279
|
+
*/
|
|
280
|
+
function cachedHtmlResponse(rec, req, url) {
|
|
281
|
+
const headers = new Headers({ 'content-type': rec.contentType });
|
|
282
|
+
headers.set('cache-control', rec.cacheControl);
|
|
283
|
+
headers.set('x-webjs-build', publishedBuildId());
|
|
284
|
+
if (req && !readToken(req)) {
|
|
285
|
+
const secure = url ? url.protocol === 'https:' : false;
|
|
286
|
+
headers.append('set-cookie', cookieHeader(newToken(), { secure }));
|
|
287
|
+
}
|
|
288
|
+
headers.set(BUFFERED_MARKER, '1');
|
|
289
|
+
return new Response(rec.body, { status: rec.status, headers });
|
|
290
|
+
}
|
|
291
|
+
|
|
200
292
|
/* ------------ internals ------------ */
|
|
201
293
|
|
|
202
294
|
async function ssrNotFoundHtml(notFoundFile, opts) {
|
|
@@ -685,15 +777,36 @@ function wrapHead(opts) {
|
|
|
685
777
|
// the request's CSP header by the caller.
|
|
686
778
|
const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
|
|
687
779
|
|
|
688
|
-
|
|
689
|
-
|
|
780
|
+
// Sub-path deployment (issue #256): the boot script's per-route module
|
|
781
|
+
// specifiers and the dev reload `src` are framework-emitted same-origin
|
|
782
|
+
// absolute URLs, so prefix them with the base path (a no-op when empty).
|
|
783
|
+
// The lazy-loader import is a BARE specifier resolved through the importmap
|
|
784
|
+
// (whose target is already base-path-prefixed in importmap.js), so it is
|
|
785
|
+
// NOT prefixed here. The base path is the one set at boot via setBasePath
|
|
786
|
+
// (read from importmap.js's module state), the same value the importmap
|
|
787
|
+
// targets were prefixed with, so the boot specifiers and the map agree.
|
|
788
|
+
const bp = basePath();
|
|
789
|
+
const imports = opts.moduleUrls
|
|
790
|
+
.map((u) => `import ${jsonForScriptTag(withBasePath(u, bp))};`)
|
|
791
|
+
.join('\n');
|
|
792
|
+
const rawLazyEntries = opts.lazyComponents && Object.keys(opts.lazyComponents).length
|
|
690
793
|
? opts.lazyComponents
|
|
691
794
|
: null;
|
|
795
|
+
// The lazy map's values are same-origin module URLs `observeLazy` will
|
|
796
|
+
// dynamically import, so prefix them with the base path too (no-op when
|
|
797
|
+
// empty).
|
|
798
|
+
const lazyEntries = rawLazyEntries && bp
|
|
799
|
+
? Object.fromEntries(
|
|
800
|
+
Object.entries(rawLazyEntries).map(([tag, u]) => [tag, withBasePath(u, bp)]),
|
|
801
|
+
)
|
|
802
|
+
: rawLazyEntries;
|
|
692
803
|
const lazyBoot = lazyEntries
|
|
693
804
|
? `\nimport { observeLazy } from '@webjsdev/core/lazy-loader';\nobserveLazy(${jsonForScriptTag(lazyEntries)});`
|
|
694
805
|
: '';
|
|
695
806
|
const boot = (imports || lazyBoot) ? `<script type="module"${n}>\n${imports}${lazyBoot}\n</script>` : '';
|
|
696
|
-
const reload = opts.dev
|
|
807
|
+
const reload = opts.dev
|
|
808
|
+
? `<script type="module"${n} src="${escapeAttr(withBasePath('/__webjs/reload.js', bp))}"></script>`
|
|
809
|
+
: '';
|
|
697
810
|
const suspenseBoot = opts.streaming
|
|
698
811
|
? `<script${n}>(function(){` +
|
|
699
812
|
`function r(id){var t=document.querySelector('template[data-webjs-resolve="'+id+'"]');` +
|
|
@@ -712,6 +825,8 @@ function wrapHead(opts) {
|
|
|
712
825
|
// alternates, archives, etc.) AND by the preload block further down.
|
|
713
826
|
// Hoist the declaration so the metadata block can push into it.
|
|
714
827
|
const linkTags = [];
|
|
828
|
+
// scriptTags collects JSON-LD structured-data blocks (see m.jsonLd below).
|
|
829
|
+
const scriptTags = [];
|
|
715
830
|
|
|
716
831
|
// Tiny URL resolver against metadataBase. If metadataBase is set and a
|
|
717
832
|
// value looks like a relative URL (no scheme, no `//` prefix), resolve
|
|
@@ -945,6 +1060,23 @@ function wrapHead(opts) {
|
|
|
945
1060
|
}
|
|
946
1061
|
}
|
|
947
1062
|
|
|
1063
|
+
// JSON-LD structured data (schema.org). `m.jsonLd` is a single object
|
|
1064
|
+
// OR an array of objects. The author owns the schema.org shape; the
|
|
1065
|
+
// framework only serializes and HTML-safe-escapes each object into a
|
|
1066
|
+
// `<script type="application/ld+json">` block. A single object emits
|
|
1067
|
+
// ONE script; an array emits one script PER element.
|
|
1068
|
+
//
|
|
1069
|
+
// The block is a NON-EXECUTABLE data island (type application/ld+json),
|
|
1070
|
+
// so CSP script-src does not gate it and it carries NO nonce. Adding one
|
|
1071
|
+
// would wrongly imply it is executable script.
|
|
1072
|
+
if (m.jsonLd != null) {
|
|
1073
|
+
const list = Array.isArray(m.jsonLd) ? m.jsonLd : [m.jsonLd];
|
|
1074
|
+
for (const obj of list) {
|
|
1075
|
+
const tag = jsonLdScript(obj);
|
|
1076
|
+
if (tag) scriptTags.push(tag);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
948
1080
|
// Preload hints: page modules themselves + every discovered component
|
|
949
1081
|
// module, then any custom `metadata.preload` entries (fonts, images, etc.)
|
|
950
1082
|
// (linkTags array was declared earlier so the metadata block above can
|
|
@@ -965,15 +1097,21 @@ function wrapHead(opts) {
|
|
|
965
1097
|
// importmap-rails) applies nonce on every modulepreload tag for
|
|
966
1098
|
// the same reason.
|
|
967
1099
|
const noncePreload = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
|
|
1100
|
+
// Sub-path deployment (issue #256): the modulepreload href is prefixed with
|
|
1101
|
+
// the base path (a no-op when empty), but `crossorigin` / `integrity` are
|
|
1102
|
+
// decided on the ORIGINAL url, so the integrity lookup still keys on the
|
|
1103
|
+
// unprefixed map url and a cross-origin CDN url (never prefixed) keeps its
|
|
1104
|
+
// crossorigin attribute.
|
|
1105
|
+
const bpPreload = basePath();
|
|
968
1106
|
for (const url of opts.moduleUrls) {
|
|
969
1107
|
linkTags.push(
|
|
970
|
-
`<link rel="modulepreload" href="${escapeAttr(url)}"` +
|
|
1108
|
+
`<link rel="modulepreload" href="${escapeAttr(withBasePath(url, bpPreload))}"` +
|
|
971
1109
|
`${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
|
|
972
1110
|
);
|
|
973
1111
|
}
|
|
974
1112
|
for (const url of opts.preloads || []) {
|
|
975
1113
|
linkTags.push(
|
|
976
|
-
`<link rel="modulepreload" href="${escapeAttr(url)}"` +
|
|
1114
|
+
`<link rel="modulepreload" href="${escapeAttr(withBasePath(url, bpPreload))}"` +
|
|
977
1115
|
`${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
|
|
978
1116
|
);
|
|
979
1117
|
}
|
|
@@ -1077,7 +1215,7 @@ ${metaTags.join('\n')}
|
|
|
1077
1215
|
${publicEnvShim({ dev: opts.dev, nonce: opts.nonce })}
|
|
1078
1216
|
${importMapTag({ nonce: opts.nonce })}
|
|
1079
1217
|
${linkTags.join('\n')}
|
|
1080
|
-
${boot}
|
|
1218
|
+
${scriptTags.length ? scriptTags.join('\n') + '\n' : ''}${boot}
|
|
1081
1219
|
${reload}
|
|
1082
1220
|
${suspenseBoot}
|
|
1083
1221
|
</head>
|
|
@@ -1213,9 +1351,18 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
1213
1351
|
}
|
|
1214
1352
|
|
|
1215
1353
|
if (!ctx.pending.length) {
|
|
1354
|
+
// No pending boundaries: this degrades to a single buffered (string)
|
|
1355
|
+
// flush, so opt it into the conditional-GET funnel like htmlResponse.
|
|
1356
|
+
headers.set(BUFFERED_MARKER, '1');
|
|
1216
1357
|
return new Response(prefix + bodyHtml + closer, { status, headers });
|
|
1217
1358
|
}
|
|
1218
1359
|
|
|
1360
|
+
// Flag a genuinely streamed body so the conditional-GET funnel skips it
|
|
1361
|
+
// (an unflushed stream cannot be hashed without buffering, which would
|
|
1362
|
+
// defeat streaming). The marker is internal and stripped at the funnel
|
|
1363
|
+
// before the response reaches the client. See conditional-get.js.
|
|
1364
|
+
headers.set(STREAM_MARKER, '1');
|
|
1365
|
+
|
|
1219
1366
|
const stream = new ReadableStream({
|
|
1220
1367
|
async start(controller) {
|
|
1221
1368
|
controller.enqueue(encoder.encode(prefix + bodyHtml));
|
|
@@ -1324,6 +1471,60 @@ function escapeAttr(s) {
|
|
|
1324
1471
|
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
1325
1472
|
}
|
|
1326
1473
|
|
|
1474
|
+
/**
|
|
1475
|
+
* HTML-safe-escape a JSON string for embedding inside a
|
|
1476
|
+
* `<script type="application/ld+json">` element.
|
|
1477
|
+
*
|
|
1478
|
+
* This is NOT the HTML-entity escaper (escapeHtml / escapeAttr). A
|
|
1479
|
+
* JSON parser reads the raw character, so turning `<` into `<`
|
|
1480
|
+
* would CORRUPT the JSON. Instead we emit the Unicode escape form
|
|
1481
|
+
* (`<`), which a JSON parser decodes back to the original
|
|
1482
|
+
* character while making the literal byte sequence `</script>`
|
|
1483
|
+
* impossible to form in the served HTML. So the embedded data parses
|
|
1484
|
+
* back to the author's exact object, AND a value containing
|
|
1485
|
+
* `</script><img onerror=...>` can never break out of the script tag.
|
|
1486
|
+
*
|
|
1487
|
+
* U+2028 / U+2029 are escaped too: they are valid inside a JSON
|
|
1488
|
+
* string but are line terminators in HTML/JS contexts, and some
|
|
1489
|
+
* consumers choke on them. Escaping keeps the block robust.
|
|
1490
|
+
*
|
|
1491
|
+
* @param {string} json the `JSON.stringify` output
|
|
1492
|
+
* @returns {string}
|
|
1493
|
+
*/
|
|
1494
|
+
function escapeJsonLd(json) {
|
|
1495
|
+
return json
|
|
1496
|
+
.replace(/</g, '\\u003c')
|
|
1497
|
+
.replace(/>/g, '\\u003e')
|
|
1498
|
+
.replace(/&/g, '\\u0026')
|
|
1499
|
+
.replace(/\u2028/g, '\\u2028')
|
|
1500
|
+
.replace(/\u2029/g, '\\u2029');
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* Serialize one schema.org object into a `<script type="application/ld+json">`
|
|
1505
|
+
* block, HTML-safe-escaped via escapeJsonLd. Fails SAFE: a non-object
|
|
1506
|
+
* input, or a circular reference that makes JSON.stringify throw, is
|
|
1507
|
+
* skipped (returns the empty string) with a one-line warn, never breaking
|
|
1508
|
+
* the whole render.
|
|
1509
|
+
*
|
|
1510
|
+
* @param {unknown} obj
|
|
1511
|
+
* @returns {string} the script tag, or '' to skip this element
|
|
1512
|
+
*/
|
|
1513
|
+
function jsonLdScript(obj) {
|
|
1514
|
+
if (!obj || typeof obj !== 'object') return '';
|
|
1515
|
+
try {
|
|
1516
|
+
const json = JSON.stringify(obj);
|
|
1517
|
+
if (typeof json !== 'string') return '';
|
|
1518
|
+
return `<script type="application/ld+json">${escapeJsonLd(json)}</script>`;
|
|
1519
|
+
} catch (err) {
|
|
1520
|
+
console.warn('[webjs] metadata.jsonLd: skipped an entry that could not be serialized:', err && err.message);
|
|
1521
|
+
return '';
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// Internal helpers re-exported for unit testing.
|
|
1526
|
+
export { escapeJsonLd as _escapeJsonLd, jsonLdScript as _jsonLdScript };
|
|
1527
|
+
|
|
1327
1528
|
/**
|
|
1328
1529
|
* Decide whether a `<link rel="modulepreload">` href needs a
|
|
1329
1530
|
* `crossorigin="anonymous"` attribute. True for absolute URLs with
|
package/src/vendor.js
CHANGED
|
@@ -44,7 +44,8 @@ import { readFile, readdir, writeFile, mkdir, unlink, stat, rename } from 'node:
|
|
|
44
44
|
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
45
45
|
import { join, dirname, basename, sep } from 'node:path';
|
|
46
46
|
import { createRequire } from 'node:module';
|
|
47
|
-
import { digestBase64
|
|
47
|
+
import { digestBase64 } from './crypto-utils.js';
|
|
48
|
+
import { BUFFERED_MARKER } from './conditional-get.js';
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* Set of package names whose importmap entries are populated by the
|
|
@@ -1410,15 +1411,17 @@ export async function serveDownloadedBundle(filename, appDir, dev) {
|
|
|
1410
1411
|
// match if any byte didn't round-trip exactly (e.g. invalid
|
|
1411
1412
|
// surrogate replacement). Keep the I/O binary end-to-end.
|
|
1412
1413
|
const body = await readFile(join(pinDir(appDir), filename));
|
|
1413
|
-
//
|
|
1414
|
-
//
|
|
1415
|
-
//
|
|
1416
|
-
|
|
1414
|
+
// Buffered (bytes) body, so opt into the conditional-GET funnel, which
|
|
1415
|
+
// hashes the bytes into a weak ETag (for downstream caches that strip the
|
|
1416
|
+
// `immutable` directive) and honors If-None-Match -> 304. A WEAK validator
|
|
1417
|
+
// is correct here because compression may re-encode the bytes per request
|
|
1418
|
+
// (RFC 7232 2.3.3); the funnel is the single source for that. See
|
|
1419
|
+
// conditional-get.js.
|
|
1417
1420
|
return new Response(body, {
|
|
1418
1421
|
headers: {
|
|
1419
1422
|
'content-type': 'application/javascript; charset=utf-8',
|
|
1420
1423
|
'cache-control': dev ? 'no-cache' : 'public, max-age=31536000, immutable',
|
|
1421
|
-
'
|
|
1424
|
+
[BUFFERED_MARKER]: '1',
|
|
1422
1425
|
},
|
|
1423
1426
|
});
|
|
1424
1427
|
} catch {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://webjs.dev/schemas/webjs-config.schema.json",
|
|
4
|
+
"title": "webjs config block",
|
|
5
|
+
"description": "Schema for the `webjs` object in a webjs app's package.json. Each key maps to a documented server reader. An unknown key is flagged (additionalProperties is false) so a typo no longer silently falls back to the default.",
|
|
6
|
+
"$comment": "Single source of truth lives in THREE co-located places that must stay in lockstep: this schema, the TS type at packages/core/src/webjs-config.d.ts, and the server reader functions (readElideEnabled in dev.js, compileHeaderRules in headers.js, compileRedirectRules / readTrailingSlashPolicy in redirects.js, readBasePath in base-path.js, readCspConfig in csp.js, readBodyLimits / computeServerTimeouts in body-limit.js). Adding a webjs.* key means updating all three. See packages/server/AGENTS.md for the one documented procedure.",
|
|
7
|
+
"type": "object",
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"elide": {
|
|
11
|
+
"description": "Display-only and inert-route dead-JS elision switch. Default true. Set to false to ship every module's JS app-wide, as before the feature existed. Read by readElideEnabled in dev.js; the WEBJS_ELIDE env override wins over this.",
|
|
12
|
+
"type": "boolean",
|
|
13
|
+
"default": true
|
|
14
|
+
},
|
|
15
|
+
"headers": {
|
|
16
|
+
"description": "Per-path response-header rules, shaped like Next's. Each rule pairs a URLPattern source against header directives. Read by compileHeaderRules in headers.js.",
|
|
17
|
+
"type": "array",
|
|
18
|
+
"items": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"required": ["source", "headers"],
|
|
21
|
+
"properties": {
|
|
22
|
+
"source": {
|
|
23
|
+
"description": "Path pattern matched with the native URLPattern API (so :param and :rest* syntax works).",
|
|
24
|
+
"type": "string"
|
|
25
|
+
},
|
|
26
|
+
"headers": {
|
|
27
|
+
"description": "Header directives applied on a match.",
|
|
28
|
+
"type": "array",
|
|
29
|
+
"items": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"required": ["key"],
|
|
32
|
+
"properties": {
|
|
33
|
+
"key": {
|
|
34
|
+
"description": "Header name, e.g. X-Frame-Options.",
|
|
35
|
+
"type": "string"
|
|
36
|
+
},
|
|
37
|
+
"value": {
|
|
38
|
+
"description": "Header value. A null or false value REMOVES the header on a match, the escape hatch that drops a secure default on a path. true is intentionally not allowed (it would stringify to the literal 'true').",
|
|
39
|
+
"oneOf": [{ "type": ["string", "null"] }, { "const": false }]
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"additionalProperties": false
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"additionalProperties": false
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"redirects": {
|
|
50
|
+
"description": "Declarative permanent / temporary redirects for moved URLs. Compiled once at boot by compileRedirectRules in redirects.js. A malformed entry is dropped with a warning.",
|
|
51
|
+
"type": "array",
|
|
52
|
+
"items": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"required": ["source", "destination"],
|
|
55
|
+
"properties": {
|
|
56
|
+
"source": {
|
|
57
|
+
"description": "Path pattern matched with the native URLPattern API (so :param and :rest* syntax works).",
|
|
58
|
+
"type": "string"
|
|
59
|
+
},
|
|
60
|
+
"destination": {
|
|
61
|
+
"description": "Target path, a path referencing named groups captured by source, or an absolute URL. The incoming query string is preserved and merged onto the destination.",
|
|
62
|
+
"type": "string"
|
|
63
|
+
},
|
|
64
|
+
"permanent": {
|
|
65
|
+
"description": "true (the default) is a 308 Permanent Redirect, false is a 307 Temporary Redirect. Both preserve the request method and body. statusCode wins over this when set.",
|
|
66
|
+
"type": "boolean",
|
|
67
|
+
"default": true
|
|
68
|
+
},
|
|
69
|
+
"statusCode": {
|
|
70
|
+
"description": "Explicit redirect status, for a tool needing a legacy code. Wins over permanent. One of 301, 302, 303, 307, 308.",
|
|
71
|
+
"type": "integer",
|
|
72
|
+
"enum": [301, 302, 303, 307, 308]
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"additionalProperties": false
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"trailingSlash": {
|
|
79
|
+
"description": "Trailing-slash canonicalization policy. 'never' strips a trailing slash, 'always' adds one (both via a 308 redirect), 'ignore' (the default) does nothing. An unrecognized value is treated as 'ignore'. Read by readTrailingSlashPolicy in redirects.js.",
|
|
80
|
+
"type": "string",
|
|
81
|
+
"enum": ["never", "always", "ignore"],
|
|
82
|
+
"default": "ignore"
|
|
83
|
+
},
|
|
84
|
+
"basePath": {
|
|
85
|
+
"description": "Sub-path deployment prefix for an app mounted under example.com/app/ behind a proxy that does NOT strip the prefix. 'app', '/app', and '/app/' all normalize to '/app'; an empty value (the default) is a root mount and a pure no-op. The prefix is stripped from the incoming path at ingress and prepended to every framework-emitted URL (importmap targets, modulepreload hints, boot module specifiers, the dev reload src). Read by readBasePath in base-path.js. Author-written <a href> links and client-router navigation are NOT auto-prefixed (a documented follow-up).",
|
|
86
|
+
"type": "string",
|
|
87
|
+
"default": ""
|
|
88
|
+
},
|
|
89
|
+
"csp": {
|
|
90
|
+
"description": "Content-Security-Policy config. Off by default. true enables a strict nonce-based default policy. An object customizes directives and report-only mode. Read by readCspConfig in csp.js.",
|
|
91
|
+
"oneOf": [
|
|
92
|
+
{
|
|
93
|
+
"description": "true enables the strict default policy, false (or absence) disables CSP.",
|
|
94
|
+
"type": "boolean"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"description": "Custom config. directives merges over the strict defaults (a null / false / '' value drops a default directive); reportOnly emits the Content-Security-Policy-Report-Only header. A bare directive map (without a directives key) is also accepted.",
|
|
98
|
+
"type": "object",
|
|
99
|
+
"properties": {
|
|
100
|
+
"directives": {
|
|
101
|
+
"description": "Directive map merged over the strict defaults, e.g. { \"connect-src\": \"'self' https://api.example.com\" }. A __NONCE__ token in a value is replaced with the per-request nonce.",
|
|
102
|
+
"type": "object",
|
|
103
|
+
"additionalProperties": {
|
|
104
|
+
"type": ["string", "null", "boolean"]
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
"reportOnly": {
|
|
108
|
+
"description": "true emits Content-Security-Policy-Report-Only instead of the enforcing header (the staged-rollout path).",
|
|
109
|
+
"type": "boolean",
|
|
110
|
+
"default": false
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
"maxBodyBytes": {
|
|
117
|
+
"description": "JSON / RPC request body cap in bytes. Default 1048576 (1 MiB). A value of 0 disables the cap. The WEBJS_MAX_BODY_BYTES env override wins. Read by readBodyLimits in body-limit.js.",
|
|
118
|
+
"type": "integer",
|
|
119
|
+
"minimum": 0,
|
|
120
|
+
"default": 1048576
|
|
121
|
+
},
|
|
122
|
+
"maxMultipartBytes": {
|
|
123
|
+
"description": "Form / multipart request body cap in bytes. Default 10485760 (10 MiB). A value of 0 disables the cap. The WEBJS_MAX_MULTIPART_BYTES env override wins. Read by readBodyLimits in body-limit.js.",
|
|
124
|
+
"type": "integer",
|
|
125
|
+
"minimum": 0,
|
|
126
|
+
"default": 10485760
|
|
127
|
+
},
|
|
128
|
+
"requestTimeoutMs": {
|
|
129
|
+
"description": "Max time in ms to receive the ENTIRE request (headers plus body). Default 30000. A value of 0 disables the timeout. The WEBJS_REQUEST_TIMEOUT_MS env override wins. Read by computeServerTimeouts in body-limit.js.",
|
|
130
|
+
"type": "integer",
|
|
131
|
+
"minimum": 0,
|
|
132
|
+
"default": 30000
|
|
133
|
+
},
|
|
134
|
+
"headersTimeoutMs": {
|
|
135
|
+
"description": "Max time in ms to receive just the request headers. Default 20000. Clamped strictly under requestTimeoutMs per node semantics. A value of 0 disables the timeout. The WEBJS_HEADERS_TIMEOUT_MS env override wins. Read by computeServerTimeouts in body-limit.js.",
|
|
136
|
+
"type": "integer",
|
|
137
|
+
"minimum": 0,
|
|
138
|
+
"default": 20000
|
|
139
|
+
},
|
|
140
|
+
"keepAliveTimeoutMs": {
|
|
141
|
+
"description": "Idle time in ms before a kept-alive socket is closed. Default 5000. A value of 0 disables the timeout. The WEBJS_KEEP_ALIVE_TIMEOUT_MS env override wins. Read by computeServerTimeouts in body-limit.js.",
|
|
142
|
+
"type": "integer",
|
|
143
|
+
"minimum": 0,
|
|
144
|
+
"default": 5000
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|