@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/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> }} 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()),
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
- return streamingHtmlResponse(
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
- const page = await loadModule(route.file, dev);
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
- * Extract a CSP nonce from the request's Content-Security-Policy header.
1279
- * Matches `'nonce-<base64>'` in the script-src directive.
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
- * The regex matches the first `nonce-...` token anywhere in the
1282
- * header, regardless of which directive it sits under. This is
1283
- * intentional: in practice every reasonable CSP uses the same
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} req
1411
+ * @param {Request} [_req]
1292
1412
  * @returns {string | undefined}
1293
1413
  */
1294
- function getNonce(req) {
1295
- const csp = req.headers.get('content-security-policy') || '';
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, 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 {