@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/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> }} opts
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
- return streamingHtmlResponse(
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, digestHex } from './crypto-utils.js';
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
- // ETag for downstream caches that strip the `immutable` directive.
1414
- // Bundle filenames already carry the version, so content + ETag
1415
- // round-trip is deterministic per filename.
1416
- const etag = `"${(await digestHex('SHA-1', body)).slice(0, 16)}"`;
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
- 'etag': etag,
1424
+ [BUFFERED_MARKER]: '1',
1422
1425
  },
1423
1426
  });
1424
1427
  } catch {