@webjsdev/server 0.8.10 → 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 +3 -1
- package/package.json +1 -1
- package/src/actions.js +12 -2
- package/src/auth.js +8 -1
- 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 +188 -27
- package/src/html-cache.js +305 -0
- package/src/session.js +4 -0
- package/src/ssr.js +102 -2
- package/src/vendor.js +9 -6
package/src/session.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { getStore } from './cache.js';
|
|
24
|
+
import { markDynamicAccess } from './context.js';
|
|
24
25
|
|
|
25
26
|
// -- Web Crypto helpers ------------------------------------------------------
|
|
26
27
|
// Same shape as auth.js. We duplicate here rather than share a module
|
|
@@ -378,5 +379,8 @@ export function session(opts = {}) {
|
|
|
378
379
|
export function getSession(req) {
|
|
379
380
|
const s = sessionMap.get(req);
|
|
380
381
|
if (!s) throw new Error('getSession() called outside of session middleware');
|
|
382
|
+
// A session read is per-user, so mark the request dynamic so the server HTML
|
|
383
|
+
// cache excludes it even when the page declared `revalidate` (#241).
|
|
384
|
+
markDynamicAccess();
|
|
381
385
|
return s;
|
|
382
386
|
}
|
package/src/ssr.js
CHANGED
|
@@ -5,6 +5,12 @@ import { importMapTag, vendorIntegrityFor, publishedBuildId } from './importmap.
|
|
|
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,10 +26,43 @@ 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>, actionData?: unknown, status?: number, pageModule?: Record<string, unknown
|
|
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()),
|
|
@@ -103,7 +142,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
103
142
|
// shell. Either way the returned `prefix` ends just past the open <body>
|
|
104
143
|
// and `closer` is the matching `</body></html>`.
|
|
105
144
|
const { prefix, streamBody, closer } = buildDocumentParts(body, wrapOpts);
|
|
106
|
-
|
|
145
|
+
const res = streamingHtmlResponse(
|
|
107
146
|
prefix,
|
|
108
147
|
streamBody,
|
|
109
148
|
closer,
|
|
@@ -117,6 +156,17 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
117
156
|
metadata,
|
|
118
157
|
nonce,
|
|
119
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;
|
|
120
170
|
} catch (err) {
|
|
121
171
|
if (isRedirect(err)) {
|
|
122
172
|
const e = /** @type any */ (err);
|
|
@@ -126,6 +176,15 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
126
176
|
const html = await ssrNotFoundHtml(null, opts);
|
|
127
177
|
return htmlResponse(html, 404, opts.req, url);
|
|
128
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
|
+
}
|
|
129
188
|
// Error paths still need to honor the request's CSP nonce so the
|
|
130
189
|
// error page's boot scripts (when moduleUrls is non-empty) and
|
|
131
190
|
// the meta csp-nonce tag both pass strict-CSP enforcement.
|
|
@@ -194,9 +253,41 @@ function htmlResponse(html, status, req, url, metadata) {
|
|
|
194
253
|
const secure = url ? url.protocol === 'https:' : false;
|
|
195
254
|
headers.append('set-cookie', cookieHeader(newToken(), { secure }));
|
|
196
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');
|
|
197
261
|
return new Response(html, { status, headers });
|
|
198
262
|
}
|
|
199
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
|
+
|
|
200
291
|
/* ------------ internals ------------ */
|
|
201
292
|
|
|
202
293
|
async function ssrNotFoundHtml(notFoundFile, opts) {
|
|
@@ -1213,9 +1304,18 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
1213
1304
|
}
|
|
1214
1305
|
|
|
1215
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');
|
|
1216
1310
|
return new Response(prefix + bodyHtml + closer, { status, headers });
|
|
1217
1311
|
}
|
|
1218
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
|
+
|
|
1219
1319
|
const stream = new ReadableStream({
|
|
1220
1320
|
async start(controller) {
|
|
1221
1321
|
controller.enqueue(encoder.encode(prefix + bodyHtml));
|
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 {
|