@webjsdev/server 0.8.10 → 0.8.12

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,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],