@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.
@@ -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
@@ -10,7 +10,32 @@ import { setCspNonceProvider, cspNonce } from '@webjsdev/core';
10
10
  *
11
11
  * Strictly server-side: importing this module on the client is a bug.
12
12
  *
13
- * @typedef {{ req: Request }} Store
13
+ * `cspNonce` holds the per-request CSP nonce when CSP is enabled
14
+ * (issue #233). It is minted in the request handler and written here via
15
+ * `setCspNonce`, so the same value the `Content-Security-Policy` header
16
+ * carries is what `cspNonce()` returns for the inline boot script.
17
+ *
18
+ * `bodyLimits` holds the resolved request body-size caps (issue #237) so
19
+ * `readBody` (used inside `route.{js,ts}` handlers, which have no handle to the
20
+ * server state) can enforce the same limit the RPC and page-action paths do. The
21
+ * handler writes it per request via `setBodyLimits`.
22
+ *
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
14
39
  */
15
40
 
16
41
  /** @type {AsyncLocalStorage<Store>} */
@@ -33,10 +58,102 @@ export function getRequest() {
33
58
  }
34
59
 
35
60
  /**
36
- * Server-only implementation of the CSP nonce reader: pulls the
37
- * current request from AsyncLocalStorage, parses the
38
- * `script-src 'nonce-...'` value from its CSP header, returns ''
39
- * when none in scope.
61
+ * Set the per-request CSP nonce on the current AsyncLocalStorage store.
62
+ * Called by the request handler when CSP is enabled, AFTER it mints the
63
+ * nonce and BEFORE the page renders, so `cspNonce()` returns this exact
64
+ * value during SSR (the same value the response's
65
+ * `Content-Security-Policy` header carries: one source, no drift).
66
+ *
67
+ * A no-op outside a request scope, or when CSP is disabled (the handler
68
+ * simply never calls it, so the store's `cspNonce` stays undefined and
69
+ * `cspNonce()` falls through to '').
70
+ *
71
+ * @param {string} nonce
72
+ */
73
+ export function setCspNonce(nonce) {
74
+ const store = als.getStore();
75
+ if (store) store.cspNonce = nonce;
76
+ }
77
+
78
+ /**
79
+ * Set the per-request resolved body-size limits on the current store (issue
80
+ * #237). The handler computes them once at boot (`readBodyLimits`) and stamps
81
+ * them on every request so `readBody` (json.js), which runs inside route
82
+ * handlers with no access to the server state, can enforce the same cap.
83
+ *
84
+ * @param {{ json: number, multipart: number }} limits
85
+ */
86
+ export function setBodyLimits(limits) {
87
+ const store = als.getStore();
88
+ if (store) store.bodyLimits = limits;
89
+ }
90
+
91
+ /**
92
+ * Read the per-request body-size limits, or null outside a request scope.
93
+ * @returns {{ json: number, multipart: number } | null}
94
+ */
95
+ export function getBodyLimits() {
96
+ return als.getStore()?.bodyLimits ?? null;
97
+ }
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
+
149
+ /**
150
+ * Server-only implementation of the CSP nonce reader. Returns the
151
+ * per-request nonce that the handler MINTED and stored (issue #233) when
152
+ * CSP is enabled. Falls back to parsing an INBOUND
153
+ * `Content-Security-Policy` request header (the legacy consume-only
154
+ * behaviour) so an app sitting behind a proxy that already mints a nonce
155
+ * still works without enabling webjs's own CSP. Returns '' when neither
156
+ * is in scope.
40
157
  *
41
158
  * The public `cspNonce()` function lives in `@webjsdev/core` so user
42
159
  * layouts / pages can import it without dragging server-only deps
@@ -46,18 +163,16 @@ export function getRequest() {
46
163
  * `cspNonce()` returns '' (empty `nonce=""` attribute, browser
47
164
  * ignores it).
48
165
  */
49
- // The regex captures the first `nonce-...` token anywhere in the CSP
50
- // header. Webjs uses a single per-request nonce shared across all
51
- // directives that emit it (the standard CSP3 single-nonce model),
52
- // so reading the first match is correct. If a future caller emits
53
- // styled inline content under a separate style nonce, this reader
54
- // would need to become directive-scoped. Kept identical to the
55
- // matching helper in ssr.js so both paths interpret the header the
56
- // same way.
166
+ // The regex fallback captures the first `nonce-...` token anywhere in the
167
+ // inbound CSP header. Webjs uses a single per-request nonce shared across
168
+ // all directives that emit it (the standard CSP3 single-nonce model), so
169
+ // reading the first match is correct. Kept identical to the matching
170
+ // helper in ssr.js so both paths interpret the header the same way.
57
171
  setCspNonceProvider(() => {
58
- const req = als.getStore()?.req;
59
- if (!req) return '';
60
- const csp = req.headers.get('content-security-policy') || '';
172
+ const store = als.getStore();
173
+ if (!store) return '';
174
+ if (typeof store.cspNonce === 'string') return store.cspNonce;
175
+ const csp = store.req?.headers.get('content-security-policy') || '';
61
176
  const match = /\bnonce-([A-Za-z0-9+/=]+)/.exec(csp);
62
177
  return match ? match[1] : '';
63
178
  });
@@ -75,6 +190,10 @@ export { cspNonce };
75
190
  export function headers() {
76
191
  const req = getRequest();
77
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();
78
197
  return req.headers;
79
198
  }
80
199
 
@@ -89,6 +208,10 @@ export function headers() {
89
208
  export function cookies() {
90
209
  const req = getRequest();
91
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();
92
215
  const map = parseCookies(req);
93
216
  return {
94
217
  get: (name) => map[name],