@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 CHANGED
@@ -34,12 +34,14 @@ export {
34
34
  } from './src/vendor.js';
35
35
  export { buildModuleGraph, transitiveDeps } from './src/module-graph.js';
36
36
  export { scanComponents, primeComponentRegistry, extractComponents, findOrphanComponents } from './src/component-scanner.js';
37
- export { headers, cookies, getRequest, withRequest, cspNonce } from './src/context.js';
37
+ export { headers, cookies, getRequest, withRequest, cspNonce, requestId } from './src/context.js';
38
38
  export { defaultLogger } from './src/logger.js';
39
39
  export { rateLimit, parseWindow, clientIp, stampRemoteIp } from './src/rate-limit.js';
40
40
  export { cors, resolveOrigin, applyCorsHeaders } from './src/cors.js';
41
41
  export { memoryStore, redisStore, getStore, setStore } from './src/cache.js';
42
42
  export { cache } from './src/cache-fn.js';
43
+ export { revalidateTag, revalidateTags } from './src/cache-tags.js';
44
+ export { revalidatePath, revalidateAll } from './src/html-cache.js';
43
45
  export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
44
46
  export { broadcast } from './src/broadcast.js';
45
47
  export { json, readBody } from './src/json.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.10",
3
+ "version": "0.8.11",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
package/src/actions.js CHANGED
@@ -301,8 +301,12 @@ export async function serveActionStub(idx, absFile) {
301
301
  * @param {string} hash
302
302
  * @param {string} fnName
303
303
  * @param {Request} req
304
+ * @param {(error: unknown) => void} [onError] best-effort sink (issue #239)
305
+ * invoked when the action throws unexpectedly, BEFORE the sanitized 500 is
306
+ * returned, so an APM integration sees the original error. The caller wraps
307
+ * it so a throwing sink can never affect the response.
304
308
  */
305
- export async function invokeAction(idx, hash, fnName, req) {
309
+ export async function invokeAction(idx, hash, fnName, req, onError) {
306
310
  if (!verifyCsrf(req)) {
307
311
  return rpcResponse({ error: 'CSRF validation failed' }, { status: 403 });
308
312
  }
@@ -327,6 +331,7 @@ export async function invokeAction(idx, hash, fnName, req) {
327
331
  const result = await fn(...args);
328
332
  return rpcResponse(result ?? null);
329
333
  } catch (e) {
334
+ if (typeof onError === 'function') onError(e);
330
335
  return actionErrorResponse(e, idx.dev);
331
336
  }
332
337
  }
@@ -432,8 +437,12 @@ function matchOrigin(configured, origin) {
432
437
  * @param {ExposedRoute} route
433
438
  * @param {Record<string,string>} params
434
439
  * @param {Request} req
440
+ * @param {(error: unknown) => void} [onError] best-effort sink (issue #239)
441
+ * invoked when the exposed REST handler throws unexpectedly, BEFORE the
442
+ * sanitized 500 is returned, so an APM integration sees the original error.
443
+ * The caller wraps it so a throwing sink can never affect the response.
435
444
  */
436
- export async function invokeExposedAction(idx, route, params, req) {
445
+ export async function invokeExposedAction(idx, route, params, req, onError) {
437
446
  const url = new URL(req.url);
438
447
  const query = Object.fromEntries(url.searchParams.entries());
439
448
  let body = {};
@@ -473,6 +482,7 @@ export async function invokeExposedAction(idx, route, params, req) {
473
482
  if (result instanceof Response) return result;
474
483
  return Response.json(result ?? null);
475
484
  } catch (e) {
485
+ if (typeof onError === 'function') onError(e);
476
486
  return actionErrorResponse(e, idx.dev);
477
487
  }
478
488
  }
package/src/auth.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { getStore } from './cache.js';
11
- import { getRequest, getBodyLimits } from './context.js';
11
+ import { getRequest, getBodyLimits, markDynamicAccess } from './context.js';
12
12
  import { readTextBounded, readFormDataBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
13
13
 
14
14
  const enc = new TextEncoder();
@@ -220,6 +220,13 @@ export function createAuth(config) {
220
220
  // -- Session read/write ---------------------------------------------------
221
221
 
222
222
  async function readSession(req) {
223
+ // Reading the auth session is per-user (the body branches on the logged-in
224
+ // user), so mark the request dynamic so the server HTML cache excludes the
225
+ // page even when it wrongly set `revalidate`, mirroring getSession() /
226
+ // cookies() / headers(). This closes the auth-path leak (#241): `auth()`
227
+ // reaches here, reads the auth cookie raw, and would otherwise leave the
228
+ // page cacheable so a logged-in body could be served to the next visitor.
229
+ markDynamicAccess();
223
230
  const cookies = parseCookies(req.headers.get('cookie') || '');
224
231
  const raw = cookies[AUTH_COOKIE];
225
232
  if (!raw) return null;
@@ -0,0 +1,59 @@
1
+ import { createRequire } from 'node:module';
2
+ import { publishedBuildId } from './importmap.js';
3
+
4
+ /**
5
+ * Build-info / version probe (issue #239).
6
+ *
7
+ * `GET /__webjs/version` returns a small JSON object a deploy can curl to
8
+ * verify which build is live, alongside the existing `/__webjs/health` and
9
+ * `/__webjs/ready` probes. It carries NO secrets: only the framework version,
10
+ * the published importmap build id (the same value the client router reads
11
+ * from `data-webjs-build` to detect a deploy), the running node version, and
12
+ * process uptime. Served before `ensureReady()` like the other probes, so it
13
+ * answers on a cold instance without blocking on the whole-app analysis.
14
+ *
15
+ * The framework version is read once from this package's own `package.json`,
16
+ * the same single-source pattern `requiredNodeMajor()` uses, so it never drifts
17
+ * from the published version.
18
+ */
19
+
20
+ /** @type {string} */
21
+ let _frameworkVersion = '';
22
+ function frameworkVersion() {
23
+ if (_frameworkVersion) return _frameworkVersion;
24
+ try {
25
+ const require = createRequire(import.meta.url);
26
+ const pkg = require('../package.json');
27
+ _frameworkVersion = String(pkg?.version || '');
28
+ } catch {
29
+ _frameworkVersion = '';
30
+ }
31
+ return _frameworkVersion;
32
+ }
33
+
34
+ /**
35
+ * Compose the build-info payload. Pure (takes the moment as an argument) so a
36
+ * test can assert the shape without mocking the clock; the handler calls it
37
+ * with `process.uptime()`.
38
+ *
39
+ * @param {{ uptime?: number }} [opts]
40
+ * @returns {{ version: string, build: string, node: string, uptime: number }}
41
+ */
42
+ export function buildInfo(opts = {}) {
43
+ return {
44
+ version: frameworkVersion(),
45
+ build: publishedBuildId(),
46
+ node: process.version,
47
+ uptime: typeof opts.uptime === 'number' ? opts.uptime : process.uptime(),
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Build the `GET /__webjs/version` response. `no-store` so a proxy / browser
53
+ * never caches a stale build fingerprint.
54
+ *
55
+ * @returns {Response}
56
+ */
57
+ export function buildInfoResponse() {
58
+ return Response.json(buildInfo(), { headers: { 'cache-control': 'no-store' } });
59
+ }
package/src/cache-fn.js CHANGED
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { getStore } from './cache.js';
27
+ import { addKeyToTags } from './cache-tags.js';
27
28
 
28
29
  /**
29
30
  * Wrap an async function with server-side caching.
@@ -33,9 +34,18 @@ import { getStore } from './cache.js';
33
34
  * @param {{
34
35
  * key: string,
35
36
  * ttl?: number,
37
+ * tags?: string[] | ((...args: Parameters<T>) => string[]),
36
38
  * }} opts
37
39
  * - `key`: cache key prefix. Combined with serialized args to form the full key.
38
40
  * - `ttl`: time-to-live in seconds. Default: 60.
41
+ * - `tags`: optional tags this cached result belongs to, for cross-module
42
+ * invalidation via `revalidateTag(tag)`. Either a static `string[]`
43
+ * (every cached entry of this function shares them) or a function
44
+ * `(...args) => string[]` so a per-arg read tags with the entity id
45
+ * (e.g. `tags: (id) => ['post:' + id]`). The result is also recorded
46
+ * under each tag's thin key index so `revalidateTag` can find and
47
+ * evict it later, including arg-specific entries that the no-args
48
+ * `invalidate()` cannot reach.
39
49
  * @returns {T & { invalidate: () => Promise<void> }}
40
50
  * The cached function with the same signature, plus an `invalidate()`
41
51
  * method to manually clear the cache.
@@ -43,6 +53,19 @@ import { getStore } from './cache.js';
43
53
  export function cache(fn, opts) {
44
54
  const prefix = opts.key;
45
55
  const ttlMs = (opts.ttl ?? 60) * 1000;
56
+ const tagsOpt = opts.tags;
57
+
58
+ /**
59
+ * Resolve the tag list for one call. A function form receives the call
60
+ * args (so a per-entity read can tag with the id); a static array is
61
+ * returned as-is. Anything else yields no tags.
62
+ * @param {any[]} args
63
+ * @returns {string[]}
64
+ */
65
+ function tagsFor(args) {
66
+ const raw = typeof tagsOpt === 'function' ? tagsOpt(...args) : tagsOpt;
67
+ return Array.isArray(raw) ? raw.filter((t) => typeof t === 'string' && t) : [];
68
+ }
46
69
 
47
70
  const wrapped = /** @type {T & { invalidate: () => Promise<void> }} */ (
48
71
  async function (...args) {
@@ -58,6 +81,23 @@ export function cache(fn, opts) {
58
81
 
59
82
  const result = await fn(...args);
60
83
  await store.set(cacheKey, JSON.stringify(result), ttlMs);
84
+ // Record tag -> cacheKey in the thin tag index so a later
85
+ // revalidateTag can find and evict this entry (including
86
+ // arg-specific keys the no-args invalidate() cannot reach).
87
+ // Best-effort: the value is already stored, so taggability must
88
+ // never break the cached call. A user tags() function that throws
89
+ // (e.g. reading post.id off a null arg), or an index write that
90
+ // fails, leaves the value cached (just untagged) and returns
91
+ // normally. tagsFor() is INSIDE the try because it runs the
92
+ // user-supplied function.
93
+ try {
94
+ await addKeyToTags(tagsFor(args), cacheKey, ttlMs);
95
+ } catch (err) {
96
+ console.warn(
97
+ `[webjs] cache(${prefix}): tag indexing failed, value is cached ` +
98
+ `but untagged (revalidateTag will not reach it): ${err && err.message ? err.message : err}`
99
+ );
100
+ }
61
101
  return result;
62
102
  }
63
103
  );
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Tag-based invalidation for server `cache()` (the Next.js revalidateTag
3
+ * model), built as a THIN key-index over the existing pluggable store
4
+ * (`get` / `set` / `delete` in `cache.js`), NOT a new subsystem.
5
+ *
6
+ * The problem it solves: `cache(fn, { key, ttl })` can only be invalidated
7
+ * by calling `wrapped.invalidate()` from the module that owns the wrapper,
8
+ * and even then only the no-args base key (arg-specific keys leak until
9
+ * their TTL). There was no way for an unrelated mutation (createComment)
10
+ * to invalidate a related read (postById) without importing every wrapper.
11
+ *
12
+ * The index: when a cached result is stored, `cache-fn.js` also records the
13
+ * mapping `tag -> cacheKey` here. Each tag holds a JSON array of the cache
14
+ * keys tagged with it, under the namespaced store key `cache:tag:<tag>`.
15
+ * `revalidateTag(tag)` reads that array, deletes every cache key in it, then
16
+ * clears the index entry. A mutation in ANY module can therefore evict every
17
+ * read tagged `'posts'` across modules with one explicit call.
18
+ *
19
+ * It stays a thin index over `store.get/set/delete`: no new store method, a
20
+ * plain JSON array (a Set is trivial in the memory store; the same get/set of
21
+ * a JSON array works for Redis). The cohesive companion for HTML paths is
22
+ * `revalidatePath` in `html-cache.js`; together they are the server cache
23
+ * invalidation surface (this one for `cache()` DATA, that one for cached HTML).
24
+ *
25
+ * MULTI-INSTANCE CAVEAT (mirrors #241): the index is a plain read-modify-write
26
+ * of a JSON array, NOT atomic across processes. With a shared Redis store,
27
+ * `revalidateTag` deletes the keys it can see and reaches every instance for
28
+ * those keys, but two instances appending to the same tag concurrently can
29
+ * lose an append (last write wins), so a freshly-stored key on a peer might
30
+ * miss eviction and live until its TTL. The tag index entry itself also
31
+ * carries the cache TTL, so the index self-prunes and never grows unbounded.
32
+ * For strict cross-instance invalidation, prefer a short `ttl` as the floor.
33
+ *
34
+ * @module cache-tags
35
+ */
36
+
37
+ import { getStore } from './cache.js';
38
+
39
+ /** Namespace prefix for every tag-index key, parallel to `cache:` entries. */
40
+ const TAG_PREFIX = 'cache:tag:';
41
+
42
+ /** @param {string} tag @returns {string} */
43
+ function tagKey(tag) {
44
+ return `${TAG_PREFIX}${tag}`;
45
+ }
46
+
47
+ /**
48
+ * Read the cache-key set stored under one tag. Returns a plain array (empty
49
+ * on a miss / parse error, failing open). The store holds a JSON array.
50
+ *
51
+ * @param {string} tag
52
+ * @returns {Promise<string[]>}
53
+ */
54
+ async function readTagKeys(tag) {
55
+ try {
56
+ const raw = await getStore().get(tagKey(tag));
57
+ if (!raw) return [];
58
+ const arr = JSON.parse(raw);
59
+ return Array.isArray(arr) ? arr : [];
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Append a cache key to every given tag's index (deduped). Called by
67
+ * `cache-fn.js` right after it stores a cached result. The index entry
68
+ * carries the same TTL as the cached value so it self-prunes and the tag
69
+ * index never outgrows the data it points at.
70
+ *
71
+ * Best-effort: a store failure here never affects the cached result that was
72
+ * already written (the value is still served; only its taggability is lost).
73
+ *
74
+ * @param {string[]} tags
75
+ * @param {string} cacheKey
76
+ * @param {number} [ttlMs]
77
+ * @returns {Promise<void>}
78
+ */
79
+ export async function addKeyToTags(tags, cacheKey, ttlMs) {
80
+ if (!Array.isArray(tags) || tags.length === 0) return;
81
+ const store = getStore();
82
+ for (const tag of tags) {
83
+ if (typeof tag !== 'string' || !tag) continue;
84
+ try {
85
+ const keys = await readTagKeys(tag);
86
+ if (keys.includes(cacheKey)) continue;
87
+ keys.push(cacheKey);
88
+ await store.set(tagKey(tag), JSON.stringify(keys), ttlMs);
89
+ } catch {
90
+ /* a tag-index write failure must never affect the cached value */
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Evict every cached entry tagged with `tag`, then clear the tag index.
97
+ * A mutating server action calls this after a write so the next read of any
98
+ * tagged query recomputes. Works across modules: the read tagged `'posts'`
99
+ * in one module is invalidated by a `revalidateTag('posts')` issued from
100
+ * any other.
101
+ *
102
+ * ```js
103
+ * // modules/comments/actions/create-comment.server.ts
104
+ * 'use server';
105
+ * import { revalidateTag } from '@webjsdev/server';
106
+ * export async function createComment(input) {
107
+ * await prisma.comment.create({ data: input });
108
+ * await revalidateTag('post:' + input.postId); // postById(postId) recomputes
109
+ * return { success: true };
110
+ * }
111
+ * ```
112
+ *
113
+ * @param {string} tag
114
+ * @returns {Promise<void>}
115
+ */
116
+ export async function revalidateTag(tag) {
117
+ if (typeof tag !== 'string' || !tag) return;
118
+ const store = getStore();
119
+ const keys = await readTagKeys(tag);
120
+ for (const k of keys) {
121
+ try {
122
+ await store.delete(k);
123
+ } catch {
124
+ /* a delete failure is non-fatal: the entry still expires via its TTL */
125
+ }
126
+ }
127
+ try {
128
+ await store.delete(tagKey(tag));
129
+ } catch {
130
+ /* non-fatal */
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Convenience: `revalidateTag` for several tags in one call. A mutation that
136
+ * touches multiple cached surfaces (e.g. a post AND the post list) evicts
137
+ * them together.
138
+ *
139
+ * @param {string[]} tags
140
+ * @returns {Promise<void>}
141
+ */
142
+ export async function revalidateTags(tags) {
143
+ if (!Array.isArray(tags)) return;
144
+ for (const tag of tags) {
145
+ await revalidateTag(tag);
146
+ }
147
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * RFC 7232 conditional GET (ETag + If-None-Match -> 304).
3
+ *
4
+ * One shared funnel that, given a Request and the buffered Response the
5
+ * pipeline produced, attaches a content-hash `ETag` when the response is
6
+ * cacheable and missing one, then turns a matching `If-None-Match` into a
7
+ * `304 Not Modified` with no body. Wired once at the response funnel in
8
+ * `dev.js`'s `handle()`, so it covers SSR HTML pages, static assets, app
9
+ * source modules, vendor / core runtime modules, and route-handler bodies
10
+ * uniformly.
11
+ *
12
+ * What is EXCLUDED, and why:
13
+ * - `no-store` / `private` responses (the default for dynamic, per-user
14
+ * pages). Never enable a cross-session 304 on private content: a shared
15
+ * cache keyed on the URL could serve one user's validator to another.
16
+ * - Any body the framework did not positively mark as fully buffered. The
17
+ * funnel only hashes a response that ALREADY carries an `ETag` (a serve
18
+ * branch hashed its own bytes) OR carries the internal `X-Webjs-Buffered`
19
+ * marker the buffered serve branches stamp on a string / bytes body. It
20
+ * never calls `arrayBuffer()` on an unmarked body. This is the guard that
21
+ * keeps a user `route.{js,ts}` handler returning a `ReadableStream` (and
22
+ * especially an SSE `text/event-stream`, whose stream NEVER ends) from
23
+ * being buffered into memory or, worse, awaited forever so the response
24
+ * hangs. A web `Response` exposes a `ReadableStream` body for a string and
25
+ * for a live stream alike, with no public way to tell them apart, so a
26
+ * positive opt-in marker is the only safe discriminator.
27
+ * - Streaming Suspense bodies, flagged with `X-Webjs-Stream: 1` by the SSR
28
+ * pipeline (a stricter form of the unmarked-body rule: the marker is
29
+ * stripped and the response returned untouched). Streaming responses are
30
+ * not conditional-GET cached, by design.
31
+ * - Non-GET / non-HEAD methods, and any status other than 200. A validator
32
+ * is only meaningful for a successful, replayable read.
33
+ *
34
+ * The ETag is WEAK (`W/"..."`). It is computed over the UNCOMPRESSED body
35
+ * bytes, then the prod compression step (`sendWebResponse` in `dev.js`) may
36
+ * stamp `Content-Encoding: gzip` / `br` on the SAME response. A STRONG
37
+ * validator must be unique per content-coding (RFC 7232 2.3.3), so reusing one
38
+ * strong ETag across identity / gzip / br would be non-conformant; a WEAK
39
+ * validator is the conformant choice for a hash shared across codings, and
40
+ * `If-None-Match` already uses weak comparison so a `304` still fires.
41
+ *
42
+ * The ETag is computed over the response's OWN body bytes, so an identical
43
+ * body yields an identical ETag across requests. Per-response varying bits
44
+ * that ride RESPONSE HEADERS (the `x-webjs-build` id, a `set-cookie` CSRF
45
+ * token, the CSP nonce on the header) are NOT part of the body hash, so they
46
+ * do not destabilise the ETag. The one body-level varying input is the CSP
47
+ * nonce stamped INTO the inline boot script: when CSP is enabled the HTML
48
+ * body changes every request, so its ETag changes every request and a 304 is
49
+ * simply never produced for that page (correct, not a bug). CSP is off by
50
+ * default, so the common case has a stable body and a stable ETag.
51
+ *
52
+ * @module conditional-get
53
+ */
54
+
55
+ import { digestHex } from './crypto-utils.js';
56
+
57
+ /**
58
+ * Internal header a buffered serve branch stamps to opt a response into
59
+ * conditional GET. Stripped at the funnel; it never reaches a client. A web
60
+ * `Response` exposes a `ReadableStream` body whether it was built from a
61
+ * string, bytes, or a live stream, so this explicit marker is how the funnel
62
+ * KNOWS a body is safe to hash without risking buffering a route handler's
63
+ * stream or hanging on an SSE response.
64
+ */
65
+ export const BUFFERED_MARKER = 'x-webjs-buffered';
66
+
67
+ /** Internal header the SSR pipeline stamps on a genuinely-streamed body. */
68
+ export const STREAM_MARKER = 'x-webjs-stream';
69
+
70
+ /**
71
+ * Headers that must not ride a 304 response (it has no body). Everything
72
+ * else (ETag, Cache-Control, Vary, the framework's X-Webjs-Build /
73
+ * X-Request-Id, Set-Cookie) is preserved so a shared cache and the client
74
+ * router behave identically to a 200.
75
+ */
76
+ const STRIP_ON_304 = ['content-length', 'content-encoding', 'content-type'];
77
+
78
+ /**
79
+ * Is this response cacheable enough to carry a validator? Cacheable means a
80
+ * 200 whose `Cache-Control` is present and does not forbid storage. A
81
+ * `no-store` or `private` response is excluded (private / per-user content
82
+ * must never get a cross-session 304). `no-cache` is INCLUDED: it means
83
+ * "revalidate before reuse", and a 304 is exactly that revalidation answer,
84
+ * so dev's `no-cache` assets still benefit.
85
+ *
86
+ * @param {Response} res
87
+ * @returns {boolean}
88
+ */
89
+ function isCacheable(res) {
90
+ if (res.status !== 200) return false;
91
+ const cc = res.headers.get('cache-control');
92
+ if (!cc) return false;
93
+ return !/(?:^|,)\s*(?:no-store|private)\s*(?:,|$)/i.test(cc);
94
+ }
95
+
96
+ /**
97
+ * Parse an `If-None-Match` request header and test it against an ETag.
98
+ * Honors the `*` wildcard and a comma-separated list, and compares
99
+ * weak-insensitively (a `W/` prefix on either side is ignored for the
100
+ * comparison, per RFC 7232 weak-comparison semantics, which is the correct
101
+ * function for `If-None-Match`).
102
+ *
103
+ * @param {string | null} header the raw `If-None-Match` value
104
+ * @param {string} etag the response ETag, e.g. `"abc123"`
105
+ * @returns {boolean}
106
+ */
107
+ export function ifNoneMatchSatisfied(header, etag) {
108
+ if (!header || !etag) return false;
109
+ const want = stripWeak(etag);
110
+ for (const raw of header.split(',')) {
111
+ const tok = raw.trim();
112
+ if (tok === '*') return true;
113
+ if (stripWeak(tok) === want) return true;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ /** @param {string} tag */
119
+ function stripWeak(tag) {
120
+ return tag.startsWith('W/') ? tag.slice(2) : tag;
121
+ }
122
+
123
+ /**
124
+ * Apply conditional-GET semantics to a finished, buffered Response.
125
+ *
126
+ * Returns either the same response (now possibly carrying an `ETag`) or, when
127
+ * the request's `If-None-Match` matches, a fresh `304 Not Modified` with no
128
+ * body and the validators / caching headers preserved. A streaming,
129
+ * non-cacheable, or non-GET/HEAD response is returned unchanged.
130
+ *
131
+ * @param {Request} req
132
+ * @param {Response} res
133
+ * @returns {Promise<Response>}
134
+ */
135
+ export async function applyConditionalGet(req, res) {
136
+ const method = req.method.toUpperCase();
137
+ // Always consume the internal buffered marker so it never reaches a client.
138
+ const buffered = res.headers.has(BUFFERED_MARKER);
139
+ if (buffered) res.headers.delete(BUFFERED_MARKER);
140
+ // A genuinely streamed Suspense response is flagged by the SSR pipeline.
141
+ // Strip that marker too and skip conditional-GET so the live stream is
142
+ // never consumed.
143
+ if (res.headers.has(STREAM_MARKER)) {
144
+ res.headers.delete(STREAM_MARKER);
145
+ return res;
146
+ }
147
+ if (method !== 'GET' && method !== 'HEAD') return res;
148
+ if (!isCacheable(res)) return res;
149
+
150
+ let etag = res.headers.get('etag');
151
+ if (!etag) {
152
+ // Only hash a body the framework positively marked as fully buffered.
153
+ // Without the marker we must NOT read the body, because a user route
154
+ // handler returning a ReadableStream would be buffered into memory, and
155
+ // an SSE (text/event-stream) stream never ends so the await would hang
156
+ // forever. A web Response exposes a ReadableStream body for a string and
157
+ // for a live stream alike, so the marker is the only safe discriminator.
158
+ if (!buffered) return res;
159
+ let bytes;
160
+ try {
161
+ // Clone so the caller's body is never consumed. Safe here because a
162
+ // marked body is built from a string / bytes, so the clone resolves
163
+ // at once.
164
+ bytes = new Uint8Array(await res.clone().arrayBuffer());
165
+ } catch {
166
+ // A body that refuses to buffer (should not happen for a marked branch)
167
+ // is left without a validator rather than crashing the funnel.
168
+ return res;
169
+ }
170
+ // WEAK validator. The hash is over the uncompressed body and is reused
171
+ // across identity / gzip / br codings (compression runs after this), which
172
+ // a STRONG ETag may not do (RFC 7232 2.3.3). If-None-Match weak-compares.
173
+ etag = `W/"${(await digestHex('SHA-1', bytes)).slice(0, 16)}"`;
174
+ res.headers.set('etag', etag);
175
+ }
176
+
177
+ if (ifNoneMatchSatisfied(req.headers.get('if-none-match'), etag)) {
178
+ const headers = new Headers(res.headers);
179
+ for (const h of STRIP_ON_304) headers.delete(h);
180
+ return new Response(null, { status: 304, headers });
181
+ }
182
+ return res;
183
+ }
package/src/context.js CHANGED
@@ -20,7 +20,22 @@ import { setCspNonceProvider, cspNonce } from '@webjsdev/core';
20
20
  * server state) can enforce the same limit the RPC and page-action paths do. The
21
21
  * handler writes it per request via `setBodyLimits`.
22
22
  *
23
- * @typedef {{ req: Request, cspNonce?: string, bodyLimits?: { json: number, multipart: number } }} Store
23
+ * `requestId` holds the per-request correlation id (issue #239). The handler
24
+ * mints a `crypto.randomUUID()` at the start of every request (or honors an
25
+ * inbound `X-Request-Id` from a trusted upstream proxy) and stores it here via
26
+ * `setRequestId`, so server-side app code can read it with `requestId()` to
27
+ * stamp it on its own logs / outbound calls, and the framework includes it in
28
+ * the access log, the error log, and the `X-Request-Id` response header.
29
+ *
30
+ * `dynamicAccess` is set the moment the render reads per-user request state
31
+ * through a framework helper (`cookies()`, `headers()`, `getSession()`),
32
+ * mirroring Next.js auto-marking a route dynamic on a `cookies()` / `headers()`
33
+ * read (#241). The server HTML cache reads it at commit time and REFUSES to
34
+ * cache a page that opted into `revalidate` but actually varies per user, so a
35
+ * wrong `revalidate` on a cookie-reading page fails safe (uncached) instead of
36
+ * leaking one visitor's view to another.
37
+ *
38
+ * @typedef {{ req: Request, cspNonce?: string, bodyLimits?: { json: number, multipart: number }, requestId?: string, dynamicAccess?: boolean }} Store
24
39
  */
25
40
 
26
41
  /** @type {AsyncLocalStorage<Store>} */
@@ -81,6 +96,56 @@ export function getBodyLimits() {
81
96
  return als.getStore()?.bodyLimits ?? null;
82
97
  }
83
98
 
99
+ /**
100
+ * Set the per-request correlation id on the current store (issue #239). The
101
+ * handler calls this at the start of every request with either an inbound
102
+ * `X-Request-Id` (a trusted upstream proxy's trace id) or a freshly minted
103
+ * `crypto.randomUUID()`. A no-op outside a request scope.
104
+ *
105
+ * @param {string} id
106
+ */
107
+ export function setRequestId(id) {
108
+ const store = als.getStore();
109
+ if (store) store.requestId = id;
110
+ }
111
+
112
+ /**
113
+ * The correlation id for the in-flight request (issue #239), or `null`
114
+ * outside a request scope. Server-side app code (pages, layouts, server
115
+ * actions, route handlers, middleware) reads it to correlate its own logs and
116
+ * outbound calls with the framework's access / error logs and the
117
+ * `X-Request-Id` response header, all of which carry the same id.
118
+ *
119
+ * @returns {string | null}
120
+ */
121
+ export function requestId() {
122
+ return als.getStore()?.requestId ?? null;
123
+ }
124
+
125
+ /**
126
+ * Mark the in-flight request as having read per-user state (issue #241). The
127
+ * framework's request-state readers (`cookies()`, `headers()`, `getSession()`)
128
+ * call this, so the server HTML cache can tell at commit time that the render
129
+ * actually depends on the visitor and refuse to cache it even when the page
130
+ * declared `revalidate`. A no-op outside a request scope.
131
+ */
132
+ export function markDynamicAccess() {
133
+ const store = als.getStore();
134
+ if (store) store.dynamicAccess = true;
135
+ }
136
+
137
+ /**
138
+ * True when the in-flight render read per-user request state via a framework
139
+ * helper (issue #241). Read by the server HTML cache's commit step to fail
140
+ * safe (do not cache) on a per-user page that wrongly set `revalidate`.
141
+ * Returns false outside a request scope.
142
+ *
143
+ * @returns {boolean}
144
+ */
145
+ export function dynamicAccessed() {
146
+ return als.getStore()?.dynamicAccess === true;
147
+ }
148
+
84
149
  /**
85
150
  * Server-only implementation of the CSP nonce reader. Returns the
86
151
  * per-request nonce that the handler MINTED and stored (issue #233) when
@@ -125,6 +190,10 @@ export { cspNonce };
125
190
  export function headers() {
126
191
  const req = getRequest();
127
192
  if (!req) throw new Error('headers(): called outside a request scope');
193
+ // Reading request headers makes the render per-user (an Authorization /
194
+ // Accept-Language / Cookie read varies the output), so mark the request
195
+ // dynamic so the HTML cache excludes it even under `revalidate` (#241).
196
+ markDynamicAccess();
128
197
  return req.headers;
129
198
  }
130
199
 
@@ -139,6 +208,10 @@ export function headers() {
139
208
  export function cookies() {
140
209
  const req = getRequest();
141
210
  if (!req) throw new Error('cookies(): called outside a request scope');
211
+ // Reading cookies makes the render per-user (a logged-in vs logged-out body
212
+ // keys off an auth cookie), so mark the request dynamic so the HTML cache
213
+ // excludes it even under `revalidate`, the core leak defense (#241).
214
+ markDynamicAccess();
142
215
  const map = parseCookies(req);
143
216
  return {
144
217
  get: (name) => map[name],