@webjsdev/server 0.8.9 → 0.8.11
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 +6 -1
- package/package.json +1 -1
- package/src/actions.js +49 -7
- package/src/api.js +16 -1
- package/src/auth.js +25 -3
- package/src/body-limit.js +291 -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 +139 -16
- package/src/cors.js +245 -0
- package/src/csp.js +218 -0
- package/src/dev.js +397 -31
- package/src/env-schema.js +250 -0
- package/src/headers.js +213 -0
- package/src/html-cache.js +305 -0
- package/src/json.js +11 -2
- package/src/node-version.js +102 -0
- package/src/page-action.js +225 -0
- package/src/session.js +4 -0
- package/src/ssr.js +142 -24
- package/src/vendor.js +9 -6
package/src/ssr.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
-
import { renderToString, isNotFound, isRedirect, lookupModuleUrl, isLazy } from '@webjsdev/core';
|
|
3
|
+
import { renderToString, isNotFound, isRedirect, lookupModuleUrl, isLazy, cspNonce } from '@webjsdev/core';
|
|
4
4
|
import { importMapTag, vendorIntegrityFor, publishedBuildId } from './importmap.js';
|
|
5
5
|
import { jsonForScriptTag } from './script-tag-json.js';
|
|
6
6
|
import { readToken, newToken, cookieHeader } from './csrf.js';
|
|
7
7
|
import { transitiveDeps } from './module-graph.js';
|
|
8
|
+
import { BUFFERED_MARKER, STREAM_MARKER } from './conditional-get.js';
|
|
9
|
+
import {
|
|
10
|
+
readRevalidate,
|
|
11
|
+
readHtmlCache,
|
|
12
|
+
HTML_CACHE_MARKER,
|
|
13
|
+
} from './html-cache.js';
|
|
8
14
|
|
|
9
15
|
/**
|
|
10
16
|
* SSR a matched page route to a Response.
|
|
@@ -20,14 +26,53 @@ import { transitiveDeps } from './module-graph.js';
|
|
|
20
26
|
* @param {import('./router.js').PageRoute} route
|
|
21
27
|
* @param {Record<string,string>} params
|
|
22
28
|
* @param {URL} url
|
|
23
|
-
* @param {{ dev: boolean, appDir: string, req?: Request, moduleGraph?: import('./module-graph.js').ModuleGraph, serverFiles?: Map<string,string> | Set<string
|
|
29
|
+
* @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
30
|
* @returns {Promise<Response>}
|
|
25
31
|
*/
|
|
26
32
|
export async function ssrPage(route, params, url, opts) {
|
|
33
|
+
// Server HTML response cache (ISR for no-build, #241). OPT-IN: only a page
|
|
34
|
+
// that declares `export const revalidate = N` is ever cached (the page
|
|
35
|
+
// module export is the single trigger). The page module is loaded ONCE up
|
|
36
|
+
// front to read that window
|
|
37
|
+
// and is threaded back through `opts.pageModule` so renderChain reuses the
|
|
38
|
+
// same evaluation (no double-load). A cache HIT serves the stored HTML
|
|
39
|
+
// without re-running the page function. Skipped entirely (no opt-in read,
|
|
40
|
+
// no double behaviour) for the page-action re-render (actionData / a non-200
|
|
41
|
+
// status) and for a partial-nav request (X-Webjs-Have), whose bytes depend
|
|
42
|
+
// on the request and must not be shared under the full-URL key.
|
|
43
|
+
const cacheEligible =
|
|
44
|
+
!opts.actionData &&
|
|
45
|
+
!opts.status &&
|
|
46
|
+
!opts.pageModule &&
|
|
47
|
+
!(opts.req && opts.req.headers.get('x-webjs-have'));
|
|
48
|
+
let revalidateSeconds = null;
|
|
49
|
+
if (cacheEligible) {
|
|
50
|
+
try {
|
|
51
|
+
const pageMod = await loadModule(route.file, opts.dev);
|
|
52
|
+
opts = { ...opts, pageModule: pageMod };
|
|
53
|
+
revalidateSeconds = readRevalidate(pageMod);
|
|
54
|
+
if (revalidateSeconds !== null) {
|
|
55
|
+
const hit = await readHtmlCache(url);
|
|
56
|
+
if (hit) return cachedHtmlResponse(hit, opts.req, url);
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// A load / store failure falls through to a normal fresh render: the
|
|
60
|
+
// cache is an optimization, never a correctness dependency. Leave
|
|
61
|
+
// revalidateSeconds as read so the write path still applies when the
|
|
62
|
+
// page loaded but only the store lookup failed.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
27
66
|
const ctx = {
|
|
28
67
|
params,
|
|
29
68
|
searchParams: Object.fromEntries(url.searchParams.entries()),
|
|
30
69
|
url: url.toString(),
|
|
70
|
+
// Populated only when this render is the re-render after a failed page
|
|
71
|
+
// `action` submission (#244). The page function and every layout receive
|
|
72
|
+
// it so they can surface field errors and repopulate inputs from the
|
|
73
|
+
// user's submitted values. Undefined on a normal GET render, so GET output
|
|
74
|
+
// is byte-identical to before this feature.
|
|
75
|
+
actionData: opts.actionData,
|
|
31
76
|
};
|
|
32
77
|
|
|
33
78
|
// Collect metadata across layouts (outermost first) then page.
|
|
@@ -46,7 +91,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
46
91
|
const have = haveHeader
|
|
47
92
|
? new Set(haveHeader.split(',').map((s) => s.trim()).filter(Boolean))
|
|
48
93
|
: null;
|
|
49
|
-
const body = await renderChain(route, ctx, opts.dev, suspenseCtx, have);
|
|
94
|
+
const body = await renderChain(route, ctx, opts.dev, suspenseCtx, have, opts.pageModule);
|
|
50
95
|
// Module URLs for the page + every layout in its chain. These ride
|
|
51
96
|
// the importmap; the browser fetches each file as it walks the
|
|
52
97
|
// import graph. Combined with the modulepreload hints below, this
|
|
@@ -97,17 +142,31 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
97
142
|
// shell. Either way the returned `prefix` ends just past the open <body>
|
|
98
143
|
// and `closer` is the matching `</body></html>`.
|
|
99
144
|
const { prefix, streamBody, closer } = buildDocumentParts(body, wrapOpts);
|
|
100
|
-
|
|
145
|
+
const res = streamingHtmlResponse(
|
|
101
146
|
prefix,
|
|
102
147
|
streamBody,
|
|
103
148
|
closer,
|
|
104
149
|
suspenseCtx,
|
|
105
|
-
200
|
|
150
|
+
// Normally 200. After a failed page `action` submission the caller passes
|
|
151
|
+
// 422 (or another 4xx) so the re-rendered page with field errors carries
|
|
152
|
+
// the right status for both the no-JS reload and the enhanced swap (#244).
|
|
153
|
+
opts.status || 200,
|
|
106
154
|
opts.req,
|
|
107
155
|
url,
|
|
108
156
|
metadata,
|
|
109
157
|
nonce,
|
|
110
158
|
);
|
|
159
|
+
// Server HTML cache write (#241). The page opted in via `revalidate`, so
|
|
160
|
+
// FLAG this candidate for the response funnel rather than writing here: the
|
|
161
|
+
// store decision must see the FINAL response (after segment middleware,
|
|
162
|
+
// which may append a per-user Set-Cookie this code can't see yet). The
|
|
163
|
+
// funnel re-checks every guard via isCacheableResponse, writes the cache,
|
|
164
|
+
// and strips this internal marker. The CSP guard is decided here (the SSR
|
|
165
|
+
// side knows whether a nonce was stamped into the body).
|
|
166
|
+
if (revalidateSeconds !== null && !opts.cspEnabled) {
|
|
167
|
+
res.headers.set(HTML_CACHE_MARKER, String(revalidateSeconds));
|
|
168
|
+
}
|
|
169
|
+
return res;
|
|
111
170
|
} catch (err) {
|
|
112
171
|
if (isRedirect(err)) {
|
|
113
172
|
const e = /** @type any */ (err);
|
|
@@ -117,6 +176,15 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
117
176
|
const html = await ssrNotFoundHtml(null, opts);
|
|
118
177
|
return htmlResponse(html, 404, opts.req, url);
|
|
119
178
|
}
|
|
179
|
+
// APM / Sentry sink (issue #239): a page render error that becomes a 500
|
|
180
|
+
// (an error.js boundary OR the default 500 page) is an unhandled error the
|
|
181
|
+
// app should see in its error tracker. Report it best-effort BEFORE
|
|
182
|
+
// rendering the boundary, so the sink gets the ORIGINAL error even if the
|
|
183
|
+
// boundary itself swallows or transforms it. notFound / redirect are
|
|
184
|
+
// sentinels (control flow), not errors, so they are excluded above.
|
|
185
|
+
if (typeof opts.onError === 'function') {
|
|
186
|
+
try { opts.onError(err); } catch { /* a throwing sink must not affect the response */ }
|
|
187
|
+
}
|
|
120
188
|
// Error paths still need to honor the request's CSP nonce so the
|
|
121
189
|
// error page's boot scripts (when moduleUrls is non-empty) and
|
|
122
190
|
// the meta csp-nonce tag both pass strict-CSP enforcement.
|
|
@@ -185,9 +253,41 @@ function htmlResponse(html, status, req, url, metadata) {
|
|
|
185
253
|
const secure = url ? url.protocol === 'https:' : false;
|
|
186
254
|
headers.append('set-cookie', cookieHeader(newToken(), { secure }));
|
|
187
255
|
}
|
|
256
|
+
// Buffered (string) body: opt into the conditional-GET funnel so a
|
|
257
|
+
// PUBLIC-cacheable page (metadata.cacheControl) gets a weak ETag + 304.
|
|
258
|
+
// The funnel still excludes the no-store default, so a private page is
|
|
259
|
+
// never ETagged. See conditional-get.js.
|
|
260
|
+
headers.set(BUFFERED_MARKER, '1');
|
|
188
261
|
return new Response(html, { status, headers });
|
|
189
262
|
}
|
|
190
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Rebuild a Response from a cached HTML record (#241). The stored body is
|
|
266
|
+
* the stable per-page HTML; the per-response varying bits are re-minted
|
|
267
|
+
* here so a new visitor still gets them: the CSRF cookie is freshly issued
|
|
268
|
+
* when the request lacks one (it is a Set-Cookie header, never part of the
|
|
269
|
+
* cached body), and the published build id is re-read so a post-deploy
|
|
270
|
+
* client sees the current id. The BUFFERED marker opts the cached body into
|
|
271
|
+
* the conditional-GET funnel exactly as a fresh render does, so a cached
|
|
272
|
+
* PUBLIC-cacheable page still 304s. Output is observably identical to the
|
|
273
|
+
* fresh render of the same route within the window.
|
|
274
|
+
*
|
|
275
|
+
* @param {{ body: string, contentType: string, cacheControl: string, status: number }} rec
|
|
276
|
+
* @param {Request | undefined} req
|
|
277
|
+
* @param {URL | undefined} url
|
|
278
|
+
*/
|
|
279
|
+
function cachedHtmlResponse(rec, req, url) {
|
|
280
|
+
const headers = new Headers({ 'content-type': rec.contentType });
|
|
281
|
+
headers.set('cache-control', rec.cacheControl);
|
|
282
|
+
headers.set('x-webjs-build', publishedBuildId());
|
|
283
|
+
if (req && !readToken(req)) {
|
|
284
|
+
const secure = url ? url.protocol === 'https:' : false;
|
|
285
|
+
headers.append('set-cookie', cookieHeader(newToken(), { secure }));
|
|
286
|
+
}
|
|
287
|
+
headers.set(BUFFERED_MARKER, '1');
|
|
288
|
+
return new Response(rec.body, { status: rec.status, headers });
|
|
289
|
+
}
|
|
290
|
+
|
|
191
291
|
/* ------------ internals ------------ */
|
|
192
292
|
|
|
193
293
|
async function ssrNotFoundHtml(notFoundFile, opts) {
|
|
@@ -209,8 +309,12 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
|
|
|
209
309
|
});
|
|
210
310
|
}
|
|
211
311
|
|
|
212
|
-
async function renderChain(route, ctx, dev, suspenseCtx, have) {
|
|
213
|
-
|
|
312
|
+
async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
|
|
313
|
+
// Reuse a caller-supplied page module when present (the page-action
|
|
314
|
+
// re-render passes the exact module whose `action` just ran, so the
|
|
315
|
+
// failure re-render shares that single evaluation instead of re-importing
|
|
316
|
+
// and re-running the module's top-level side effects).
|
|
317
|
+
const page = pageModule || await loadModule(route.file, dev);
|
|
214
318
|
if (!page.default) throw new Error(`Page ${route.file} must have a default export`);
|
|
215
319
|
let tree = await page.default(ctx);
|
|
216
320
|
|
|
@@ -1200,9 +1304,18 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
1200
1304
|
}
|
|
1201
1305
|
|
|
1202
1306
|
if (!ctx.pending.length) {
|
|
1307
|
+
// No pending boundaries: this degrades to a single buffered (string)
|
|
1308
|
+
// flush, so opt it into the conditional-GET funnel like htmlResponse.
|
|
1309
|
+
headers.set(BUFFERED_MARKER, '1');
|
|
1203
1310
|
return new Response(prefix + bodyHtml + closer, { status, headers });
|
|
1204
1311
|
}
|
|
1205
1312
|
|
|
1313
|
+
// Flag a genuinely streamed body so the conditional-GET funnel skips it
|
|
1314
|
+
// (an unflushed stream cannot be hashed without buffering, which would
|
|
1315
|
+
// defeat streaming). The marker is internal and stripped at the funnel
|
|
1316
|
+
// before the response reaches the client. See conditional-get.js.
|
|
1317
|
+
headers.set(STREAM_MARKER, '1');
|
|
1318
|
+
|
|
1206
1319
|
const stream = new ReadableStream({
|
|
1207
1320
|
async start(controller) {
|
|
1208
1321
|
controller.enqueue(encoder.encode(prefix + bodyHtml));
|
|
@@ -1254,10 +1367,17 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
1254
1367
|
}
|
|
1255
1368
|
|
|
1256
1369
|
/**
|
|
1370
|
+
* Import a route module. In prod the URL is stable so Node's module cache
|
|
1371
|
+
* serves a single evaluation; in dev a cache-bust query forces a fresh
|
|
1372
|
+
* evaluation so source edits take effect (which also re-runs the module's
|
|
1373
|
+
* top-level side effects, the reason pages/layouts must keep their top level
|
|
1374
|
+
* side-effect-free). Exported so page-action.js loads the page module the same
|
|
1375
|
+
* way the SSR re-render does.
|
|
1376
|
+
*
|
|
1257
1377
|
* @param {string} file
|
|
1258
1378
|
* @param {boolean} dev
|
|
1259
1379
|
*/
|
|
1260
|
-
async function loadModule(file, dev) {
|
|
1380
|
+
export async function loadModule(file, dev) {
|
|
1261
1381
|
const url = pathToFileURL(file).toString();
|
|
1262
1382
|
const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
|
|
1263
1383
|
return import(url + bust);
|
|
@@ -1275,26 +1395,24 @@ function toUrlPath(file, appDir) {
|
|
|
1275
1395
|
}
|
|
1276
1396
|
|
|
1277
1397
|
/**
|
|
1278
|
-
*
|
|
1279
|
-
*
|
|
1398
|
+
* The CSP nonce for the in-flight request, or undefined if none is in
|
|
1399
|
+
* scope. Delegates to `cspNonce()`, which returns the per-request nonce
|
|
1400
|
+
* the handler MINTED when CSP is enabled (issue #233), or, as a fallback,
|
|
1401
|
+
* the nonce parsed from an inbound `Content-Security-Policy` request
|
|
1402
|
+
* header (the legacy consume-only path). Using the same source as the
|
|
1403
|
+
* `Content-Security-Policy` response header is what guarantees the inline
|
|
1404
|
+
* boot script, the importmap, the modulepreload hints, and the header all
|
|
1405
|
+
* carry the EXACT same nonce: one minted value, no drift.
|
|
1280
1406
|
*
|
|
1281
|
-
*
|
|
1282
|
-
*
|
|
1283
|
-
*
|
|
1284
|
-
* nonce across `script-src` and `style-src` (a per-request
|
|
1285
|
-
* single-nonce model), and webjs only emits `<script>` /
|
|
1286
|
-
* `<link rel="modulepreload">` tags, so reading the first match
|
|
1287
|
-
* is the right behaviour. A future caller that emits styled
|
|
1288
|
-
* inline content under a separate style nonce would need to
|
|
1289
|
-
* extend this to be directive-scoped.
|
|
1407
|
+
* `req` is accepted (and ignored) so existing call sites stay unchanged;
|
|
1408
|
+
* the value comes from the request-scoped AsyncLocalStorage store, not
|
|
1409
|
+
* the argument.
|
|
1290
1410
|
*
|
|
1291
|
-
* @param {Request}
|
|
1411
|
+
* @param {Request} [_req]
|
|
1292
1412
|
* @returns {string | undefined}
|
|
1293
1413
|
*/
|
|
1294
|
-
function getNonce(
|
|
1295
|
-
|
|
1296
|
-
const match = /\bnonce-([A-Za-z0-9+/=]+)/.exec(csp);
|
|
1297
|
-
return match ? match[1] : undefined;
|
|
1414
|
+
function getNonce(_req) {
|
|
1415
|
+
return cspNonce() || undefined;
|
|
1298
1416
|
}
|
|
1299
1417
|
|
|
1300
1418
|
/** @param {string} s */
|
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 {
|