partial-content 1.0.0

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +601 -0
  4. package/SECURITY.md +92 -0
  5. package/dist/azure.d.ts +79 -0
  6. package/dist/azure.d.ts.map +1 -0
  7. package/dist/azure.js +251 -0
  8. package/dist/azure.js.map +1 -0
  9. package/dist/content-disposition.d.ts +74 -0
  10. package/dist/content-disposition.d.ts.map +1 -0
  11. package/dist/content-disposition.js +253 -0
  12. package/dist/content-disposition.js.map +1 -0
  13. package/dist/fs.d.ts +86 -0
  14. package/dist/fs.d.ts.map +1 -0
  15. package/dist/fs.js +375 -0
  16. package/dist/fs.js.map +1 -0
  17. package/dist/gcs.d.ts +72 -0
  18. package/dist/gcs.d.ts.map +1 -0
  19. package/dist/gcs.js +202 -0
  20. package/dist/gcs.js.map +1 -0
  21. package/dist/hono.d.ts +92 -0
  22. package/dist/hono.d.ts.map +1 -0
  23. package/dist/hono.js +61 -0
  24. package/dist/hono.js.map +1 -0
  25. package/dist/http.d.ts +70 -0
  26. package/dist/http.d.ts.map +1 -0
  27. package/dist/http.js +281 -0
  28. package/dist/http.js.map +1 -0
  29. package/dist/index.d.ts +21 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +27 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/kernel.d.ts +541 -0
  34. package/dist/kernel.d.ts.map +1 -0
  35. package/dist/kernel.js +1218 -0
  36. package/dist/kernel.js.map +1 -0
  37. package/dist/memory.d.ts +55 -0
  38. package/dist/memory.d.ts.map +1 -0
  39. package/dist/memory.js +107 -0
  40. package/dist/memory.js.map +1 -0
  41. package/dist/mime.d.ts +49 -0
  42. package/dist/mime.d.ts.map +1 -0
  43. package/dist/mime.js +150 -0
  44. package/dist/mime.js.map +1 -0
  45. package/dist/node.d.ts +84 -0
  46. package/dist/node.d.ts.map +1 -0
  47. package/dist/node.js +215 -0
  48. package/dist/node.js.map +1 -0
  49. package/dist/object-store.d.ts +472 -0
  50. package/dist/object-store.d.ts.map +1 -0
  51. package/dist/object-store.js +335 -0
  52. package/dist/object-store.js.map +1 -0
  53. package/dist/r2.d.ts +94 -0
  54. package/dist/r2.d.ts.map +1 -0
  55. package/dist/r2.js +150 -0
  56. package/dist/r2.js.map +1 -0
  57. package/dist/s3.d.ts +49 -0
  58. package/dist/s3.d.ts.map +1 -0
  59. package/dist/s3.js +263 -0
  60. package/dist/s3.js.map +1 -0
  61. package/dist/web.d.ts +336 -0
  62. package/dist/web.d.ts.map +1 -0
  63. package/dist/web.js +1094 -0
  64. package/dist/web.js.map +1 -0
  65. package/docs/DESIGN.md +426 -0
  66. package/package.json +182 -0
  67. package/src/azure.ts +329 -0
  68. package/src/content-disposition.ts +300 -0
  69. package/src/fs.ts +469 -0
  70. package/src/gcs.ts +290 -0
  71. package/src/hono.ts +123 -0
  72. package/src/http.ts +351 -0
  73. package/src/index.ts +85 -0
  74. package/src/kernel.ts +1498 -0
  75. package/src/memory.ts +148 -0
  76. package/src/mime.ts +160 -0
  77. package/src/node.ts +261 -0
  78. package/src/object-store.ts +665 -0
  79. package/src/r2.ts +232 -0
  80. package/src/s3.ts +324 -0
  81. package/src/web.ts +1603 -0
package/src/kernel.ts ADDED
@@ -0,0 +1,1498 @@
1
+ /**
2
+ * HTTP Range header parser and response builder (RFC 7233 + RFC 9110).
3
+ *
4
+ * Pure functions for building RFC-compliant HTTP responses that serve
5
+ * files from any storage backend (S3, R2, GCS, local disk). Handles
6
+ * range parsing, conditional request evaluation, and response header
7
+ * construction.
8
+ *
9
+ * Supports:
10
+ * - `If-None-Match` -> 304 Not Modified (bandwidth savings on revisits)
11
+ * - `If-Match` / `If-Unmodified-Since` -> 412 Precondition Failed
12
+ * - `If-Range` -> ETag/date validation (prevents data corruption)
13
+ * - `Range: bytes=...` -> 206 Partial Content (seeking in video/PDF)
14
+ * - 416 Range Not Satisfiable (proper error for unsatisfiable ranges)
15
+ *
16
+ * Supports both single byte ranges and multiple ranges (`parseRanges` +
17
+ * the `multipart/byteranges` builders below). Multi-range coalesces
18
+ * overlapping/adjacent parts and caps the count (amplification defense); the
19
+ * single-range fast path is kept separate so the common seek pays no
20
+ * multi-range overhead.
21
+ */
22
+
23
+ // ─── Types ──────────────────────────────────────────────────────────────────
24
+
25
+ /** A validated, clamped byte range with inclusive bounds. */
26
+ export interface ParsedRange {
27
+ /** First byte position (0-indexed). */
28
+ start: number;
29
+ /**
30
+ * Last byte position (inclusive). May equal {@link OPEN_ENDED} on the
31
+ * single-round-trip fast path, meaning "to end of object" -- the range's
32
+ * true end is unknown until the backend responds. Adapters MUST treat
33
+ * `end === OPEN_ENDED` as an open-ended read (`bytes=start-`, or omit the
34
+ * count) rather than emitting the sentinel as a literal last-byte-pos.
35
+ */
36
+ end: number;
37
+ }
38
+
39
+ /**
40
+ * Sentinel `ParsedRange.end` meaning "to the end of the object". Used only
41
+ * by the fast path for a `bytes=a-` request, where the total size is not yet
42
+ * known. It is deliberately NOT `Number.MAX_SAFE_INTEGER`-as-a-magic-number
43
+ * at call sites: adapters compare against this named constant and emit the
44
+ * idiomatic open-ended wire form, so no 16-digit last-byte-pos ever reaches
45
+ * a backend or proxy that might reject it.
46
+ */
47
+ export const OPEN_ENDED = Number.MAX_SAFE_INTEGER;
48
+
49
+ /** True when a range is the fast-path open-ended form (`bytes=start-`). */
50
+ export function isOpenEndedRange(range: { end: number } | undefined | null): boolean {
51
+ return range?.end === OPEN_ENDED;
52
+ }
53
+
54
+ /** Options for building range response headers. */
55
+ export interface RangeResponseHeaderOpts {
56
+ /**
57
+ * Total object size in bytes, or `undefined` when the total is unknown --
58
+ * a `bytes a-b/*` partial response from a streaming origin that does not
59
+ * know its full length (RFC 7233 Section 4.2). Unknown is valid ONLY
60
+ * alongside a `range`: a 206's `Content-Length` is the range span, so the
61
+ * total is not needed to size the body and is emitted as `*`. A full (200)
62
+ * response has no length without it and rejects `undefined`.
63
+ */
64
+ totalSize: number | undefined;
65
+ /** Parsed range, or null for a full-content response. */
66
+ range: ParsedRange | null;
67
+ /** MIME type from storage metadata. */
68
+ contentType: string | undefined;
69
+ /** ETag from storage metadata, for conditional caching. */
70
+ etag: string | undefined;
71
+ /**
72
+ * Last-Modified date from storage metadata.
73
+ *
74
+ * Accepts any string parseable by `Date.parse()` (ISO 8601, IMF-fixdate,
75
+ * RFC 850, asctime). The library normalizes it to IMF-fixdate
76
+ * (e.g. "Sun, 29 Jun 2025 12:00:00 GMT") before emitting the
77
+ * `Last-Modified` response header, because ISO strings are not valid
78
+ * HTTP-dates and would break `If-Modified-Since` revalidation.
79
+ */
80
+ lastModified: string | undefined;
81
+ /**
82
+ * RFC 9530 representation digest for end-to-end integrity.
83
+ *
84
+ * When provided, emitted as `Repr-Digest: sha-256=:<base64>:` on 200/206
85
+ * responses. The digest covers the *full representation* (not the partial
86
+ * range), so it remains stable across range requests.
87
+ *
88
+ * Storage backends typically provide this:
89
+ * - S3: `x-amz-checksum-sha256`
90
+ * - GCS: `x-goog-hash: crc32c=...,md5=...`
91
+ *
92
+ * Must be a raw base64-encoded SHA-256 hash (no prefix, no colons).
93
+ */
94
+ digest?: string;
95
+ /**
96
+ * Cache-Control directive to include in the response.
97
+ *
98
+ * The library never generates cache directives on its own. It only echoes
99
+ * a value you provide. Common patterns:
100
+ * - `"private, no-cache"` (revalidate every request)
101
+ * - `"private, max-age=3600"` (cache 1 hour)
102
+ * - `"public, max-age=31536000, immutable"` (content-addressed)
103
+ */
104
+ cacheControl?: string;
105
+ }
106
+
107
+ /** Result of building range response headers. */
108
+ export interface RangeResponseHeaders {
109
+ /** HTTP status code: 200 for full content, 206 for partial, 304 for not modified, 412 for precondition failure, 416 for unsatisfiable. */
110
+ status: 200 | 206 | 304 | 412 | 416;
111
+ /** Headers to set on the response. */
112
+ headers: Record<string, string>;
113
+ }
114
+
115
+ /** Result of the full conditional request evaluation chain. */
116
+ export interface EvaluatedRequest extends RangeResponseHeaders {
117
+ /** Parsed range, or null if full content or an error status. */
118
+ range: ParsedRange | null;
119
+ }
120
+
121
+ // ─── Parser ─────────────────────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Parse an HTTP Range header value into a validated byte range.
125
+ *
126
+ * Supports three RFC 7233 forms:
127
+ * - `bytes=0-499` (first 500 bytes)
128
+ * - `bytes=500-` (everything from byte 500 onward)
129
+ * - `bytes=-500` (last 500 bytes, suffix range)
130
+ *
131
+ * Returns `null` for:
132
+ * - Missing or empty header
133
+ * - Malformed syntax
134
+ * - Multi-range requests (e.g. `bytes=0-100,200-300`)
135
+ * - Non-byte range units
136
+ *
137
+ * Returns `"unsatisfiable"` for:
138
+ * - Start >= totalSize (valid syntax but out of bounds)
139
+ * - Suffix of 0 bytes
140
+ * - Start > end after clamping
141
+ *
142
+ * This distinction matters: `null` means "not a range request" (serve 200),
143
+ * while `"unsatisfiable"` means "valid range syntax but cannot be honored" (serve 416).
144
+ *
145
+ * @param rangeHeader - The raw `Range` header value (e.g. "bytes=0-499")
146
+ * @param totalSize - Total file size in bytes
147
+ */
148
+ export function parseRangeHeader(
149
+ rangeHeader: string | null | undefined,
150
+ totalSize: number,
151
+ ): ParsedRange | "unsatisfiable" | null {
152
+ // Reject corrupt metadata: NaN, Infinity, negative, or non-integer sizes
153
+ // would produce invalid Content-Range headers downstream.
154
+ if (!Number.isSafeInteger(totalSize) || totalSize < 0) return null;
155
+ if (!rangeHeader || totalSize <= 0) return null;
156
+
157
+ // RFC 9110 Section 14.1: "Range units are case-insensitive."
158
+ const lower = rangeHeader.toLowerCase();
159
+ if (!lower.startsWith("bytes=")) return null;
160
+
161
+ const rangeSpec = rangeHeader.slice(6); // Strip "bytes=" (preserves original casing for value)
162
+
163
+ // Single range only on this fast path. Multi-range (comma-separated) is
164
+ // served as multipart/byteranges via parseRanges; this path handles the
165
+ // common single-seek that media elements and PDF viewers emit.
166
+ if (rangeSpec.includes(",")) return null;
167
+
168
+ return parseOneRange(rangeSpec, totalSize);
169
+ }
170
+
171
+ /**
172
+ * Parse ONE byte-range-spec element (already stripped of the `bytes=` unit)
173
+ * against a known total size. Shared by {@link parseRangeHeader} (single) and
174
+ * {@link parseRanges} (multipart).
175
+ *
176
+ * Returns a clamped {@link ParsedRange}, `"unsatisfiable"` for valid syntax
177
+ * that cannot be honored (start past EOF, zero-length suffix), or `null` for
178
+ * malformed syntax.
179
+ */
180
+ function parseOneRange(spec: string, totalSize: number): ParsedRange | "unsatisfiable" | null {
181
+ const dashIndex = spec.indexOf("-");
182
+ if (dashIndex === -1) return null;
183
+
184
+ const startStr = spec.slice(0, dashIndex).trim();
185
+ const endStr = spec.slice(dashIndex + 1).trim();
186
+
187
+ // Suffix range: bytes=-500 (last 500 bytes)
188
+ if (startStr === "") {
189
+ const suffixLength = parsePos(endStr);
190
+ if (isNaN(suffixLength)) return null;
191
+ // bytes=-0 is a zero-length suffix: valid syntax but unsatisfiable
192
+ if (suffixLength <= 0) return "unsatisfiable";
193
+ const start = Math.max(0, totalSize - suffixLength);
194
+ return { start, end: totalSize - 1 };
195
+ }
196
+
197
+ const start = parsePos(startStr);
198
+ if (isNaN(start)) return null;
199
+
200
+ // Unsatisfiable: start beyond file (valid syntax, cannot be honored)
201
+ if (start >= totalSize) return "unsatisfiable";
202
+
203
+ // Open-ended range: bytes=500-
204
+ if (endStr === "") {
205
+ return { start, end: totalSize - 1 };
206
+ }
207
+
208
+ let end = parsePos(endStr);
209
+ if (isNaN(end)) return null;
210
+
211
+ // Clamp end to file boundary
212
+ end = Math.min(end, totalSize - 1);
213
+
214
+ // RFC 9110 Section 14.1.2: "A server that receives a byte-range-spec
215
+ // with a first-byte-pos that is greater than its last-byte-pos MUST
216
+ // ignore the invalid range." Ignore = serve full 200.
217
+ if (start > end) return null;
218
+
219
+ return { start, end };
220
+ }
221
+
222
+ // ─── Multiple Ranges (multipart/byteranges, RFC 9110 Section 14) ────────────
223
+
224
+ /** Default cap on distinct coalesced ranges before a request degrades to 200. */
225
+ export const MAX_RANGES_DEFAULT = 50;
226
+
227
+ /** A validated, coalesced set of satisfiable ranges. */
228
+ export interface RangeSet {
229
+ /**
230
+ * Coalesced satisfiable ranges in ascending order (length >= 1). A length
231
+ * of 1 is served as a normal single 206; length > 1 as multipart/byteranges.
232
+ */
233
+ ranges: ParsedRange[];
234
+ }
235
+
236
+ /**
237
+ * Parse a possibly-multi-range `Range` header into a coalesced, satisfiable
238
+ * range set, applying range-amplification defenses.
239
+ *
240
+ * Return contract mirrors {@link parseRangeHeader} but for a set:
241
+ * - `null` -- not a byte range, ignorable syntax, or a defense
242
+ * tripped: serve the full 200.
243
+ * - `"unsatisfiable"` -- every element was valid syntax but out of bounds:
244
+ * serve 416.
245
+ * - {@link RangeSet} -- 1+ satisfiable ranges (overlapping/adjacent already
246
+ * coalesced): serve 206 (single) or multipart (>1).
247
+ *
248
+ * Amplification defenses (informed by Go `net/http.ServeContent`'s
249
+ * sum-of-ranges check and nginx `max_ranges` + adjacent coalescing):
250
+ * 1. Overlapping/adjacent ranges are coalesced, so a client cannot force
251
+ * redundant bytes by requesting the same region many times.
252
+ * 2. If the coalesced set still exceeds `maxRanges` distinct parts, or the
253
+ * bytes it covers reach or exceed the whole representation, the ranges
254
+ * are ignored and the full 200 is served -- multipart framing over the
255
+ * entire file is pure overhead and a classic amplification vector.
256
+ *
257
+ * Empty list elements (`bytes=0-1,,2-3`) are skipped per the RFC 9110
258
+ * Section 5.6.1 list rule; a genuinely malformed element voids the whole
259
+ * header (serve 200), matching single-range leniency.
260
+ */
261
+ export function parseRanges(
262
+ rangeHeader: string | null | undefined,
263
+ totalSize: number,
264
+ maxRanges: number = MAX_RANGES_DEFAULT,
265
+ ): RangeSet | "unsatisfiable" | null {
266
+ if (!Number.isSafeInteger(totalSize) || totalSize < 0) return null;
267
+ if (!rangeHeader || totalSize <= 0) return null;
268
+
269
+ const lower = rangeHeader.toLowerCase();
270
+ if (!lower.startsWith("bytes=")) return null;
271
+
272
+ const specs = rangeHeader.slice(6).split(",");
273
+ const satisfiable: ParsedRange[] = [];
274
+ let sawUnsatisfiable = false;
275
+
276
+ for (const rawSpec of specs) {
277
+ const spec = rawSpec.trim();
278
+ if (spec === "") continue; // RFC 9110 Section 5.6.1 list rule: skip empties
279
+ const parsed = parseOneRange(spec, totalSize);
280
+ if (parsed === null) return null; // malformed element -> ignore whole header
281
+ if (parsed === "unsatisfiable") {
282
+ sawUnsatisfiable = true;
283
+ continue;
284
+ }
285
+ satisfiable.push(parsed);
286
+ }
287
+
288
+ if (satisfiable.length === 0) {
289
+ // Valid syntax but nothing in range -> 416; nothing at all -> serve 200.
290
+ return sawUnsatisfiable ? "unsatisfiable" : null;
291
+ }
292
+
293
+ const coalesced = coalesceRanges(satisfiable);
294
+
295
+ // Amplification defenses: too many parts, or the parts already cover the
296
+ // whole file -- either way, serving the full 200 is cheaper and safe.
297
+ if (coalesced.length > maxRanges) return null;
298
+ const covered = coalesced.reduce((n, r) => n + (r.end - r.start + 1), 0);
299
+ if (covered >= totalSize) return null;
300
+
301
+ return { ranges: coalesced };
302
+ }
303
+
304
+ /**
305
+ * Merge overlapping and adjacent ranges (sorted ascending). Adjacent means
306
+ * `next.start <= cur.end + 1` (a 1-byte gap is NOT bridged; touching ranges
307
+ * are). Prevents redundant bytes in the multipart body.
308
+ */
309
+ function coalesceRanges(ranges: ParsedRange[]): ParsedRange[] {
310
+ const sorted = [...ranges].sort((a, b) => a.start - b.start || a.end - b.end);
311
+ const merged: ParsedRange[] = [{ ...sorted[0]! }];
312
+ for (let i = 1; i < sorted.length; i++) {
313
+ const cur = sorted[i]!;
314
+ const last = merged[merged.length - 1]!;
315
+ if (cur.start <= last.end + 1) {
316
+ if (cur.end > last.end) last.end = cur.end;
317
+ } else {
318
+ merged.push({ ...cur });
319
+ }
320
+ }
321
+ return merged;
322
+ }
323
+
324
+ /**
325
+ * Generate a multipart/byteranges boundary token. Hyphen-free so the token
326
+ * itself can never contain the `--` delimiter run; random so it cannot appear
327
+ * in body content by construction.
328
+ */
329
+ export function generateMultipartBoundary(): string {
330
+ const uuid = globalThis.crypto?.randomUUID?.();
331
+ if (uuid) return `partialcontent${uuid.replace(/-/g, "")}`;
332
+ // Fallback for runtimes without randomUUID: 16 random bytes as hex.
333
+ const bytes = new Uint8Array(16);
334
+ globalThis.crypto.getRandomValues(bytes);
335
+ let hex = "";
336
+ for (const b of bytes) hex += b.toString(16).padStart(2, "0");
337
+ return `partialcontent${hex}`;
338
+ }
339
+
340
+ /**
341
+ * Build the per-part header block for one multipart/byteranges part:
342
+ * `--BOUNDARY CRLF [Content-Type CRLF] Content-Range CRLF CRLF`. The part's
343
+ * body bytes and a trailing CRLF follow (the caller emits those).
344
+ */
345
+ export function buildMultipartPartHeader(
346
+ boundary: string,
347
+ range: ParsedRange,
348
+ totalSize: number,
349
+ contentType: string | undefined,
350
+ ): string {
351
+ const ct = contentType ? `Content-Type: ${sanitizeHeaderValue(contentType)}\r\n` : "";
352
+ return `--${boundary}\r\n${ct}Content-Range: bytes ${range.start}-${range.end}/${totalSize}\r\n\r\n`;
353
+ }
354
+
355
+ /** The closing delimiter for a multipart/byteranges body: `--BOUNDARY-- CRLF`. */
356
+ export function multipartEpilogue(boundary: string): string {
357
+ return `--${boundary}--\r\n`;
358
+ }
359
+
360
+ /** Result of {@link buildMultipartHeaders}. */
361
+ export interface MultipartResponse {
362
+ status: 206;
363
+ headers: Record<string, string>;
364
+ /** Exact byte length of the full multipart body (framing + all part bytes). */
365
+ contentLength: number;
366
+ }
367
+
368
+ /**
369
+ * Build the top-level headers and exact Content-Length for a
370
+ * multipart/byteranges (206) response. The per-representation Content-Type
371
+ * lives in each part; the top-level type is `multipart/byteranges`.
372
+ *
373
+ * Content-Length is computed deterministically from the framing and the
374
+ * range spans, so the response is never chunked and the client gets an exact
375
+ * length up front. `contentType` is the representation's own MIME (placed in
376
+ * every part header).
377
+ */
378
+ export function buildMultipartHeaders(opts: {
379
+ boundary: string;
380
+ ranges: ParsedRange[];
381
+ totalSize: number;
382
+ contentType: string | undefined;
383
+ etag?: string;
384
+ lastModified?: string;
385
+ cacheControl?: string;
386
+ digest?: string;
387
+ }): MultipartResponse {
388
+ const { boundary, ranges, totalSize, contentType, etag, lastModified, cacheControl, digest } = opts;
389
+
390
+ if (!Number.isSafeInteger(totalSize) || totalSize < 0) {
391
+ throw new RangeError(
392
+ `buildMultipartHeaders: totalSize must be a non-negative safe integer, got ${totalSize}`,
393
+ );
394
+ }
395
+
396
+ let contentLength = 0;
397
+ for (const range of ranges) {
398
+ const partHeader = buildMultipartPartHeader(boundary, range, totalSize, contentType);
399
+ // Framing is ASCII, but Content-Type may carry obs-text; count real bytes.
400
+ contentLength += utf8ByteLength(partHeader);
401
+ contentLength += range.end - range.start + 1; // the part body
402
+ contentLength += 2; // trailing CRLF after each part body
403
+ }
404
+ contentLength += utf8ByteLength(multipartEpilogue(boundary));
405
+
406
+ const headers: Record<string, string> = {
407
+ "Accept-Ranges": "bytes",
408
+ "Content-Type": `multipart/byteranges; boundary=${boundary}`,
409
+ "Content-Length": String(contentLength),
410
+ };
411
+ if (etag) headers["ETag"] = sanitizeHeaderValue(etag);
412
+ if (lastModified) headers["Last-Modified"] = toHttpDate(lastModified) ?? sanitizeHeaderValue(lastModified);
413
+ // Repr-Digest covers the full representation, so it is valid on a partial
414
+ // multipart response; Content-Digest is omitted (it would have to cover the
415
+ // assembled parts, not the representation).
416
+ if (digest) emitReprDigest(headers, digest, true);
417
+ if (cacheControl) headers["Cache-Control"] = sanitizeHeaderValue(cacheControl);
418
+
419
+ return { status: 206, headers, contentLength };
420
+ }
421
+
422
+ /** Shared UTF-8 encoder: allocating one per call is pure waste in the
423
+ * per-part multipart sizing loop, and TextEncoder is stateless/reusable. */
424
+ const UTF8_ENCODER = new TextEncoder();
425
+
426
+ /** Byte length of a string as UTF-8 (framing math must count bytes, not code units). */
427
+ function utf8ByteLength(s: string): number {
428
+ return UTF8_ENCODER.encode(s).byteLength;
429
+ }
430
+
431
+ // ─── Conditional Request Helpers (RFC 9110 / RFC 7232) ──────────────────────
432
+
433
+ /**
434
+ * Check if a conditional GET request is "fresh" (resource not modified).
435
+ *
436
+ * Implements RFC 7232 Section 3.2 (If-None-Match) and Section 3.3
437
+ * (If-Modified-Since). `If-None-Match` takes precedence per the spec.
438
+ *
439
+ * When this returns true, the server should respond with 304 Not Modified
440
+ * and skip streaming the body entirely.
441
+ *
442
+ * @param reqHeaders - Request headers (must support `.get(name)`)
443
+ * @param etag - Current resource ETag
444
+ * @param lastModified - Current resource Last-Modified date string
445
+ */
446
+ export function isConditionalFresh(
447
+ reqHeaders: { get(name: string): string | null },
448
+ etag: string | undefined,
449
+ lastModified: string | undefined,
450
+ ): boolean {
451
+ const ifNoneMatch = reqHeaders.get("if-none-match");
452
+ const ifModifiedSince = reqHeaders.get("if-modified-since");
453
+
454
+ // No conditional headers -> not a conditional request
455
+ if (!ifNoneMatch && !ifModifiedSince) return false;
456
+
457
+ // Request `Cache-Control: no-cache` is deliberately IGNORED here, matching
458
+ // Go stdlib and nginx. RFC 9111 aims that directive at caches, not origin
459
+ // conditional evaluation, and a 304 IS the end-to-end revalidation the
460
+ // client asked for. Critically, spec-compliant fetch clients (undici,
461
+ // browsers) auto-append `Cache-Control: no-cache` to any request carrying
462
+ // manual conditional headers -- honoring it would make 304 unreachable for
463
+ // every programmatic revalidation. Hard reloads need no special case: they
464
+ // omit the validators entirely, so this function already returns false.
465
+
466
+ // If-None-Match takes precedence (RFC 7232 Section 6)
467
+ if (ifNoneMatch) {
468
+ // Wildcard: "a representation exists" (RFC 9110 Section 8.8.3).
469
+ // Must be checked before the !etag guard.
470
+ if (ifNoneMatch.trim() === "*") return true;
471
+ if (!etag) return false;
472
+ // Parse comma-separated list of ETags, compare each.
473
+ // Supports both strong and weak comparison (W/ prefix stripping).
474
+ const normalizedEtag = stripWeakPrefix(etag);
475
+ return parseETagList(ifNoneMatch).some(
476
+ (candidate) => stripWeakPrefix(candidate) === normalizedEtag,
477
+ );
478
+ }
479
+
480
+ // If-Modified-Since (only evaluated when If-None-Match is absent)
481
+ if (ifModifiedSince && lastModified) {
482
+ const modifiedDate = parseHttpSeconds(lastModified);
483
+ const sinceDate = parseRequestHttpSeconds(ifModifiedSince);
484
+ if (!isNaN(modifiedDate) && !isNaN(sinceDate)) {
485
+ // RFC 9110 Section 13.1.3: "A recipient MUST ignore
486
+ // If-Modified-Since if the field value is not a valid HTTP-date,
487
+ // or if it is a date in the future." A future date would cause
488
+ // false freshness (always 304).
489
+ if (sinceDate > Date.now()) return false;
490
+ return modifiedDate <= sinceDate;
491
+ }
492
+ }
493
+
494
+ return false;
495
+ }
496
+
497
+ /**
498
+ * Check if a request's preconditions have failed.
499
+ *
500
+ * Implements RFC 7232 Section 3.1 (If-Match) and Section 3.4
501
+ * (If-Unmodified-Since). `If-Match` takes precedence per the spec.
502
+ *
503
+ * When this returns true, the server MUST respond with 412 Precondition
504
+ * Failed and skip processing the request body.
505
+ *
506
+ * Evaluation order per RFC 7232 Section 6:
507
+ * 1. isPreconditionFailure -> 412 (this function)
508
+ * 2. isConditionalFresh -> 304
509
+ * 3. Process range / serve content
510
+ *
511
+ * @param reqHeaders - Request headers (must support `.get(name)`)
512
+ * @param etag - Current resource ETag
513
+ * @param lastModified - Current resource Last-Modified date string
514
+ * @param exists - Whether the resource currently exists. When omitted,
515
+ * inferred from `etag !== undefined`. Callers that have already fetched
516
+ * metadata (e.g. the read orchestrator) should pass `true` explicitly.
517
+ */
518
+ export function isPreconditionFailure(
519
+ reqHeaders: { get(name: string): string | null },
520
+ etag: string | undefined,
521
+ lastModified: string | undefined,
522
+ exists?: boolean,
523
+ ): boolean {
524
+ // If-Match (RFC 7232 Section 3.1)
525
+ const ifMatch = reqHeaders.get("if-match");
526
+ if (ifMatch) {
527
+ // RFC 9110 Section 13.1.1: "If-Match: *" is false when the server has
528
+ // no current representation. Existence is threaded from the caller;
529
+ // when omitted, inferred from etag presence.
530
+ if (ifMatch.trim() === "*") {
531
+ const present = exists ?? (etag !== undefined);
532
+ return !present;
533
+ }
534
+ // No server ETag -> precondition fails (cannot confirm match)
535
+ if (!etag) return true;
536
+ // Strong comparison only. RFC 9110 Section 13.1.1: "A recipient
537
+ // MUST use the strong comparison function when comparing entity-tags
538
+ // for If-Match." This means: (a) both sides must be strong validators
539
+ // (no W/ prefix), and (b) the quoted values must match exactly.
540
+ // If the server's ETag is weak, it cannot satisfy If-Match because
541
+ // a weak validator only asserts semantic equivalence, not byte equality.
542
+ if (etag.startsWith("W/")) return true;
543
+ const matches = parseETagList(ifMatch).some(
544
+ (candidate) => !candidate.startsWith("W/") && candidate === etag,
545
+ );
546
+ return !matches;
547
+ }
548
+
549
+ // If-Unmodified-Since (RFC 7232 Section 3.4)
550
+ // Only evaluated when If-Match is absent.
551
+ const ifUnmodifiedSince = reqHeaders.get("if-unmodified-since");
552
+ if (ifUnmodifiedSince) {
553
+ // RFC 9110 Section 13.1.4: "A recipient MUST ignore the
554
+ // If-Unmodified-Since header field if the resource does not have
555
+ // a modification date available." No date = ignore = no failure.
556
+ if (!lastModified) return false;
557
+ const modifiedDate = parseHttpSeconds(lastModified);
558
+ const sinceDate = parseRequestHttpSeconds(ifUnmodifiedSince);
559
+ if (!isNaN(modifiedDate) && !isNaN(sinceDate)) {
560
+ // Precondition fails if the resource was modified after the client's date
561
+ return modifiedDate > sinceDate;
562
+ }
563
+ // RFC 9110 Section 13.1.4: "A recipient MUST ignore the
564
+ // If-Unmodified-Since header field if the received field-value
565
+ // is not a valid HTTP-date." Unparseable dates are ignored.
566
+ return false;
567
+ }
568
+
569
+ return false;
570
+ }
571
+
572
+ /**
573
+ * Check if a Range request's `If-Range` precondition is "fresh".
574
+ *
575
+ * RFC 7233 Section 3.2: If the client sends `If-Range`, the server MUST
576
+ * only honor the Range when the validator matches. If it doesn't match,
577
+ * the resource has changed since the client cached it, so serving a partial
578
+ * response would result in data corruption (new bytes appended to old content).
579
+ *
580
+ * Returns `true` if the Range should be honored (validator matches or no If-Range).
581
+ * Returns `false` if the Range should be ignored (serve full 200 instead).
582
+ *
583
+ * @param reqHeaders - Request headers
584
+ * @param etag - Current resource ETag
585
+ * @param lastModified - Current resource Last-Modified date string
586
+ */
587
+ export function isRangeFresh(
588
+ reqHeaders: { get(name: string): string | null },
589
+ etag: string | undefined,
590
+ lastModified: string | undefined,
591
+ ): boolean {
592
+ const ifRange = reqHeaders.get("if-range");
593
+
594
+ // No If-Range header -> range is always fresh (honor the Range)
595
+ if (!ifRange) return true;
596
+
597
+ // If-Range as ETag (contains a quote character)
598
+ if (ifRange.includes('"')) {
599
+ if (!etag) return false;
600
+ const client = ifRange.trim();
601
+ // RFC 7233 Section 3.2: If-Range requires a STRONG validator. If either
602
+ // side is weak, the representations may be byte-different, so honoring
603
+ // the range could splice mismatched bytes onto the client's cached body.
604
+ if (client.startsWith("W/") || etag.startsWith("W/")) return false;
605
+ return client === etag; // both strong: exact quoted match, no W/ stripping
606
+ }
607
+
608
+ // If-Range as HTTP-date. RFC 9110 Section 13.1.5: the condition is true
609
+ // only if the date EXACTLY matches the representation's Last-Modified.
610
+ // A lenient `<=` would honor the range when the current Last-Modified is
611
+ // older than the client's cached date (clock skew, restored backup), even
612
+ // though the bytes may differ -- splicing corrupted content.
613
+ if (lastModified) {
614
+ const lastMod = parseHttpSeconds(lastModified);
615
+ const ifRangeDate = parseRequestHttpSeconds(ifRange);
616
+ if (!isNaN(lastMod) && !isNaN(ifRangeDate)) {
617
+ return lastMod === ifRangeDate;
618
+ }
619
+ }
620
+
621
+ // Cannot validate -> ignore the range (return full resource)
622
+ return false;
623
+ }
624
+
625
+ // ─── Response Builder ───────────────────────────────────────────────────────
626
+
627
+ /**
628
+ * Build HTTP response headers for a full or partial content response.
629
+ *
630
+ * Always includes `Accept-Ranges: bytes` to advertise range support
631
+ * (even on 200 responses, per RFC 7233 Section 2.3).
632
+ *
633
+ * Handles:
634
+ * - 200 OK (full content, when range is null)
635
+ * - 206 Partial Content (valid range)
636
+ *
637
+ * For error statuses (304, 412, 416), use the dedicated builders
638
+ * (`build304Headers`, `build412Headers`, `build416Headers`).
639
+ *
640
+ * @returns Status code and headers dict ready to pass to `new Response()`.
641
+ */
642
+ export function buildRangeResponseHeaders(opts: RangeResponseHeaderOpts): RangeResponseHeaders {
643
+ const { totalSize, range, contentType, etag, lastModified, digest, cacheControl } = opts;
644
+
645
+ // Validate totalSize to prevent invalid Content-Length / Content-Range headers.
646
+ // Content-Length MUST be a non-negative integer (RFC 9110 Section 8.6).
647
+ // `undefined` is the unknown-total sentinel (`bytes a-b/*`); it is only
648
+ // admissible on a partial response and is re-checked on the 200 path below.
649
+ if (totalSize !== undefined && (!Number.isSafeInteger(totalSize) || totalSize < 0)) {
650
+ throw new RangeError(
651
+ `buildRangeResponseHeaders: totalSize must be a non-negative safe integer or undefined, got ${totalSize}`,
652
+ );
653
+ }
654
+
655
+ const headers: Record<string, string> = {};
656
+
657
+ // Always advertise range support
658
+ headers["Accept-Ranges"] = "bytes";
659
+
660
+ if (contentType) {
661
+ // MIME types commonly originate from stored upload metadata
662
+ // (attacker-influenced at upload time); header-sanitize like every
663
+ // other metadata-derived value.
664
+ headers["Content-Type"] = sanitizeHeaderValue(contentType);
665
+ }
666
+
667
+ if (etag) {
668
+ headers["ETag"] = sanitizeHeaderValue(etag);
669
+ }
670
+
671
+ if (lastModified) {
672
+ // Strip CRLF from raw fallback to prevent header injection on unparseable dates
673
+ headers["Last-Modified"] = toHttpDate(lastModified) ?? sanitizeHeaderValue(lastModified);
674
+ }
675
+
676
+ // RFC 9530: Repr-Digest covers the full representation.
677
+ if (digest) emitReprDigest(headers, digest, range !== null);
678
+
679
+ if (range) {
680
+ // 206 Partial Content. The body length is the range span, so an unknown
681
+ // total is emitted honestly as `*` (RFC 7233 Section 4.2) rather than a
682
+ // fabricated number.
683
+ const rangeLength = range.end - range.start + 1;
684
+ headers["Content-Length"] = String(rangeLength);
685
+ headers["Content-Range"] = `bytes ${range.start}-${range.end}/${totalSize ?? "*"}`;
686
+ if (cacheControl) headers["Cache-Control"] = sanitizeHeaderValue(cacheControl);
687
+ return { status: 206, headers };
688
+ }
689
+
690
+ // 200 OK (full content) -- a bodyful full response cannot be sized without a
691
+ // known total, so the unknown sentinel is an adapter bug on this path.
692
+ if (totalSize === undefined) {
693
+ throw new RangeError(
694
+ "buildRangeResponseHeaders: a full (non-range) response requires a known totalSize",
695
+ );
696
+ }
697
+ headers["Content-Length"] = String(totalSize);
698
+ if (cacheControl) headers["Cache-Control"] = sanitizeHeaderValue(cacheControl);
699
+ return { status: 200, headers };
700
+ }
701
+
702
+ /**
703
+ * Build 416 Range Not Satisfiable response headers.
704
+ *
705
+ * Per RFC 7233 Section 4.4, the response MUST include a Content-Range
706
+ * header with the unsatisfied-range syntax: `bytes * /totalSize`.
707
+ *
708
+ * Deliberately omits ETag, Last-Modified, and Content-Type. These are
709
+ * representation metadata for the successful response; including them
710
+ * on error responses can poison shared caches.
711
+ */
712
+ export function build416Headers(totalSize: number): RangeResponseHeaders {
713
+ return {
714
+ status: 416,
715
+ headers: {
716
+ "Accept-Ranges": "bytes",
717
+ "Content-Range": `bytes */${totalSize}`,
718
+ "Content-Length": "0",
719
+ },
720
+ };
721
+ }
722
+
723
+ // ─── Content-Range Parser ───────────────────────────────────────────────────
724
+
725
+ /** Result of parsing a Content-Range response header. */
726
+ export interface ParsedContentRange {
727
+ /** First byte of the range (inclusive, 0-indexed). */
728
+ start: number;
729
+ /** Last byte of the range (inclusive). */
730
+ end: number;
731
+ /** Total size of the complete representation. */
732
+ totalSize: number;
733
+ }
734
+
735
+ /**
736
+ * Parse a `Content-Range` response header into structured fields.
737
+ *
738
+ * Understands the two RFC 7233 Section 4.2 forms:
739
+ * - `bytes 0-499/1000` (byte range with known total)
740
+ * - `bytes 0-499/*` (byte range with unknown total, returns `totalSize: -1`)
741
+ *
742
+ * The unsatisfied-range form (`bytes * /1000`) is NOT parsed because it carries
743
+ * no range information -- callers should check the HTTP status (416) instead.
744
+ *
745
+ * Returns `null` for:
746
+ * - Missing or empty header
747
+ * - Non-byte range units
748
+ * - Malformed syntax
749
+ * - Unsatisfied range form (`bytes * /...`)
750
+ *
751
+ * Storage adapters use this to extract `totalSize` from a 206 response when
752
+ * the backend serves a range slice. Without it, each adapter reimplements the
753
+ * same regex inline, with varying correctness on edge cases.
754
+ *
755
+ * @example
756
+ * ```typescript
757
+ * import { parseContentRange } from "partial-content";
758
+ *
759
+ * const cr = parseContentRange("bytes 0-499/1000");
760
+ * // => { start: 0, end: 499, totalSize: 1000 }
761
+ *
762
+ * const unknown = parseContentRange("bytes 0-499/*");
763
+ * // => { start: 0, end: 499, totalSize: -1 }
764
+ * ```
765
+ */
766
+ export function parseContentRange(
767
+ header: string | null | undefined,
768
+ ): ParsedContentRange | null {
769
+ if (!header) return null;
770
+
771
+ // RFC 7233 Section 4.2: content-range = byte-content-range / other-content-range
772
+ // byte-content-range = bytes-unit SP ( byte-range-resp / unsatisfied-range )
773
+ // Case-insensitive unit per RFC 9110 Section 14.1.
774
+ const lower = header.toLowerCase();
775
+ if (!lower.startsWith("bytes ")) return null;
776
+
777
+ const spec = header.slice(6); // Strip "bytes "
778
+
779
+ // Reject unsatisfied-range form: "bytes */1000"
780
+ if (spec.startsWith("*")) return null;
781
+
782
+ // Parse "start-end/total" or "start-end/*"
783
+ const slashIdx = spec.indexOf("/");
784
+ if (slashIdx === -1) return null;
785
+
786
+ const rangePart = spec.slice(0, slashIdx).trim();
787
+ const totalPart = spec.slice(slashIdx + 1).trim();
788
+
789
+ const dashIdx = rangePart.indexOf("-");
790
+ if (dashIdx === -1) return null;
791
+
792
+ const startStr = rangePart.slice(0, dashIdx).trim();
793
+ const endStr = rangePart.slice(dashIdx + 1).trim();
794
+
795
+ if (!startStr || !endStr) return null;
796
+
797
+ const start = parseStrictInt(startStr);
798
+ const end = parseStrictInt(endStr);
799
+ if (isNaN(start) || isNaN(end)) return null;
800
+ if (start > end) return null;
801
+
802
+ // Total: "*" means unknown (use -1 sentinel), otherwise must be a valid integer.
803
+ let totalSize: number;
804
+ if (totalPart === "*") {
805
+ totalSize = -1;
806
+ } else {
807
+ totalSize = parseStrictInt(totalPart);
808
+ if (isNaN(totalSize) || totalSize < 0) return null;
809
+ // Sanity: end must be < totalSize when total is known.
810
+ if (end >= totalSize) return null;
811
+ }
812
+
813
+ return { start, end, totalSize };
814
+ }
815
+
816
+ /**
817
+ * Parse a string as a strict non-negative integer.
818
+ * Rejects floats, scientific notation, negative values, and leading zeros
819
+ * (except for the literal "0").
820
+ */
821
+ function parseStrictInt(s: string): number {
822
+ if (!/^\d+$/.test(s)) return NaN;
823
+ const n = Number(s);
824
+ if (n > Number.MAX_SAFE_INTEGER) return NaN;
825
+ return n;
826
+ }
827
+
828
+ /**
829
+ * Build 412 Precondition Failed response headers.
830
+ *
831
+ * Per RFC 7232 Section 4.2, a 412 response indicates that one or more
832
+ * conditions in the request headers (If-Match, If-Unmodified-Since)
833
+ * evaluated to false.
834
+ *
835
+ * Deliberately omits representation metadata (ETag, Last-Modified,
836
+ * Content-Type) to prevent cache poisoning.
837
+ */
838
+ export function build412Headers(): RangeResponseHeaders {
839
+ return {
840
+ status: 412,
841
+ headers: {
842
+ // Explicit Content-Length: 0 for enterprise proxies (HAProxy, Envoy)
843
+ // that require it on bodyless responses to avoid chunked-encoding timeouts.
844
+ "Content-Length": "0",
845
+ },
846
+ };
847
+ }
848
+
849
+ /**
850
+ * Build 304 Not Modified response headers.
851
+ *
852
+ * Per RFC 7232 Section 4.1, a 304 response MUST NOT contain a message
853
+ * body and SHOULD NOT include representation headers (Content-Type,
854
+ * Content-Length, Content-Encoding, Content-Language, Content-Range).
855
+ *
856
+ * Only includes: ETag, Last-Modified, and Cache-Control.
857
+ */
858
+ export function build304Headers(
859
+ etag: string | undefined,
860
+ lastModified: string | undefined,
861
+ cacheControl?: string,
862
+ ): RangeResponseHeaders {
863
+ const headers: Record<string, string> = {};
864
+
865
+ if (etag) {
866
+ headers["ETag"] = sanitizeHeaderValue(etag);
867
+ }
868
+ // RFC 7232 Section 4.1: "a sender SHOULD NOT generate representation
869
+ // metadata other than the above listed fields unless said metadata exists
870
+ // for the purpose of guiding cache updates (e.g., Last-Modified might be
871
+ // useful if the response does not have an ETag field)."
872
+ // When ETag is present, Last-Modified is redundant (ETag is the stronger
873
+ // validator). Omitting it reduces 304 size and matches Go stdlib behavior.
874
+ if (lastModified && !etag) {
875
+ headers["Last-Modified"] = toHttpDate(lastModified) ?? sanitizeHeaderValue(lastModified);
876
+ }
877
+ if (cacheControl) {
878
+ headers["Cache-Control"] = sanitizeHeaderValue(cacheControl);
879
+ }
880
+
881
+ return { status: 304, headers };
882
+ }
883
+
884
+ /**
885
+ * Evaluate a conditional request and return the correct HTTP response.
886
+ *
887
+ * Implements the full RFC 7232 Section 6 evaluation chain in the correct
888
+ * order. This is the recommended entry point for most consumers -- it
889
+ * eliminates the risk of misordering the evaluation steps.
890
+ *
891
+ * Evaluation order:
892
+ * 1. `isPreconditionFailure` -> 412 Precondition Failed
893
+ * 2. `isConditionalFresh` -> 304 Not Modified
894
+ * 3. `isRangeFresh` -> Should we honor the Range?
895
+ * 4. `parseRangeHeader` -> Parse and validate the Range
896
+ * 5. `buildRangeResponseHeaders` or `build416Headers`
897
+ *
898
+ * @returns Status, headers, and the parsed range (null if full content or error status).
899
+ */
900
+ export function evaluateConditionalRequest(
901
+ reqHeaders: { get(name: string): string | null },
902
+ meta: {
903
+ totalSize: number;
904
+ contentType?: string;
905
+ etag?: string;
906
+ lastModified?: string;
907
+ cacheControl?: string;
908
+ /** RFC 9530 SHA-256 digest (raw base64, no prefix). Emitted as `Repr-Digest` on 200/206. */
909
+ digest?: string;
910
+ },
911
+ ): EvaluatedRequest {
912
+ // Guard against corrupt metadata from storage adapters. NaN, Infinity,
913
+ // negative, or fractional sizes would produce invalid Content-Range /
914
+ // Content-Length headers. Content-Length MUST be a non-negative integer
915
+ // (RFC 9110 Section 8.6). Fail loudly so the caller notices the adapter bug.
916
+ if (!Number.isSafeInteger(meta.totalSize) || meta.totalSize < 0) {
917
+ throw new RangeError(
918
+ `evaluateConditionalRequest: totalSize must be a non-negative safe integer, got ${meta.totalSize}`,
919
+ );
920
+ }
921
+ // Pre-compute the HTTP-date once. toHttpDate calls Date.parse + toUTCString
922
+ // which together cost ~190ns. Without caching, the orchestrator would call
923
+ // it up to 4 times on the same string (isPreconditionFailure,
924
+ // isConditionalFresh, isRangeFresh, and the final header builder).
925
+ const httpDate = meta.lastModified ? toHttpDate(meta.lastModified) : undefined;
926
+ // Use the normalized IMF-fixdate for all downstream calls.
927
+ // If toHttpDate returned undefined (unparseable), fall back to sanitized
928
+ // raw string. This matches the standalone builders (build304Headers,
929
+ // buildRangeResponseHeaders) which apply the same sanitizeHeaderValue on
930
+ // their fallback path.
931
+ const normalizedLastModified = httpDate ?? (meta.lastModified ? sanitizeHeaderValue(meta.lastModified) : undefined);
932
+
933
+ // Step 1: Preconditions (If-Match / If-Unmodified-Since)
934
+ // Pass exists: true because metadata was already fetched (the resource exists).
935
+ if (isPreconditionFailure(reqHeaders, meta.etag, normalizedLastModified, true)) {
936
+ return { ...build412Headers(), range: null };
937
+ }
938
+
939
+ // Step 2: Freshness (If-None-Match / If-Modified-Since)
940
+ if (isConditionalFresh(reqHeaders, meta.etag, normalizedLastModified)) {
941
+ // Pass pre-formatted date directly to avoid another toHttpDate call.
942
+ const headers: Record<string, string> = {};
943
+ if (meta.etag) headers["ETag"] = sanitizeHeaderValue(meta.etag);
944
+ // RFC 7232 Section 4.1: omit Last-Modified when ETag is present (see build304Headers).
945
+ if (normalizedLastModified && !meta.etag) headers["Last-Modified"] = normalizedLastModified;
946
+ if (meta.cacheControl) headers["Cache-Control"] = sanitizeHeaderValue(meta.cacheControl);
947
+ return { status: 304, headers, range: null };
948
+ }
949
+
950
+ // Step 3-4: Range validation and parsing
951
+ const rangeHeader = reqHeaders.get("range");
952
+ const rangeFresh = isRangeFresh(reqHeaders, meta.etag, normalizedLastModified);
953
+ const parsed = rangeFresh && rangeHeader
954
+ ? parseRangeHeader(rangeHeader, meta.totalSize)
955
+ : null;
956
+
957
+ // Step 5: Build response
958
+ if (parsed === "unsatisfiable") {
959
+ return { ...build416Headers(meta.totalSize), range: null };
960
+ }
961
+
962
+ // Inline the header building to avoid another toHttpDate call inside
963
+ // buildRangeResponseHeaders.
964
+ const headers: Record<string, string> = {};
965
+ headers["Accept-Ranges"] = "bytes";
966
+ if (meta.contentType) headers["Content-Type"] = sanitizeHeaderValue(meta.contentType);
967
+ if (meta.etag) headers["ETag"] = sanitizeHeaderValue(meta.etag);
968
+ if (normalizedLastModified) headers["Last-Modified"] = normalizedLastModified;
969
+
970
+ // RFC 9530: Repr-Digest and Content-Digest.
971
+ // Only emit when the client accepts sha-256 (or sends no Want-* header).
972
+ if (meta.digest && clientWantsDigest(reqHeaders)) {
973
+ emitReprDigest(headers, meta.digest, parsed !== null);
974
+ }
975
+
976
+ // Emit Cache-Control on 200/206 (not only 304) so standalone orchestrator
977
+ // consumers get complete response headers without a framework adapter.
978
+ if (meta.cacheControl) headers["Cache-Control"] = sanitizeHeaderValue(meta.cacheControl);
979
+
980
+ if (parsed) {
981
+ const rangeLength = parsed.end - parsed.start + 1;
982
+ headers["Content-Length"] = String(rangeLength);
983
+ headers["Content-Range"] = `bytes ${parsed.start}-${parsed.end}/${meta.totalSize}`;
984
+ return { status: 206, headers, range: parsed };
985
+ }
986
+
987
+ headers["Content-Length"] = String(meta.totalSize);
988
+ return { status: 200, headers, range: parsed };
989
+ }
990
+
991
+ // ─── Write-Side Orchestrator ────────────────────────────────────────────────
992
+
993
+ /**
994
+ * Result of a conditional-write evaluation.
995
+ *
996
+ * Discriminated union on `proceed`:
997
+ * - `{ proceed: true }` -- safe to execute the write.
998
+ * - `{ proceed: false, status: 412, headers }` -- return 412 to the client.
999
+ */
1000
+ export type EvaluatedWrite =
1001
+ | { proceed: true }
1002
+ | { proceed: false; status: 412; headers: Record<string, string> };
1003
+
1004
+ /**
1005
+ * Evaluate conditional headers for a **write** request (PUT, PATCH, DELETE).
1006
+ *
1007
+ * On read requests (GET/HEAD), `If-None-Match` match triggers 304 Not Modified.
1008
+ * On write requests, the same match triggers **412 Precondition Failed**
1009
+ * (RFC 9110 Section 13.1.2). This function implements the write-side
1010
+ * evaluation so consumers don't accidentally use the read-side orchestrator
1011
+ * for OCC flows.
1012
+ *
1013
+ * Evaluation order (RFC 9110 Section 13.2.2):
1014
+ * 1. `If-Match` / `If-Unmodified-Since` -> 412 (same as reads)
1015
+ * 2. `If-None-Match` -> 412 (differs from reads: 412, not 304)
1016
+ *
1017
+ * ### OCC pattern (If-Match)
1018
+ *
1019
+ * ```typescript
1020
+ * const result = evaluateConditionalWrite(req.headers, {
1021
+ * etag: '"v2"',
1022
+ * lastModified: "2025-06-28T12:00:00.000Z",
1023
+ * });
1024
+ * if (!result.proceed) {
1025
+ * return new Response(null, { status: result.status, headers: result.headers });
1026
+ * }
1027
+ * // Safe to apply the mutation
1028
+ * ```
1029
+ *
1030
+ * ### PUT-if-absent pattern (If-None-Match: *)
1031
+ *
1032
+ * ```typescript
1033
+ * const result = evaluateConditionalWrite(req.headers, {
1034
+ * etag: existingEtag, // undefined if resource doesn't exist yet
1035
+ * exists: resourceExists,
1036
+ * });
1037
+ * ```
1038
+ *
1039
+ * @param reqHeaders - Request headers (must support `.get(name)`)
1040
+ * @param meta - Current resource state
1041
+ */
1042
+ export function evaluateConditionalWrite(
1043
+ reqHeaders: { get(name: string): string | null },
1044
+ meta: {
1045
+ etag?: string;
1046
+ lastModified?: string;
1047
+ /**
1048
+ * Whether the target resource currently exists. Used to evaluate
1049
+ * `If-None-Match: *` (PUT-if-absent / create-only pattern).
1050
+ *
1051
+ * When omitted, existence is inferred from `etag !== undefined`.
1052
+ */
1053
+ exists?: boolean;
1054
+ },
1055
+ ): EvaluatedWrite {
1056
+ // Normalize lastModified once, same pattern as the read orchestrator.
1057
+ const httpDate = meta.lastModified ? toHttpDate(meta.lastModified) : undefined;
1058
+ const normalizedLastModified = httpDate ?? (meta.lastModified ? sanitizeHeaderValue(meta.lastModified) : undefined);
1059
+
1060
+ // Helper: build 412 headers, including the current ETag when available
1061
+ // so the client can resync without a follow-up GET.
1062
+ const make412 = (): EvaluatedWrite => ({
1063
+ proceed: false,
1064
+ status: 412 as const,
1065
+ headers: {
1066
+ "Content-Length": "0",
1067
+ ...(meta.etag ? { ETag: sanitizeHeaderValue(meta.etag) } : {}),
1068
+ },
1069
+ });
1070
+
1071
+ // Step 1: If-Match / If-Unmodified-Since -> 412
1072
+ // Thread existence so If-Match:* is evaluated correctly.
1073
+ if (isPreconditionFailure(reqHeaders, meta.etag, normalizedLastModified, meta.exists)) {
1074
+ return make412();
1075
+ }
1076
+
1077
+ // Step 2: If-None-Match -> 412 (NOT 304)
1078
+ // RFC 9110 Section 13.1.2: "if the request method is not GET or HEAD,
1079
+ // the server MUST respond with a 412 (Precondition Failed) status code."
1080
+ const ifNoneMatch = reqHeaders.get("if-none-match");
1081
+ if (ifNoneMatch) {
1082
+ if (ifNoneMatch.trim() === "*") {
1083
+ // Create-only. If existence is genuinely unknowable, FAIL CLOSED.
1084
+ // A loud error beats a silent overwrite of an existing resource.
1085
+ if (meta.exists === undefined && meta.etag === undefined) {
1086
+ throw new Error(
1087
+ "evaluateConditionalWrite: 'If-None-Match: *' (create-only) requires a known " +
1088
+ "existence state. Pass meta.exists explicitly -- refusing to guess and risk " +
1089
+ "overwriting an existing resource.",
1090
+ );
1091
+ }
1092
+ const resourceExists = meta.exists ?? (meta.etag !== undefined);
1093
+ if (resourceExists) {
1094
+ return make412();
1095
+ }
1096
+ } else if (meta.etag) {
1097
+ // Check if any client-provided ETag matches the current resource.
1098
+ const normalizedEtag = stripWeakPrefix(meta.etag);
1099
+ const matches = parseETagList(ifNoneMatch).some(
1100
+ (candidate) => stripWeakPrefix(candidate) === normalizedEtag,
1101
+ );
1102
+ if (matches) {
1103
+ return make412();
1104
+ }
1105
+ }
1106
+ }
1107
+
1108
+ return { proceed: true };
1109
+ }
1110
+
1111
+ /**
1112
+ * Adapter for Node.js HTTP servers (Express, Fastify, raw `http`).
1113
+ *
1114
+ * Converts a plain Node headers object (`IncomingHttpHeaders`) into the
1115
+ * `{ get(name) }` interface expected by `partial-content` functions.
1116
+ *
1117
+ * @example
1118
+ * ```typescript
1119
+ * import { fromNodeHeaders, isConditionalFresh } from "partial-content";
1120
+ *
1121
+ * // Express
1122
+ * app.get("/file", (req, res) => {
1123
+ * const headers = fromNodeHeaders(req.headers);
1124
+ * if (isConditionalFresh(headers, etag, lastModified)) {
1125
+ * return res.status(304).end();
1126
+ * }
1127
+ * });
1128
+ * ```
1129
+ */
1130
+ export function fromNodeHeaders(
1131
+ headers: Record<string, string | string[] | undefined>,
1132
+ ): { get(name: string): string | null } {
1133
+ // Normalize keys to lowercase at construction time so lookups
1134
+ // work regardless of the caller's casing convention.
1135
+ const lower: Record<string, string | string[] | undefined> = {};
1136
+ for (const key in headers) {
1137
+ lower[key.toLowerCase()] = headers[key];
1138
+ }
1139
+ return {
1140
+ get(name: string): string | null {
1141
+ const val = lower[name.toLowerCase()];
1142
+ if (val === undefined) return null;
1143
+ // Node.js joins multi-value headers with ', ' for most headers,
1144
+ // but some proxies pass arrays. Join to match HTTP semantics.
1145
+ return Array.isArray(val) ? val.join(", ") : val;
1146
+ },
1147
+ };
1148
+ }
1149
+
1150
+ // ─── ETag Generation ────────────────────────────────────────────────────────
1151
+
1152
+ /** Metadata from a storage backend used to derive an entity-tag. */
1153
+ export interface ETagSource {
1154
+ /**
1155
+ * Content digest from the backend (S3/Azure/GCS object hash).
1156
+ * Yields a STRONG validator. May include surrounding quotes or `W/` prefix,
1157
+ * both of which are normalized.
1158
+ */
1159
+ hash?: string;
1160
+ /** Object size in bytes. With `mtime`, yields a WEAK validator when no hash exists. */
1161
+ size?: number;
1162
+ /**
1163
+ * Last-modified: `Date`, epoch-ms number, or any `Date.parse()`-able string.
1164
+ * Floor to whole seconds for consistency with emitted `Last-Modified` headers.
1165
+ */
1166
+ mtime?: Date | number | string;
1167
+ }
1168
+
1169
+ /**
1170
+ * Derive an entity-tag from storage metadata.
1171
+ *
1172
+ * This is a **formatter**, not a hasher. It classifies what the storage backend
1173
+ * already provides into the correct validator strength per RFC 9110 Section 8.8.1:
1174
+ *
1175
+ * - `hash` present -> strong `"<hash>"` (a content digest asserts byte equality)
1176
+ * - `size` + `mtime` only -> weak `W/"<size>-<mtime>"` (cannot assert byte equality;
1177
+ * mtime resolution is coarse, collisions exist)
1178
+ * - insufficient metadata -> `undefined` (caller omits ETag; never fabricate a
1179
+ * validator we cannot stand behind)
1180
+ *
1181
+ * The dangerous failure mode is emitting a strong or size-only validator that ignores
1182
+ * modification, which would serve stale 304s. Every `undefined` return guards that.
1183
+ *
1184
+ * @param source - Storage metadata to derive the ETag from
1185
+ * @returns A correctly-typed ETag string, or `undefined` if insufficient metadata
1186
+ */
1187
+ export function generateETag(source: ETagSource): string | undefined {
1188
+ // Strong path: a content digest is a byte-exact validator.
1189
+ if (source.hash) {
1190
+ const raw = source.hash.trim();
1191
+ if (!raw) return toWeakETag(source);
1192
+
1193
+ const stripped = stripWeakPrefix(raw);
1194
+ const weak = stripped !== raw; // W/ prefix was present
1195
+ // Strip anchoring quotes, then enforce etagc grammar (removes interior
1196
+ // DQUOTE/DEL/controls) so the emitted tag is always well-formed.
1197
+ const unquoted = sanitizeETagBody(stripped.replace(/^"|"$/g, ""));
1198
+ if (unquoted) return `${weak ? "W/" : ""}"${unquoted}"`;
1199
+
1200
+ // Hash cleaned to empty (was just quotes) -> fall through to weak path
1201
+ return toWeakETag(source);
1202
+ }
1203
+
1204
+ return toWeakETag(source);
1205
+ }
1206
+
1207
+ /**
1208
+ * Attempt to build a weak ETag from size + mtime.
1209
+ * Returns `undefined` if either is missing or invalid.
1210
+ */
1211
+ function toWeakETag(source: ETagSource): string | undefined {
1212
+ if (source.size === undefined || !Number.isFinite(source.size) || source.size < 0) return undefined;
1213
+
1214
+ const ms = toEpochMs(source.mtime);
1215
+ if (ms === undefined) return undefined;
1216
+
1217
+ const sizeHex = Math.floor(source.size).toString(16);
1218
+ // Floor mtime to seconds to match the emitted Last-Modified header resolution.
1219
+ // Without this, the ETag and Last-Modified would disagree on sub-second jitter.
1220
+ const mtimeHex = Math.floor(ms / 1000).toString(16);
1221
+ return `W/"${sizeHex}-${mtimeHex}"`;
1222
+ }
1223
+
1224
+ /** Convert a Date, number, or string to epoch milliseconds, or undefined if unparseable. */
1225
+ function toEpochMs(m: Date | number | string | undefined): number | undefined {
1226
+ if (m === undefined) return undefined;
1227
+ const t = m instanceof Date ? m.getTime() : typeof m === "number" ? m : Date.parse(m);
1228
+ return Number.isNaN(t) ? undefined : t;
1229
+ }
1230
+
1231
+ // ─── Internal Helpers ───────────────────────────────────────────────────────
1232
+
1233
+ /**
1234
+ * Strip bytes outside RFC 9110 field-value grammar to prevent HTTP header
1235
+ * injection (CWE-113) and header-write crashes.
1236
+ *
1237
+ * Storage backends may pass through untrusted ETag, Last-Modified,
1238
+ * Content-Type, digest, Cache-Control, or Location values. Exported so
1239
+ * framework adapters can sanitize backend-derived headers the kernel does
1240
+ * not build itself (e.g. the web adapter's signed-URL `Location`).
1241
+ */
1242
+ export function sanitizeHeaderValue(s: string): string {
1243
+ // Strip every byte outside RFC 9110 field-value grammar (HTAB, SP,
1244
+ // visible ASCII, obs-text). CR/LF alone is not enough: Node writeHead,
1245
+ // undici Headers, and Workers all THROW on any other control byte
1246
+ // (\x00-\x08, \x0B, \x0C, \x7F), which would turn a poisoned metadata
1247
+ // value into a runtime crash instead of a clean response. The character
1248
+ // class mirrors Node's own invalid-header-char check, so anything that
1249
+ // survives is writable on every runtime.
1250
+ return s.replace(/[^\t\x20-\x7e\x80-ÿ]/g, "");
1251
+ }
1252
+
1253
+ /**
1254
+ * Emit RFC 9530 `Repr-Digest` (and `Content-Digest` on a full response) for
1255
+ * a backend SHA-256 digest. Single source of truth for both the header
1256
+ * builder and the orchestrator: the digest is header-sanitized (custom
1257
+ * stores can return anything), and `Content-Digest` is omitted on a 206
1258
+ * because it would have to cover only the range slice (RFC 9530 Section 2),
1259
+ * which requires streaming the bytes through crypto.
1260
+ */
1261
+ function emitReprDigest(headers: Record<string, string>, digest: string, isPartial: boolean): void {
1262
+ const reprDigest = `sha-256=:${sanitizeHeaderValue(digest)}:`;
1263
+ headers["Repr-Digest"] = reprDigest;
1264
+ if (!isPartial) headers["Content-Digest"] = reprDigest;
1265
+ }
1266
+
1267
+ /**
1268
+ * Sanitize an ETag body to RFC 9110 Section 8.8.3 `etagc` grammar:
1269
+ * `%x21 / %x23-7E / obs-text` -- i.e. field-value bytes MINUS DQUOTE
1270
+ * (0x22), which is the entity-tag delimiter. A backend hash containing an
1271
+ * embedded quote would otherwise emit a structurally-broken ETag like
1272
+ * `"abc"def"` that no cache can parse (silently defeating revalidation).
1273
+ * DEL (0x7F) and control bytes are already removed by sanitizeHeaderValue.
1274
+ */
1275
+ function sanitizeETagBody(s: string): string {
1276
+ return sanitizeHeaderValue(s).replace(/"/g, "");
1277
+ }
1278
+
1279
+ /** Strip the `W/` weak validator prefix from an ETag for comparison. */
1280
+ function stripWeakPrefix(etag: string): string {
1281
+ return etag.startsWith("W/") ? etag.slice(2) : etag;
1282
+ }
1283
+
1284
+ /**
1285
+ * Parse a list of ETags from an If-None-Match or If-Match header.
1286
+ *
1287
+ * Quote-aware: correctly handles commas inside ETag values, which are
1288
+ * valid per RFC 7232 Section 2.3 (etagc includes %x2C). A naive
1289
+ * `.split(",")` would incorrectly split `"ver,1", "ver,2"` into four
1290
+ * fragments instead of two ETags.
1291
+ *
1292
+ * Uses character-by-character parsing with quote boundary tracking
1293
+ * (modeled on Go stdlib `scanETag()` from `net/http/fs.go`).
1294
+ */
1295
+ function parseETagList(header: string): string[] {
1296
+ const tags: string[] = [];
1297
+ let i = 0;
1298
+ const len = header.length;
1299
+
1300
+ while (i < len) {
1301
+ // Skip whitespace and comma separators
1302
+ while (i < len && (header[i] === " " || header[i] === "\t" || header[i] === ",")) i++;
1303
+ if (i >= len) break;
1304
+
1305
+ const start = i;
1306
+
1307
+ // Check for W/ weak prefix
1308
+ if (header[i] === "W" && i + 1 < len && header[i + 1] === "/") {
1309
+ i += 2;
1310
+ }
1311
+
1312
+ // Must start with a double quote
1313
+ if (i >= len || header[i] !== '"') {
1314
+ // Not a valid ETag, skip to next comma or end
1315
+ while (i < len && header[i] !== ",") i++;
1316
+ continue;
1317
+ }
1318
+ i++; // skip opening quote
1319
+
1320
+ // Scan for closing quote, validating each character is a valid etagc.
1321
+ // RFC 9110 Section 8.8.3: etagc = %x21 / %x23-7E / obs-text
1322
+ // obs-text = %x80-FF (extended ASCII / UTF-8 continuation bytes)
1323
+ // This matches Go stdlib scanETag() which rejects invalid characters.
1324
+ let valid = true;
1325
+ while (i < len && header[i] !== '"') {
1326
+ const c = header.charCodeAt(i);
1327
+ // Valid etagc: 0x21 (!) or 0x23-0x7E (#..~) or >= 0x80 (obs-text)
1328
+ // Invalid: 0x00-0x20 (controls + space), 0x22 ("), 0x7F (DEL)
1329
+ // 0x22 is the quote terminator so it's handled by the while condition.
1330
+ if (c < 0x21 || c === 0x7F) {
1331
+ valid = false;
1332
+ break;
1333
+ }
1334
+ i++;
1335
+ }
1336
+
1337
+ if (!valid) {
1338
+ // Invalid etagc character found, skip this malformed ETag
1339
+ while (i < len && header[i] !== ",") i++;
1340
+ continue;
1341
+ }
1342
+
1343
+ if (i < len) i++; // skip closing quote
1344
+
1345
+ tags.push(header.slice(start, i));
1346
+ }
1347
+
1348
+ return tags;
1349
+ }
1350
+
1351
+ /**
1352
+ * Parse a string as a non-negative integer position.
1353
+ *
1354
+ * Only accepts pure digit strings. Rejects floats ("1.5"), scientific
1355
+ * notation ("1e3"), and negative values ("-5") that `parseInt()` would
1356
+ * silently truncate to valid-looking integers.
1357
+ *
1358
+ * Values beyond `Number.MAX_SAFE_INTEGER` are capped rather than rejected.
1359
+ * Some HTTP clients (Go net/http, curl -r) send max-uint64 values for
1360
+ * open-ended ranges (e.g. `bytes=500-18446744073709551615`). The downstream
1361
+ * `Math.min(end, totalSize - 1)` clamps to the file boundary, so capping
1362
+ * here is safe and avoids unnecessary full-file fallback.
1363
+ */
1364
+ function parsePos(str: string): number {
1365
+ if (!/^\d+$/.test(str)) return NaN;
1366
+ const n = Number(str);
1367
+ // Cap values that lost integer precision. Any realistic file size
1368
+ // fits in MAX_SAFE_INTEGER (9 PB). The downstream Math.min clamp
1369
+ // narrows to the actual file boundary.
1370
+ if (n > Number.MAX_SAFE_INTEGER) return Number.MAX_SAFE_INTEGER;
1371
+ return n;
1372
+ }
1373
+
1374
+ /**
1375
+ * Parse a date string and floor to whole seconds for HTTP-date comparison.
1376
+ *
1377
+ * HTTP-dates (RFC 9110 Section 5.6.7) have 1-second resolution, but storage
1378
+ * backends often provide ISO-8601 timestamps with millisecond precision
1379
+ * (e.g. "2025-06-29T12:00:00.500Z"). The emitted Last-Modified header is
1380
+ * floored to whole seconds via `toHttpDate()`, so the client echoes back a
1381
+ * date without sub-second granularity. If we compare the raw millisecond
1382
+ * timestamp against the floored client date, the skew defeats 304, triggers
1383
+ * spurious 412s, and breaks If-Range validation.
1384
+ *
1385
+ * Floor both sides to seconds so comparisons match the HTTP wire format.
1386
+ */
1387
+ function parseHttpSeconds(s: string): number {
1388
+ const t = Date.parse(s);
1389
+ return Number.isNaN(t) ? NaN : Math.floor(t / 1000) * 1000;
1390
+ }
1391
+
1392
+ // RFC 9110 Section 5.6.7 HTTP-date grammar: IMF-fixdate, obsolete RFC 850,
1393
+ // and ANSI C asctime. Client-supplied conditional dates MUST be ignored when
1394
+ // they match none of these (Sections 13.1.3/13.1.4) -- bare Date.parse would
1395
+ // also honor ISO 8601 and other formats the spec requires us to reject.
1396
+ const IMF_FIXDATE_RE =
1397
+ /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/;
1398
+ const RFC850_DATE_RE =
1399
+ /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{2} \d{2}:\d{2}:\d{2} GMT$/;
1400
+ const ASCTIME_DATE_RE =
1401
+ /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/;
1402
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
1403
+
1404
+ /**
1405
+ * Parse a CLIENT-supplied conditional date, accepting only the three
1406
+ * HTTP-date formats. Returns NaN for anything else, which callers treat as
1407
+ * "header not present" per RFC 9110. Backend metadata keeps the lenient
1408
+ * `parseHttpSeconds` path (adapters may hand us ISO 8601; that is our own
1409
+ * input, not wire input).
1410
+ */
1411
+ function parseRequestHttpSeconds(s: string): number {
1412
+ if (IMF_FIXDATE_RE.test(s) || RFC850_DATE_RE.test(s)) {
1413
+ return parseHttpSeconds(s);
1414
+ }
1415
+ // asctime carries no zone designator; RFC 9110 says it is UTC. Date.parse
1416
+ // would interpret it as LOCAL time, skewing comparisons by the server's
1417
+ // offset, so build the timestamp explicitly.
1418
+ const m = ASCTIME_DATE_RE.exec(s);
1419
+ if (m) {
1420
+ const [, mon, day, hh, mm, ss, year] = m;
1421
+ return Date.UTC(Number(year), MONTHS.indexOf(mon), Number(day), Number(hh), Number(mm), Number(ss));
1422
+ }
1423
+ return NaN;
1424
+ }
1425
+
1426
+ /**
1427
+ * Normalize a date string to IMF-fixdate (RFC 9110 Section 5.6.7).
1428
+ *
1429
+ * `Date.prototype.toUTCString()` produces exactly the IMF-fixdate format
1430
+ * (e.g. "Sun, 29 Jun 2025 12:00:00 GMT") and floors to whole seconds,
1431
+ * fixing both format and sub-second granularity issues.
1432
+ *
1433
+ * Returns `undefined` for unparseable input so the caller can fall back.
1434
+ *
1435
+ * Single-entry memo: hot serving paths normalize the same Last-Modified
1436
+ * string on every request for the same object (stores that cache metadata
1437
+ * even hand back the same string reference, making the hit a pointer
1438
+ * compare). Date.parse + toUTCString together cost ~190ns per call and the
1439
+ * evaluation chain needs the normalized form on every conditional request.
1440
+ */
1441
+ let httpDateMemoIn: string | undefined;
1442
+ let httpDateMemoOut: string | undefined;
1443
+
1444
+ function toHttpDate(s: string): string | undefined {
1445
+ if (s === httpDateMemoIn) return httpDateMemoOut;
1446
+ const t = Date.parse(s);
1447
+ const out = Number.isNaN(t) ? undefined : new Date(t).toUTCString();
1448
+ httpDateMemoIn = s;
1449
+ httpDateMemoOut = out;
1450
+ return out;
1451
+ }
1452
+
1453
+ /**
1454
+ * RFC 9530 Section 4: Check if the client wants a `sha-256` digest.
1455
+ *
1456
+ * `Want-Repr-Digest` uses Structured Fields Dictionary syntax:
1457
+ * Want-Repr-Digest: sha-256=5, sha-512=3
1458
+ *
1459
+ * Each key is a hash algorithm, each value is a preference weight (0-10).
1460
+ * Weight 0 means "explicitly unwanted." If the header is absent or contains
1461
+ * `sha-256` with weight > 0, we emit the digest. If it only lists algorithms
1462
+ * we don't support (e.g. `sha-512=5`), we skip emission entirely.
1463
+ *
1464
+ * We also check `Want-Content-Digest` since our Content-Digest uses the
1465
+ * same algorithm.
1466
+ *
1467
+ * Exported so framework adapters apply the same negotiation the orchestrator
1468
+ * uses internally -- digest emission behaves identically at every layer.
1469
+ */
1470
+ export function clientWantsDigest(reqHeaders: { get(name: string): string | null }): boolean {
1471
+ const want = reqHeaders.get("want-repr-digest") ?? reqHeaders.get("want-content-digest");
1472
+ // No preference header: server MAY send unsolicited digests (RFC 9530 Section 4)
1473
+ if (!want) return true;
1474
+
1475
+ // Parse Structured Fields Dictionary: "sha-256=5, sha-512=3"
1476
+ // Look for sha-256 with non-zero weight
1477
+ const lower = want.toLowerCase();
1478
+ for (const entry of lower.split(",")) {
1479
+ const trimmed = entry.trim();
1480
+ if (!trimmed.startsWith("sha-256")) continue;
1481
+ // Guard against matching hypothetical future algorithms like "sha-256-v2".
1482
+ // After the "sha-256" prefix, the next character must be '=' (weight),
1483
+ // end-of-string (bare preference), or whitespace.
1484
+ const nextChar = trimmed[7]; // "sha-256".length === 7
1485
+ if (nextChar !== undefined && nextChar !== "=" && nextChar !== " ") continue;
1486
+ // Extract weight: "sha-256=5" or just "sha-256"
1487
+ const eqIdx = trimmed.indexOf("=");
1488
+ if (eqIdx === -1) return true; // bare "sha-256" means wanted
1489
+ // Strip any Structured Fields parameters (";q=...") before the weight.
1490
+ const weightStr = trimmed.slice(eqIdx + 1).split(";")[0]!.trim();
1491
+ const weight = Number(weightStr);
1492
+ // Weight 0 means explicitly unwanted
1493
+ return !Number.isNaN(weight) && weight > 0;
1494
+ }
1495
+
1496
+ // Client sent Want-* but didn't list sha-256: they don't want our algorithm
1497
+ return false;
1498
+ }