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/web.js ADDED
@@ -0,0 +1,1094 @@
1
+ /**
2
+ * Web / Fetch API adapter for partial-content.
3
+ *
4
+ * Composes a partial-content {@link ObjectStore} with the kernel's conditional
5
+ * request evaluation to produce a standards-compliant file-serving handler.
6
+ *
7
+ * Framework-agnostic: works with Next.js App Router, Hono, SvelteKit, Remix,
8
+ * Cloudflare Workers, Bun.serve, Deno.serve, or any runtime that uses the
9
+ * standard Request/Response API.
10
+ *
11
+ * Handles: 200 (full), 206 (partial), 304 (not modified), 412 (precondition
12
+ * failed), 416 (range not satisfiable), and HEAD (headers only).
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * import { serveObject } from "partial-content/web";
17
+ * import { s3Store } from "partial-content/s3";
18
+ *
19
+ * const store = s3Store({ client, bucket: "documents" });
20
+ *
21
+ * export const GET = serveObject(store, {
22
+ * key: (req) => req.url.split("/").pop()!,
23
+ * disposition: "inline",
24
+ * });
25
+ * export const HEAD = GET;
26
+ * ```
27
+ *
28
+ * @packageDocumentation
29
+ */
30
+ import { evaluateConditionalRequest, buildRangeResponseHeaders, buildContentDisposition, generateETag, clientWantsDigest, sanitizeHeaderValue, isRangeFresh, parseRanges, ObjectChangedError, build416Headers, buildMultipartHeaders, buildMultipartPartHeader, multipartEpilogue, generateMultipartBoundary, MAX_RANGES_DEFAULT, OPEN_ENDED, parseRetryAfterSeconds, } from "./index.js";
31
+ // ─── Constants ───────────────────────────────────────────────────────────────
32
+ /**
33
+ * One reusable UTF-8 encoder for the module (multipart part headers, plain-text
34
+ * error bodies), mirroring the kernel's hoisted encoder. `TextEncoder` is
35
+ * stateless, so a single instance is safe to share and avoids a per-response
36
+ * allocation.
37
+ */
38
+ const UTF8_ENCODER = new TextEncoder();
39
+ /** Reason phrases for HTTP statuses used by this adapter. */
40
+ const STATUS_TEXT = {
41
+ 200: "OK",
42
+ 206: "Partial Content",
43
+ 304: "Not Modified",
44
+ 412: "Precondition Failed",
45
+ 416: "Range Not Satisfiable",
46
+ 499: "Client Closed Request",
47
+ 502: "Bad Gateway",
48
+ };
49
+ export function serveObject(store, opts = {}) {
50
+ const raw = serveObjectRaw(store, opts);
51
+ return async function handleServe(req, ctx) {
52
+ const parts = await raw(req, ctx);
53
+ // BodyInit accepts both body forms; byte bodies take the runtime's
54
+ // static-body fast path (no stream machinery).
55
+ return new Response(parts.body, {
56
+ status: parts.status,
57
+ statusText: parts.statusText,
58
+ headers: parts.headers,
59
+ });
60
+ };
61
+ }
62
+ /**
63
+ * The engine behind serveObject, exposed for server adapters: identical
64
+ * protocol behavior, but the result is RawResponseParts instead of a
65
+ * Response. Use this when your server writes status/headers/body natively
66
+ * (the bundled node adapter does) -- constructing a Response + Headers pair
67
+ * per request just to immediately deconstruct them is measurable overhead
68
+ * on hot paths.
69
+ */
70
+ export function serveObjectRaw(store, opts = {}) {
71
+ const { disposition: dispositionOpt = "attachment", cacheControl: rawCacheControl = "private, no-cache", immutable: immutableOpt = false, securityHeaders, crossOriginResourcePolicy, timingAllowOrigin, timing: timingEnabled = false, onTiming, fallbackFilename = "download", onError, onServe: rawOnServe, onTransfer: rawOnTransfer, maxRanges = MAX_RANGES_DEFAULT, enforceCharset = true, } = opts;
72
+ // A consumer's audit hook must never break the never-throw contract: the
73
+ // bodyless emissions (302/304/412/416/HEAD) fire outside any guard, so a
74
+ // throwing hook would escape the handler. Route hook failures to onError;
75
+ // the response itself is unaffected.
76
+ const onServe = rawOnServe
77
+ ? (event) => {
78
+ try {
79
+ rawOnServe(event);
80
+ }
81
+ catch (err) {
82
+ onError?.(err, { key: event.key, operation: "audit" });
83
+ }
84
+ }
85
+ : undefined;
86
+ // Same guard for the transfer hook, which fires from inside the body's
87
+ // stream machinery (during the runtime's consumption, after the handler has
88
+ // returned). A throw there would error the response stream mid-flight;
89
+ // route it to onError instead so the transfer completes cleanly.
90
+ const onTransfer = rawOnTransfer
91
+ ? (event) => {
92
+ try {
93
+ rawOnTransfer(event);
94
+ }
95
+ catch (err) {
96
+ onError?.(err, { key: event.key, operation: "audit" });
97
+ }
98
+ }
99
+ : undefined;
100
+ // Resolve Cache-Control once (append immutable if configured)
101
+ const cacheControl = immutableOpt && !rawCacheControl.includes("immutable")
102
+ ? `${rawCacheControl}, immutable`
103
+ : rawCacheControl;
104
+ return async function handleServeRaw(req, ctx) {
105
+ // Method validation: only GET and HEAD are valid for file serving.
106
+ // Other methods get 405 with Allow header per RFC 9110 Section 15.5.6.
107
+ const method = req.method;
108
+ if (method !== "GET" && method !== "HEAD") {
109
+ return {
110
+ status: 405,
111
+ statusText: "Method Not Allowed",
112
+ headers: {
113
+ Allow: "GET, HEAD",
114
+ "Content-Length": "0",
115
+ "Cache-Control": "no-store",
116
+ "Content-Security-Policy": "default-src 'none'",
117
+ "X-Content-Type-Options": "nosniff",
118
+ },
119
+ body: null,
120
+ };
121
+ }
122
+ const { key } = ctx;
123
+ const isHead = method === "HEAD";
124
+ const mime = ctx.mime ?? "application/octet-stream";
125
+ // Resolve Content-Disposition
126
+ const dispositionType = typeof dispositionOpt === "function"
127
+ ? dispositionOpt(mime)
128
+ : dispositionOpt;
129
+ const disposition = ctx.filename
130
+ ? buildContentDisposition(ctx.filename, { type: dispositionType, fallback: fallbackFilename })
131
+ : dispositionType;
132
+ // Extra security headers for body responses
133
+ const extraHeaders = securityHeaders ? securityHeaders(mime) : {};
134
+ // Response-building context shared by all response paths
135
+ const rctx = {
136
+ mime, disposition, extraHeaders,
137
+ cacheControl: ctx.cacheControl ?? cacheControl,
138
+ crossOriginResourcePolicy, timingAllowOrigin, enforceCharset,
139
+ digestWanted: clientWantsDigest(req.headers),
140
+ };
141
+ // ── Signed-URL fallback for backends that cannot stream ranges ───────
142
+ if (store.supportsRange === false) {
143
+ if (store.createSignedUrl) {
144
+ const result = await store.createSignedUrl(key, {
145
+ expiresInSeconds: 60,
146
+ downloadFilename: ctx.filename,
147
+ });
148
+ if ("url" in result) {
149
+ // A signed-URL redirect grants file access: audit it like a serve.
150
+ onServe?.({ key, method: isHead ? "HEAD" : "GET", status: 302, mime, bytesServed: 0 });
151
+ return {
152
+ status: 302,
153
+ statusText: "Found",
154
+ headers: {
155
+ // The signed URL is backend-derived: sanitize it like every
156
+ // other metadata-sourced header so a malformed provider
157
+ // response cannot inject a header or crash the writer.
158
+ Location: sanitizeHeaderValue(result.url),
159
+ "Cache-Control": "no-store, no-cache",
160
+ "Accept-Ranges": "none",
161
+ "Content-Length": "0",
162
+ },
163
+ body: null,
164
+ };
165
+ }
166
+ }
167
+ return plainTextError(502, "Bad Gateway", "Storage backend does not support streaming");
168
+ }
169
+ // ── Detect request characteristics ──────────────────────────────────
170
+ const headers = req.headers;
171
+ const hasConditional = Boolean(headers.get("if-none-match") || headers.get("if-modified-since")
172
+ || headers.get("if-match") || headers.get("if-unmodified-since"));
173
+ const rangeHeader = headers.get("range");
174
+ const hasIfRange = Boolean(headers.get("if-range"));
175
+ // Multi-range (comma-separated) is served as multipart/byteranges. It
176
+ // always needs the HEAD-resolved total to clamp bounds and cannot use the
177
+ // single-range fast path.
178
+ const isMultiRange = !isHead && Boolean(rangeHeader && rangeHeader.includes(","));
179
+ // Range requests need HEAD to resolve: parseRangeHeader requires
180
+ // totalSize to clamp bounds and detect unsatisfiable ranges.
181
+ const needsHead = hasConditional || hasIfRange || isHead || Boolean(rangeHeader);
182
+ // ── Path B: plain range on an authoritative-range store ──────────────
183
+ // A range GET with no conditionals and no If-Range needs nothing from a
184
+ // HEAD: stores whose 206 bounds/total come from the backend's actual
185
+ // Content-Range can serve the seek in ONE round-trip (validators,
186
+ // bounds, and digest all come from the GET itself -- inherently
187
+ // TOCTOU-atomic). This halves latency on the hottest media path
188
+ // (video seeking, PDF.js chunked loading). Suffix ranges (`bytes=-N`)
189
+ // and anything the strict parser rejects fall through to Path A, whose
190
+ // HEAD-resolved evaluation handles them.
191
+ if (rangeHeader && !hasConditional && !hasIfRange && !isHead && store.authoritativeRange) {
192
+ const fastRange = parseFastRange(rangeHeader);
193
+ if (fastRange) {
194
+ let parts = null;
195
+ // Capture (don't report yet) the speculative failure. A 502 falls
196
+ // through to Path A, which re-runs and reports authoritatively --
197
+ // reporting here too would double-count. But a terminal 404/503 is
198
+ // served straight from Path B and never re-runs, so on the hottest
199
+ // path (every authoritative-range seek) those failures would be
200
+ // invisible to onError -- exactly the telemetry a monitoring
201
+ // consumer wires up. Capture here, report once below if terminal.
202
+ let speculativeErr;
203
+ try {
204
+ parts = await streamFromStore({
205
+ store, key, range: fastRange, ctx: rctx, signal: req.signal,
206
+ onError: onError ? (err) => { speculativeErr = err; } : undefined,
207
+ timingCtx: timingEnabled ? { storeMs: 0, evaluateMs: 0, onTiming } : undefined,
208
+ auditCtx: onServe ? { onServe, mime } : undefined,
209
+ // A speculative success IS the served response, so it must meter.
210
+ // A failure returns 502 (no store body) and falls to Path A, which
211
+ // meters the real transfer -- no double-fire.
212
+ onTransfer,
213
+ });
214
+ }
215
+ catch {
216
+ // ObjectChangedError without a pin, or any other escape: fall
217
+ // through to Path A, which reports and responds correctly.
218
+ }
219
+ // A 502 here usually means the backend rejected the range natively
220
+ // (e.g. start beyond EOF -> S3 InvalidRange). Path A's
221
+ // HEAD-resolved evaluation turns that into the correct 416 with
222
+ // real bounds (or surfaces the genuine store failure with full
223
+ // error reporting). The retry only ever costs an extra attempt on
224
+ // error paths.
225
+ if (parts && parts.status !== 502) {
226
+ // Terminal outcome served from Path B (200/206 success, or a 404/503
227
+ // that Path A would never revisit): surface a captured 404/503 error
228
+ // exactly once so throttle/not-found storms stay visible to onError.
229
+ if ((parts.status === 404 || parts.status === 503) && speculativeErr !== undefined) {
230
+ onError?.(speculativeErr, { key, operation: "get" });
231
+ }
232
+ return parts;
233
+ }
234
+ }
235
+ }
236
+ // ── Path A: HEAD required ───────────────────────────────────────────
237
+ // The GET is pinned to the HEAD's raw ETag (GetObjectOptions.ifMatch),
238
+ // making the HEAD->GET pair atomic on backends with conditional reads.
239
+ // If the object changes in that window the store throws
240
+ // ObjectChangedError; re-validate ONCE against the new state so the
241
+ // client gets a coherent answer (e.g. a stale If-Range now correctly
242
+ // yields a full 200 of the new bytes) instead of an error.
243
+ for (let attempt = 0; needsHead; attempt++) {
244
+ let meta;
245
+ const t0 = timingEnabled ? performance.now() : 0;
246
+ try {
247
+ meta = await store.headObject(key, { signal: req.signal });
248
+ }
249
+ catch (err) {
250
+ if (isAbortError(err))
251
+ return clientClosed();
252
+ onError?.(err, { key, operation: "head" });
253
+ return storeErrorResponse(err);
254
+ }
255
+ const storeMs = timingEnabled ? performance.now() - t0 : 0;
256
+ const etag = deriveETag(meta);
257
+ // RFC 7232/7233 full evaluation chain. Conditionals apply to HEAD
258
+ // exactly as to GET (RFC 9110 Section 13.1) -- a conditional HEAD must
259
+ // still yield 304/412. Range, however, is only defined for GET
260
+ // (RFC 9110 Section 14.2), so it is masked out for HEAD requests.
261
+ const evalHeaders = isHead ? withoutRangeHeaders(headers) : headers;
262
+ const t1 = timingEnabled ? performance.now() : 0;
263
+ let evaluation;
264
+ try {
265
+ evaluation = evaluateConditionalRequest(evalHeaders, {
266
+ totalSize: meta.contentLength,
267
+ contentType: mime,
268
+ etag,
269
+ lastModified: meta.lastModified,
270
+ cacheControl: rctx.cacheControl,
271
+ digest: meta.digest,
272
+ });
273
+ }
274
+ catch (err) {
275
+ // The kernel rejects corrupt store metadata (NaN/negative sizes)
276
+ // with a RangeError. That is an adapter bug, but a handler must
277
+ // always produce a Response -- an escaping throw becomes an
278
+ // unhandled rejection (and a process crash under Express 4).
279
+ onError?.(err, { key, operation: "head" });
280
+ return storeErrorResponse(err);
281
+ }
282
+ const evaluateMs = timingEnabled ? performance.now() - t1 : 0;
283
+ // Early exits: 412, 304, 416
284
+ if (evaluation.status === 304) {
285
+ onServe?.({ key, method: isHead ? "HEAD" : "GET", status: 304, mime, bytesServed: 0, etag });
286
+ return {
287
+ status: 304,
288
+ statusText: STATUS_TEXT[304],
289
+ headers: evaluation.headers,
290
+ body: null,
291
+ };
292
+ }
293
+ if (evaluation.status === 412) {
294
+ // Denials are audit events too: a 412 is an optimistic-concurrency
295
+ // conflict (failed If-Match), exactly what SOC 2 change-control
296
+ // trails want captured alongside grants.
297
+ onServe?.({ key, method: isHead ? "HEAD" : "GET", status: 412, mime, bytesServed: 0, etag });
298
+ return {
299
+ status: 412,
300
+ statusText: STATUS_TEXT[412],
301
+ headers: {
302
+ ...evaluation.headers,
303
+ ...DENY_HEADERS,
304
+ },
305
+ body: null,
306
+ };
307
+ }
308
+ if (evaluation.status === 416) {
309
+ onServe?.({ key, method: "GET", status: 416, mime, bytesServed: 0, etag });
310
+ return {
311
+ status: evaluation.status,
312
+ statusText: "Range Not Satisfiable",
313
+ headers: {
314
+ ...evaluation.headers,
315
+ ...DENY_HEADERS,
316
+ },
317
+ body: null,
318
+ };
319
+ }
320
+ // HEAD method: preconditions passed -- return headers only, no body
321
+ // (PDF.js size probing, cache priming).
322
+ if (isHead) {
323
+ onServe?.({ key, method: "HEAD", status: 200, mime, bytesServed: 0, etag });
324
+ return buildHeadResponse(meta, etag, rctx);
325
+ }
326
+ // Stream bytes from store, pinned to the representation just validated.
327
+ // Only STRONG validators pin: RFC 9110 Section 13.1.1 mandates strong
328
+ // comparison for If-Match, so a weak `W/` ETag would fail the
329
+ // precondition on every attempt (guaranteed 412 -> retry -> 502).
330
+ // Weak validators cannot assert byte equality anyway; the response-side
331
+ // guard still protects those reads.
332
+ const pinEtag = meta.etag && !meta.etag.startsWith("W/") ? meta.etag : undefined;
333
+ // ── Multi-range: serve multipart/byteranges ──────────────────────────
334
+ // evaluateConditionalRequest already settled 412/304 above (conditionals
335
+ // are range-independent) and, because parseRangeHeader rejects comma
336
+ // ranges, left this as a would-be 200. Honor the ranges here. A stale
337
+ // If-Range (validator moved) means ignore the Range -> fall through to
338
+ // the full 200 below; parseRanges returning null (amplification, or the
339
+ // ranges cover the whole file) does the same.
340
+ if (isMultiRange && isRangeFresh(headers, etag, meta.lastModified)) {
341
+ const set = parseRanges(rangeHeader, meta.contentLength, maxRanges);
342
+ if (set === "unsatisfiable") {
343
+ onServe?.({ key, method: "GET", status: 416, mime, bytesServed: 0, etag });
344
+ return {
345
+ status: 416,
346
+ statusText: "Range Not Satisfiable",
347
+ headers: {
348
+ ...build416Headers(meta.contentLength).headers,
349
+ ...DENY_HEADERS,
350
+ },
351
+ body: null,
352
+ };
353
+ }
354
+ if (set !== null) {
355
+ try {
356
+ if (set.ranges.length === 1) {
357
+ // Overlapping ranges coalesced to one: a normal single 206.
358
+ return await streamFromStore({
359
+ store, key, range: set.ranges[0],
360
+ ifMatch: pinEtag, pin: meta.pin,
361
+ headEtag: etag, headLastModified: meta.lastModified,
362
+ reprDigest: meta.digest, ctx: rctx, signal: req.signal, onError,
363
+ timingCtx: timingEnabled ? { storeMs, evaluateMs, onTiming } : undefined,
364
+ auditCtx: onServe ? { onServe, mime } : undefined,
365
+ onTransfer,
366
+ });
367
+ }
368
+ return await serveMultipart({
369
+ store, key, ranges: set.ranges, totalSize: meta.contentLength,
370
+ mime, etag, lastModified: meta.lastModified, digest: meta.digest,
371
+ ifMatch: pinEtag, pin: meta.pin, ctx: rctx, signal: req.signal,
372
+ onError, auditCtx: onServe ? { onServe, mime } : undefined, onTransfer,
373
+ });
374
+ }
375
+ catch (err) {
376
+ // A pinned first-part read lost its race: re-validate once, exactly
377
+ // like single-range serving.
378
+ if (isObjectChangedError(err) && attempt === 0)
379
+ continue;
380
+ onError?.(err, { key, operation: "get" });
381
+ return storeErrorResponse(err);
382
+ }
383
+ }
384
+ // set === null: serve the full 200 (fall through).
385
+ }
386
+ try {
387
+ return await streamFromStore({
388
+ store, key, range: evaluation.range ?? undefined,
389
+ ifMatch: pinEtag, pin: meta.pin,
390
+ headEtag: etag, headLastModified: meta.lastModified,
391
+ reprDigest: meta.digest, ctx: rctx, signal: req.signal, onError,
392
+ timingCtx: timingEnabled ? { storeMs, evaluateMs, onTiming } : undefined,
393
+ auditCtx: onServe ? { onServe, mime } : undefined,
394
+ onTransfer,
395
+ });
396
+ }
397
+ catch (err) {
398
+ // Re-validate ONCE, and only for a pinned-read race. Any other error
399
+ // that escapes streamFromStore -- e.g. a corrupt-metadata RangeError
400
+ // rethrown from buildHeaders -- is deterministic, so a retry just wastes
401
+ // a HEAD+GET and drops the first failure from onError. Report it now.
402
+ // Mirrors the multipart catch above.
403
+ if (isObjectChangedError(err) && attempt === 0)
404
+ continue;
405
+ onError?.(err, { key, operation: "get" });
406
+ return storeErrorResponse(err);
407
+ }
408
+ }
409
+ // ── Path C: No Range, no conditional headers ─────────────────────────
410
+ // Same never-throw contract as Path A: corrupt GET metadata (header
411
+ // builder RangeError), a throwing onServe hook, or a store throwing
412
+ // ObjectChangedError without a pin must become a 502, not a rejected
413
+ // handler (which crashes Express 4 processes).
414
+ try {
415
+ return await streamFromStore({
416
+ store, key, ctx: rctx, signal: req.signal, onError,
417
+ timingCtx: timingEnabled ? { storeMs: 0, evaluateMs: 0, onTiming } : undefined,
418
+ auditCtx: onServe ? { onServe, mime } : undefined,
419
+ onTransfer,
420
+ });
421
+ }
422
+ catch (err) {
423
+ onError?.(err, { key, operation: "get" });
424
+ return storeErrorResponse(err);
425
+ }
426
+ };
427
+ }
428
+ // ─── Internal Helpers ───────────────────────────────────────────────────────
429
+ /**
430
+ * Wrap a headers view so Range and If-Range read as absent.
431
+ *
432
+ * Used for HEAD requests: RFC 9110 Section 14.2 defines range handling only
433
+ * for GET, so the evaluation chain must not see the Range header (a 206 or
434
+ * 416 for HEAD would be wrong), while conditionals still apply.
435
+ */
436
+ function withoutRangeHeaders(headers) {
437
+ return {
438
+ get(name) {
439
+ const lower = name.toLowerCase();
440
+ if (lower === "range" || lower === "if-range")
441
+ return null;
442
+ return headers.get(lower);
443
+ },
444
+ };
445
+ }
446
+ /**
447
+ * Parse a Range header for the single-round-trip fast path (no totalSize
448
+ * available yet). Accepts only `bytes=a-b` and `bytes=a-` (open end becomes
449
+ * MAX_SAFE_INTEGER; RFC 9110 Section 14.1.2 lets the server clamp a
450
+ * last-byte-pos past EOF, and authoritative-range backends do). Everything
451
+ * else -- suffix ranges, multi-range, malformed specs -- returns null so the
452
+ * validating HEAD path evaluates it with the real object size.
453
+ */
454
+ function parseFastRange(header) {
455
+ const m = /^bytes=(\d+)-(\d*)$/.exec(header.trim());
456
+ if (!m)
457
+ return null;
458
+ const start = Number(m[1]);
459
+ if (!Number.isSafeInteger(start))
460
+ return null;
461
+ const end = m[2] ? Number(m[2]) : OPEN_ENDED;
462
+ if (!Number.isSafeInteger(end) || end < start)
463
+ return null;
464
+ return { start, end };
465
+ }
466
+ /** Derive an ETag from storage metadata using the kernel's formatter. */
467
+ function deriveETag(meta) {
468
+ return generateETag({
469
+ hash: meta.etag,
470
+ size: meta.contentLength,
471
+ mtime: meta.lastModified,
472
+ });
473
+ }
474
+ /**
475
+ * Locked-down headers for bodyless denial responses (304 excepted, which
476
+ * carries only validators). A `default-src 'none'` CSP plus `nosniff` so a
477
+ * 412/416 body can never be sniffed or execute anything, and `no-store` so a
478
+ * transient denial is never cached (a 416 in particular advertises
479
+ * `Accept-Ranges: bytes` per RFC 7233 and must not linger in a shared cache).
480
+ * Spread onto the protocol headers the kernel produced for the status.
481
+ */
482
+ const DENY_HEADERS = {
483
+ "Content-Security-Policy": "default-src 'none'",
484
+ "X-Content-Type-Options": "nosniff",
485
+ "Cache-Control": "no-store",
486
+ };
487
+ /**
488
+ * Append `; charset=utf-8` to a textual Content-Type when charset enforcement
489
+ * is on and none is present. Shared by the single-range/200 path and each
490
+ * multipart part so their Content-Type (and thus the precomputed
491
+ * Content-Length) is derived identically.
492
+ */
493
+ function withCharset(mime, ctx) {
494
+ return ctx.enforceCharset && isTextualMime(mime) && !mime.includes("charset")
495
+ ? `${mime}; charset=utf-8`
496
+ : mime;
497
+ }
498
+ /**
499
+ * Layer the adapter's success-response header tail (Content-Disposition,
500
+ * nosniff, caller `extraHeaders`, CORP, TAO) onto a protocol header base.
501
+ * Shared by the single-range/200 and multipart paths so a future security
502
+ * header can never be added to one and silently forgotten on the other.
503
+ */
504
+ function applyAdapterHeaders(base, ctx) {
505
+ const headers = {
506
+ ...base,
507
+ "Content-Disposition": ctx.disposition,
508
+ "X-Content-Type-Options": "nosniff",
509
+ ...ctx.extraHeaders,
510
+ };
511
+ if (ctx.crossOriginResourcePolicy)
512
+ headers["Cross-Origin-Resource-Policy"] = ctx.crossOriginResourcePolicy;
513
+ if (ctx.timingAllowOrigin)
514
+ headers["Timing-Allow-Origin"] = ctx.timingAllowOrigin;
515
+ return headers;
516
+ }
517
+ /**
518
+ * Build response headers by composing the kernel's protocol headers with
519
+ * adapter-specific extras.
520
+ */
521
+ function buildHeaders(ctx, meta) {
522
+ const { headers: protocol } = buildRangeResponseHeaders({
523
+ totalSize: meta.totalSize,
524
+ range: meta.range ?? null,
525
+ contentType: withCharset(ctx.mime, ctx),
526
+ etag: meta.etag,
527
+ lastModified: meta.lastModified,
528
+ // RFC 9530 Section 4: respect Want-Repr-Digest negotiation. Weight 0 or
529
+ // an algorithm list without sha-256 means "do not send"; honoring that
530
+ // here keeps adapter responses consistent with the kernel orchestrator.
531
+ digest: ctx.digestWanted ? meta.digest : undefined,
532
+ cacheControl: ctx.cacheControl,
533
+ });
534
+ const headers = applyAdapterHeaders(protocol, ctx);
535
+ if (meta.serverTiming)
536
+ headers["Server-Timing"] = meta.serverTiming;
537
+ return headers;
538
+ }
539
+ /** Build a HEAD-only response (no body). */
540
+ function buildHeadResponse(meta, etag, ctx) {
541
+ return {
542
+ status: 200,
543
+ statusText: "OK",
544
+ headers: buildHeaders(ctx, {
545
+ totalSize: meta.contentLength,
546
+ etag,
547
+ lastModified: meta.lastModified,
548
+ digest: meta.digest,
549
+ }),
550
+ body: null,
551
+ };
552
+ }
553
+ /**
554
+ * Stream bytes from the store, building a proper 200/206 response.
555
+ *
556
+ * Maps store failures to responses internally, with one exception:
557
+ * ObjectChangedError (a pinned read losing its race) is rethrown so the
558
+ * caller can re-validate against the object's new state.
559
+ */
560
+ async function streamFromStore(opts) {
561
+ const { store, key, range, ifMatch, pin, headEtag, headLastModified, reprDigest, ctx, signal, onError, timingCtx, auditCtx, onTransfer, } = opts;
562
+ const t0 = timingCtx ? performance.now() : 0;
563
+ let result;
564
+ try {
565
+ result = await store.getObject(key, { range, signal, ifMatch, pin });
566
+ }
567
+ catch (err) {
568
+ if (isObjectChangedError(err))
569
+ throw err;
570
+ if (isAbortError(err))
571
+ return clientClosed();
572
+ onError?.(err, { key, operation: "get" });
573
+ return storeErrorResponse(err);
574
+ }
575
+ const getMs = timingCtx ? performance.now() - t0 : 0;
576
+ let serverTiming;
577
+ if (timingCtx) {
578
+ const totalMs = (timingCtx.storeMs + timingCtx.evaluateMs + getMs);
579
+ serverTiming = `store;dur=${(timingCtx.storeMs + getMs).toFixed(1)},eval;dur=${timingCtx.evaluateMs.toFixed(1)}`;
580
+ timingCtx.onTiming?.({
581
+ storeMs: timingCtx.storeMs + getMs,
582
+ evaluateMs: timingCtx.evaluateMs,
583
+ totalMs,
584
+ });
585
+ }
586
+ // Derive the response ETag from the GET result itself: strong from the
587
+ // backend hash when present, weak from size + mtime otherwise. This keeps
588
+ // validators consistent between Path A (HEAD-derived) and Path C (GET-only),
589
+ // so plain 200s from hash-less stores (fs) still carry a revalidator.
590
+ const finalEtag = generateETag({
591
+ hash: result.etag,
592
+ size: result.totalSize,
593
+ mtime: result.lastModified,
594
+ }) ?? headEtag;
595
+ const finalLastModified = result.lastModified ?? headLastModified;
596
+ const finalDigest = result.digest ?? reprDigest;
597
+ // TOCTOU guard: the range the backend ACTUALLY served is the source of
598
+ // truth for 206 vs 200 AND for the emitted byte bounds. If a range was
599
+ // requested but the GET result carries none, the store served full
600
+ // content: emit 200, never a lying 206. Incoherent bounds (a custom-store
601
+ // bug) cannot be trusted -- emitting them would corrupt client caches,
602
+ // so fail loudly instead.
603
+ const actualRange = range ? result.range ?? null : null;
604
+ if (actualRange && !isServableRange(actualRange, result.totalSize)) {
605
+ const err = new Error(`Store returned invalid served range for ${key}: ` +
606
+ `${actualRange.start}-${actualRange.end}/${result.totalSize}`);
607
+ onError?.(err, { key, operation: "get" });
608
+ // The body was never handed to a response: cancel a stream form or the
609
+ // backing resource (fs file handle, pooled HTTP socket) stays open
610
+ // until GC. Byte bodies hold nothing.
611
+ cancelBody(result.body);
612
+ return storeErrorResponse(err);
613
+ }
614
+ // Byte-count coherence: the emitted Content-Length is derived from the range
615
+ // span (206) or totalSize (200), but the body streams result.contentLength
616
+ // bytes. A store that reports a contentLength disagreeing with those would
617
+ // commit a Content-Length that over- or under-runs the body -- a truncated
618
+ // response the client cannot distinguish from a complete one. The multipart
619
+ // path enforces this per part (servedSpanMatches); the hotter single-range
620
+ // and 200 paths must guarantee it too. (A 200 with undefined totalSize is an
621
+ // adapter bug the header builder already rejects, so only the defined case
622
+ // is checked here.)
623
+ const incoherentByteCount = actualRange
624
+ ? result.contentLength !== actualRange.end - actualRange.start + 1
625
+ : result.totalSize !== undefined && result.contentLength !== result.totalSize;
626
+ if (incoherentByteCount) {
627
+ const err = new Error(`Store returned incoherent byte count for ${key}: contentLength=${result.contentLength} ` +
628
+ (actualRange ? `range=${actualRange.start}-${actualRange.end}` : `totalSize=${result.totalSize}`));
629
+ onError?.(err, { key, operation: "get" });
630
+ cancelBody(result.body);
631
+ return storeErrorResponse(err);
632
+ }
633
+ const isPartial = actualRange !== null;
634
+ const status = isPartial ? 206 : 200;
635
+ const responseRange = actualRange ?? undefined;
636
+ const totalSize = result.totalSize;
637
+ // Between here and the returned parts the body has an owner only on the
638
+ // happy path: a throw from the audit hook or header builder would
639
+ // otherwise leak a stream form.
640
+ try {
641
+ const headers = buildHeaders(ctx, {
642
+ totalSize,
643
+ range: responseRange,
644
+ etag: finalEtag,
645
+ lastModified: finalLastModified,
646
+ digest: finalDigest,
647
+ serverTiming,
648
+ });
649
+ // Audit AFTER the headers commit, never before. onServe is the "grant"
650
+ // event, so it must not fire for a response that never materializes: if
651
+ // buildHeaders throws on corrupt metadata this streamFromStore call is
652
+ // discarded (Path A re-runs, or a 502 is returned), and an onServe fired
653
+ // up front would double-count the retry or log a phantom grant on a 502.
654
+ if (auditCtx) {
655
+ // streamFromStore only runs for GET: HEAD returns early via
656
+ // buildHeadResponse, and Path C is unreachable when isHead forces needsHead.
657
+ auditCtx.onServe({
658
+ key, method: "GET", status: status, mime: auditCtx.mime,
659
+ bytesServed: result.contentLength, etag: finalEtag,
660
+ ...(responseRange ? { rangeStart: responseRange.start, rangeEnd: responseRange.end } : {}),
661
+ });
662
+ }
663
+ // Metering is opt-in: only wrap the body when a transfer hook is present,
664
+ // so the common path keeps the runtime's static-body fast path. The wrap
665
+ // happens AFTER buildHeaders and the audit hook, so the catch below still
666
+ // cancels the untouched original on any earlier throw (once wrapped, the
667
+ // source reader is locked and owned by the returned stream).
668
+ const body = onTransfer
669
+ ? meterBody(result.body, (bytesTransferred, completed) => onTransfer({
670
+ key,
671
+ method: "GET",
672
+ status: status,
673
+ bytesExpected: result.contentLength,
674
+ bytesTransferred,
675
+ completed,
676
+ ...(responseRange ? { rangeStart: responseRange.start, rangeEnd: responseRange.end } : {}),
677
+ }))
678
+ : result.body;
679
+ return {
680
+ status,
681
+ statusText: STATUS_TEXT[status],
682
+ headers,
683
+ body,
684
+ };
685
+ }
686
+ catch (err) {
687
+ cancelBody(result.body);
688
+ throw err;
689
+ }
690
+ }
691
+ /**
692
+ * Serve multiple byte ranges as a `multipart/byteranges` (206) response.
693
+ *
694
+ * The Content-Length is computed exactly by the kernel from the framing and
695
+ * the range spans, so the response is never chunked. The first part is fetched
696
+ * EAGERLY so a pinned-read `ObjectChangedError` surfaces before headers commit
697
+ * (giving the caller the same one-shot re-validation single-range serving
698
+ * gets); the remaining parts stream lazily, one `getObject` per range, each
699
+ * pinned to the same representation via `ifMatch`.
700
+ *
701
+ * Relies on each store serving exactly the requested (already size-clamped)
702
+ * span per range -- which every bundled adapter does -- so the precomputed
703
+ * Content-Length matches the streamed body byte-for-byte.
704
+ */
705
+ async function serveMultipart(opts) {
706
+ const { store, key, ranges, totalSize, mime, etag, lastModified, digest, ifMatch, pin, ctx, signal, onError, auditCtx, onTransfer, } = opts;
707
+ const boundary = generateMultipartBoundary();
708
+ // Each part carries the representation's own Content-Type; apply the same
709
+ // charset enforcement single-range responses use so the value (and thus the
710
+ // precomputed Content-Length) is identical to what the framing emits.
711
+ const partContentType = withCharset(mime, ctx);
712
+ // Fetch the first part up front: a pinned read losing its race throws
713
+ // ObjectChangedError HERE, before any headers are committed, so the caller
714
+ // can re-validate once. Abort and store errors map to responses.
715
+ let firstStream;
716
+ try {
717
+ firstStream = await store.getObject(key, { range: ranges[0], signal, ifMatch, pin });
718
+ }
719
+ catch (err) {
720
+ if (isObjectChangedError(err))
721
+ throw err;
722
+ if (isAbortError(err))
723
+ return clientClosed();
724
+ onError?.(err, { key, operation: "get" });
725
+ return storeErrorResponse(err);
726
+ }
727
+ // Validate the first part's served span BEFORE headers commit. On a
728
+ // non-pinning store (one that cannot honor ifMatch/pin), a concurrent
729
+ // overwrite between parts would otherwise splice bytes across
730
+ // representations or under-run the precomputed Content-Length. A first-part
731
+ // mismatch re-validates once via the orchestrator's retry loop.
732
+ if (!servedSpanMatches(firstStream, ranges[0])) {
733
+ cancelBody(firstStream.body);
734
+ throw new ObjectChangedError(key);
735
+ }
736
+ const multipart = buildMultipartHeaders({
737
+ boundary, ranges, totalSize, contentType: partContentType,
738
+ etag, lastModified, digest, cacheControl: ctx.cacheControl,
739
+ });
740
+ // Ownership of the eagerly-fetched first part transfers to the generator the
741
+ // moment it takes the part's reader (or yields its bytes). Until then, a
742
+ // consumer cancel would strand firstStream: gen.return() only runs the
743
+ // reader's finally if control already entered the try. Track the handoff so
744
+ // the outer cancel can release it in the pre-handoff window (suspendedStart,
745
+ // or suspended at the part-header yield).
746
+ let firstPartOwned = false;
747
+ const enc = UTF8_ENCODER;
748
+ async function* multipartChunks() {
749
+ for (let i = 0; i < ranges.length; i++) {
750
+ const range = ranges[i];
751
+ yield enc.encode(buildMultipartPartHeader(boundary, range, totalSize, partContentType));
752
+ const stream = i === 0
753
+ ? firstStream
754
+ : await store.getObject(key, { range, signal, ifMatch, pin });
755
+ // Lazy parts settle AFTER headers commit, so a mismatch can only be
756
+ // surfaced as a stream error (a reset). That still beats splicing bytes
757
+ // from a changed representation or under-running the committed length.
758
+ // The span check alone would miss a SAME-SIZE overwrite (right byte count,
759
+ // different bytes); comparing each part's validator against the first
760
+ // catches that too, since any overwrite changes the fs weak ETag (mtime)
761
+ // and the S3 strong ETag (content hash). Stores that return no GET
762
+ // validator fall back to the span check.
763
+ if (i > 0 && (!servedSpanMatches(stream, range) || !sameRepresentation(firstStream, stream))) {
764
+ cancelBody(stream.body);
765
+ throw new ObjectChangedError(key);
766
+ }
767
+ const body = stream.body;
768
+ // Handoff point: cleanup is now guaranteed by the reader's finally below
769
+ // (stream body) or is unneeded (byte body holds no resource). No await or
770
+ // yield sits between here and getReader(), so a cancel cannot interleave.
771
+ if (i === 0)
772
+ firstPartOwned = true;
773
+ if (body instanceof Uint8Array) {
774
+ yield body;
775
+ }
776
+ else {
777
+ const reader = body.getReader();
778
+ try {
779
+ for (;;) {
780
+ const { done, value } = await reader.read();
781
+ if (done)
782
+ break;
783
+ yield value;
784
+ }
785
+ }
786
+ finally {
787
+ // Release the backend resource on normal completion AND on an early
788
+ // generator return (client cancelled mid-part).
789
+ reader.cancel().catch(() => { });
790
+ }
791
+ }
792
+ yield enc.encode("\r\n");
793
+ }
794
+ yield enc.encode(multipartEpilogue(boundary));
795
+ }
796
+ const gen = multipartChunks();
797
+ const rawBody = new ReadableStream({
798
+ async pull(controller) {
799
+ try {
800
+ const { done, value } = await gen.next();
801
+ if (done)
802
+ controller.close();
803
+ // Part headers (encoder output) and part bodies are ArrayBuffer-backed;
804
+ // narrow so the multipart body stays Response-assignable under DOM (F5).
805
+ else
806
+ controller.enqueue(value);
807
+ }
808
+ catch (err) {
809
+ controller.error(err);
810
+ }
811
+ },
812
+ async cancel(reason) {
813
+ await gen.return?.(reason);
814
+ // Cancelled before the generator took ownership of the eagerly-fetched
815
+ // first part (suspendedStart, or suspended at the part-header yield):
816
+ // gen.return ran no finally, so release it here. A byte-body first part
817
+ // is a no-op in cancelBody; a stream part's file handle/socket is freed.
818
+ if (!firstPartOwned)
819
+ cancelBody(firstStream.body);
820
+ },
821
+ });
822
+ const headers = applyAdapterHeaders(multipart.headers, ctx);
823
+ // Audit reports the multipart body's granted length (framing + all parts).
824
+ auditCtx?.onServe({
825
+ key, method: "GET", status: 206, mime: auditCtx.mime,
826
+ bytesServed: multipart.contentLength, etag,
827
+ });
828
+ const body = onTransfer
829
+ ? meterBody(rawBody, (bytesTransferred, completed) => onTransfer({
830
+ key, method: "GET", status: 206,
831
+ bytesExpected: multipart.contentLength, bytesTransferred, completed,
832
+ }))
833
+ : rawBody;
834
+ return { status: 206, statusText: STATUS_TEXT[206], headers, body };
835
+ }
836
+ /**
837
+ * Wrap a response body in a counting stream that reports the true bytes
838
+ * transferred exactly once when the body reaches its terminal state.
839
+ *
840
+ * `report(bytesTransferred, completed)` fires on:
841
+ * - full drain (`completed: true`) -- the consumer read every byte,
842
+ * - cancel / client disconnect (`completed: false`) -- fewer bytes reached
843
+ * the client than the Content-Length promised,
844
+ * - source error (`completed: false`) -- a mid-transfer backend failure.
845
+ *
846
+ * A byte body is wrapped into a one-shot stream so metering is uniform across
847
+ * stream and byte stores (the caller only pays this when a transfer hook is
848
+ * registered; unmetered byte bodies keep the static-body fast path). The
849
+ * `settled` latch guarantees the report fires once even if the consumer
850
+ * cancels after the stream already closed.
851
+ */
852
+ function meterBody(source, report) {
853
+ let transferred = 0;
854
+ let settled = false;
855
+ const settle = (completed) => {
856
+ if (settled)
857
+ return;
858
+ settled = true;
859
+ report(transferred, completed);
860
+ };
861
+ // Normalize a byte body to a one-shot stream and meter it through the SAME
862
+ // reader path, so `completed:true` fires only when the consumer has pulled
863
+ // the terminal (done) result -- i.e. after it read the last chunk -- never
864
+ // merely because the chunk was buffered ahead of a read. A dedicated
865
+ // byte-path that settled inside its enqueue would over-report a disconnect
866
+ // that lands after the chunk was queued but before it was consumed.
867
+ const stream = source instanceof Uint8Array
868
+ ? new ReadableStream({
869
+ start(controller) {
870
+ if (source.byteLength > 0)
871
+ controller.enqueue(source);
872
+ controller.close();
873
+ },
874
+ })
875
+ : source;
876
+ const reader = stream.getReader();
877
+ return new ReadableStream({
878
+ async pull(controller) {
879
+ let res;
880
+ try {
881
+ res = await reader.read();
882
+ }
883
+ catch (err) {
884
+ settle(false);
885
+ controller.error(err);
886
+ return;
887
+ }
888
+ if (res.done) {
889
+ settle(true);
890
+ controller.close();
891
+ }
892
+ else {
893
+ transferred += res.value.byteLength;
894
+ // Backend byte chunks are ArrayBuffer-backed; narrow so the metered
895
+ // body stays `new Response(...)`-assignable under DOM lib (F5).
896
+ controller.enqueue(res.value);
897
+ }
898
+ },
899
+ async cancel(reason) {
900
+ settle(false);
901
+ await reader.cancel(reason);
902
+ },
903
+ });
904
+ }
905
+ /**
906
+ * True when a store served EXACTLY the requested span: same inclusive bounds
907
+ * and matching byte count. A mismatch means the object changed under a
908
+ * non-pinning store (or the store's accounting is broken), so the committed
909
+ * multipart framing and Content-Length no longer describe the bytes.
910
+ */
911
+ function servedSpanMatches(stream, range) {
912
+ const served = stream.range;
913
+ return !!served
914
+ && served.start === range.start
915
+ && served.end === range.end
916
+ && stream.contentLength === range.end - range.start + 1;
917
+ }
918
+ /**
919
+ * True when two parts of a multipart response came from the same
920
+ * representation, judged by the strongest validator both expose. Any overwrite
921
+ * changes the fs weak ETag (mtime) and the S3 strong ETag (content hash), so an
922
+ * ETag disagreement means the object changed mid-stream. When a store returns
923
+ * no GET ETag (or no Last-Modified) on one side, there is nothing to compare
924
+ * and the caller's span check is the only guard -- so this returns `true` and
925
+ * does not manufacture a mismatch from missing metadata.
926
+ */
927
+ function sameRepresentation(a, b) {
928
+ if (a.etag && b.etag)
929
+ return a.etag === b.etag;
930
+ if (a.lastModified && b.lastModified)
931
+ return a.lastModified === b.lastModified;
932
+ return true;
933
+ }
934
+ /** Release a body that will never reach a response (stream forms only). */
935
+ function cancelBody(body) {
936
+ if (!(body instanceof ReadableStream))
937
+ return;
938
+ try {
939
+ // A locked stream (a reader was taken) throws SYNCHRONOUSLY here rather
940
+ // than rejecting, so .catch alone would let it escape; an already-errored
941
+ // stream rejects. Swallow both -- teardown is best-effort by definition.
942
+ body.cancel().catch(() => { });
943
+ }
944
+ catch { /* locked: the reader owns teardown */ }
945
+ }
946
+ /** Bodyless 499 parts, built per return (headers object is caller-owned). */
947
+ function clientClosed() {
948
+ return {
949
+ status: 499,
950
+ statusText: "Client Closed Request",
951
+ headers: { "Content-Length": "0" },
952
+ body: null,
953
+ };
954
+ }
955
+ // ─── Error Helpers ──────────────────────────────────────────────────────────
956
+ /**
957
+ * Validate a store-reported served range against the representation size:
958
+ * inclusive integer bounds, ordered, and inside the object. Anything else
959
+ * means the store's byte accounting is broken.
960
+ *
961
+ * When `totalSize` is `undefined` the backend served an unknown-total partial
962
+ * (`bytes a-b/*`): there is no EOF to bound-check against, so the ordered
963
+ * bounds the authoritative backend reported are trusted as-is.
964
+ */
965
+ function isServableRange(r, totalSize) {
966
+ if (!Number.isSafeInteger(r.start) || !Number.isSafeInteger(r.end))
967
+ return false;
968
+ if (r.start < 0 || r.start > r.end)
969
+ return false;
970
+ if (totalSize === undefined)
971
+ return true;
972
+ return Number.isSafeInteger(totalSize) && r.end < totalSize;
973
+ }
974
+ /**
975
+ * Check if an error is an AbortError (client disconnected).
976
+ * Works across runtimes: DOMException in browsers/Workers, AbortError in Node.
977
+ */
978
+ function isAbortError(err) {
979
+ if (err instanceof DOMException && err.name === "AbortError")
980
+ return true;
981
+ if (err instanceof Error && err.name === "AbortError")
982
+ return true;
983
+ return false;
984
+ }
985
+ /**
986
+ * Check if an error is a pinned-read ObjectChangedError.
987
+ *
988
+ * Matched by name rather than instanceof so third-party stores can throw
989
+ * their own equivalently-named error without importing the kernel class.
990
+ */
991
+ function isObjectChangedError(err) {
992
+ return err instanceof Error && err.name === "ObjectChangedError";
993
+ }
994
+ /**
995
+ * Build a plain-text error response with a computed Content-Length.
996
+ *
997
+ * The body is encoded once and Content-Length comes from the encoded byte
998
+ * count, so the header stays truthful even if a message ever gains
999
+ * non-ASCII characters (String.length counts UTF-16 units, not bytes).
1000
+ */
1001
+ function plainTextError(status, statusText, body, extraHeaders) {
1002
+ const bytes = UTF8_ENCODER.encode(body);
1003
+ return {
1004
+ status,
1005
+ statusText,
1006
+ headers: {
1007
+ "Content-Type": "text/plain; charset=utf-8",
1008
+ "Content-Length": String(bytes.byteLength),
1009
+ "Accept-Ranges": "none",
1010
+ // 404 is heuristically cacheable (RFC 9111 Section 4.2.2): without
1011
+ // this, a CDN can cache a transient miss and keep serving it after
1012
+ // the object appears. Errors must never outlive their cause.
1013
+ "Cache-Control": "no-store",
1014
+ "X-Content-Type-Options": "nosniff",
1015
+ "Content-Security-Policy": "default-src 'none'",
1016
+ ...extraHeaders,
1017
+ },
1018
+ body: bytes,
1019
+ };
1020
+ }
1021
+ /**
1022
+ * Build an error response for store failures.
1023
+ *
1024
+ * Discriminates three cases: "object not found" (404), "backend transiently
1025
+ * unavailable" (503, retryable), and everything else (502, the upstream
1026
+ * returned something invalid). A 404 hint comes from a `status` of 404 or an
1027
+ * error named `ObjectNotFoundError`/`NotFound`; a 503 hint from a `status` of
1028
+ * 503 or an error named `StoreUnavailableError`, which additionally carries an
1029
+ * optional `retryAfterSeconds` echoed as `Retry-After`.
1030
+ */
1031
+ function storeErrorResponse(err) {
1032
+ if (isNotFoundStoreError(err)) {
1033
+ return plainTextError(404, "Not Found", "Not Found");
1034
+ }
1035
+ if (isUnavailableStoreError(err)) {
1036
+ const secs = retryAfterSeconds(err);
1037
+ return plainTextError(503, "Service Unavailable", "Storage backend unavailable", secs !== undefined ? { "Retry-After": String(secs) } : undefined);
1038
+ }
1039
+ return plainTextError(502, "Bad Gateway", "Storage backend error");
1040
+ }
1041
+ /**
1042
+ * Check if a store error represents a missing object (404).
1043
+ */
1044
+ function isNotFoundStoreError(err) {
1045
+ if (typeof err === "object" && err !== null && "status" in err) {
1046
+ return err.status === 404;
1047
+ }
1048
+ if (err instanceof Error) {
1049
+ return err.name === "ObjectNotFoundError" || err.name === "NotFound";
1050
+ }
1051
+ return false;
1052
+ }
1053
+ /**
1054
+ * Check if a store error signals a transient, retryable backend condition
1055
+ * (throttling/overload/timeout). Matched by `status` 503 or by name so a
1056
+ * third-party store can throw an equivalently-named error without importing
1057
+ * the kernel class.
1058
+ */
1059
+ function isUnavailableStoreError(err) {
1060
+ if (typeof err === "object" && err !== null && "status" in err) {
1061
+ if (err.status === 503)
1062
+ return true;
1063
+ }
1064
+ return err instanceof Error && err.name === "StoreUnavailableError";
1065
+ }
1066
+ /**
1067
+ * Extract a non-negative integer `Retry-After` (delay-seconds, RFC 9110
1068
+ * Section 10.2.3) from a store error's `retryAfterSeconds`, or `undefined`
1069
+ * when absent or malformed. Delegates to the shared parser: fractional hints
1070
+ * are floored, 0 is kept, and a huge finite hint that would serialize as
1071
+ * `1e+21` (a duck-typed third-party error that skipped the StoreUnavailableError
1072
+ * constructor's normalization) is rejected rather than emitted as a malformed
1073
+ * header.
1074
+ */
1075
+ function retryAfterSeconds(err) {
1076
+ if (typeof err !== "object" || err === null || !("retryAfterSeconds" in err))
1077
+ return undefined;
1078
+ return parseRetryAfterSeconds(err.retryAfterSeconds);
1079
+ }
1080
+ /**
1081
+ * Check if a MIME type is textual and should have charset=utf-8 enforced.
1082
+ */
1083
+ function isTextualMime(mime) {
1084
+ if (mime.startsWith("text/"))
1085
+ return true;
1086
+ if (mime === "application/json" || mime === "application/xml")
1087
+ return true;
1088
+ if (mime.endsWith("+json") || mime.endsWith("+xml"))
1089
+ return true;
1090
+ if (mime === "application/javascript" || mime === "application/ecmascript")
1091
+ return true;
1092
+ return false;
1093
+ }
1094
+ //# sourceMappingURL=web.js.map