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