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.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +601 -0
- package/SECURITY.md +92 -0
- package/dist/azure.d.ts +79 -0
- package/dist/azure.d.ts.map +1 -0
- package/dist/azure.js +251 -0
- package/dist/azure.js.map +1 -0
- package/dist/content-disposition.d.ts +74 -0
- package/dist/content-disposition.d.ts.map +1 -0
- package/dist/content-disposition.js +253 -0
- package/dist/content-disposition.js.map +1 -0
- package/dist/fs.d.ts +86 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +375 -0
- package/dist/fs.js.map +1 -0
- package/dist/gcs.d.ts +72 -0
- package/dist/gcs.d.ts.map +1 -0
- package/dist/gcs.js +202 -0
- package/dist/gcs.js.map +1 -0
- package/dist/hono.d.ts +92 -0
- package/dist/hono.d.ts.map +1 -0
- package/dist/hono.js +61 -0
- package/dist/hono.js.map +1 -0
- package/dist/http.d.ts +70 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +281 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel.d.ts +541 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +1218 -0
- package/dist/kernel.js.map +1 -0
- package/dist/memory.d.ts +55 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +107 -0
- package/dist/memory.js.map +1 -0
- package/dist/mime.d.ts +49 -0
- package/dist/mime.d.ts.map +1 -0
- package/dist/mime.js +150 -0
- package/dist/mime.js.map +1 -0
- package/dist/node.d.ts +84 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +215 -0
- package/dist/node.js.map +1 -0
- package/dist/object-store.d.ts +472 -0
- package/dist/object-store.d.ts.map +1 -0
- package/dist/object-store.js +335 -0
- package/dist/object-store.js.map +1 -0
- package/dist/r2.d.ts +94 -0
- package/dist/r2.d.ts.map +1 -0
- package/dist/r2.js +150 -0
- package/dist/r2.js.map +1 -0
- package/dist/s3.d.ts +49 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +263 -0
- package/dist/s3.js.map +1 -0
- package/dist/web.d.ts +336 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +1094 -0
- package/dist/web.js.map +1 -0
- package/docs/DESIGN.md +426 -0
- package/package.json +182 -0
- package/src/azure.ts +329 -0
- package/src/content-disposition.ts +300 -0
- package/src/fs.ts +469 -0
- package/src/gcs.ts +290 -0
- package/src/hono.ts +123 -0
- package/src/http.ts +351 -0
- package/src/index.ts +85 -0
- package/src/kernel.ts +1498 -0
- package/src/memory.ts +148 -0
- package/src/mime.ts +160 -0
- package/src/node.ts +261 -0
- package/src/object-store.ts +665 -0
- package/src/r2.ts +232 -0
- package/src/s3.ts +324 -0
- 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
|