partial-content 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +601 -0
  4. package/SECURITY.md +92 -0
  5. package/dist/azure.d.ts +79 -0
  6. package/dist/azure.d.ts.map +1 -0
  7. package/dist/azure.js +251 -0
  8. package/dist/azure.js.map +1 -0
  9. package/dist/content-disposition.d.ts +74 -0
  10. package/dist/content-disposition.d.ts.map +1 -0
  11. package/dist/content-disposition.js +253 -0
  12. package/dist/content-disposition.js.map +1 -0
  13. package/dist/fs.d.ts +86 -0
  14. package/dist/fs.d.ts.map +1 -0
  15. package/dist/fs.js +375 -0
  16. package/dist/fs.js.map +1 -0
  17. package/dist/gcs.d.ts +72 -0
  18. package/dist/gcs.d.ts.map +1 -0
  19. package/dist/gcs.js +202 -0
  20. package/dist/gcs.js.map +1 -0
  21. package/dist/hono.d.ts +92 -0
  22. package/dist/hono.d.ts.map +1 -0
  23. package/dist/hono.js +61 -0
  24. package/dist/hono.js.map +1 -0
  25. package/dist/http.d.ts +70 -0
  26. package/dist/http.d.ts.map +1 -0
  27. package/dist/http.js +281 -0
  28. package/dist/http.js.map +1 -0
  29. package/dist/index.d.ts +21 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +27 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/kernel.d.ts +541 -0
  34. package/dist/kernel.d.ts.map +1 -0
  35. package/dist/kernel.js +1218 -0
  36. package/dist/kernel.js.map +1 -0
  37. package/dist/memory.d.ts +55 -0
  38. package/dist/memory.d.ts.map +1 -0
  39. package/dist/memory.js +107 -0
  40. package/dist/memory.js.map +1 -0
  41. package/dist/mime.d.ts +49 -0
  42. package/dist/mime.d.ts.map +1 -0
  43. package/dist/mime.js +150 -0
  44. package/dist/mime.js.map +1 -0
  45. package/dist/node.d.ts +84 -0
  46. package/dist/node.d.ts.map +1 -0
  47. package/dist/node.js +215 -0
  48. package/dist/node.js.map +1 -0
  49. package/dist/object-store.d.ts +472 -0
  50. package/dist/object-store.d.ts.map +1 -0
  51. package/dist/object-store.js +335 -0
  52. package/dist/object-store.js.map +1 -0
  53. package/dist/r2.d.ts +94 -0
  54. package/dist/r2.d.ts.map +1 -0
  55. package/dist/r2.js +150 -0
  56. package/dist/r2.js.map +1 -0
  57. package/dist/s3.d.ts +49 -0
  58. package/dist/s3.d.ts.map +1 -0
  59. package/dist/s3.js +263 -0
  60. package/dist/s3.js.map +1 -0
  61. package/dist/web.d.ts +336 -0
  62. package/dist/web.d.ts.map +1 -0
  63. package/dist/web.js +1094 -0
  64. package/dist/web.js.map +1 -0
  65. package/docs/DESIGN.md +426 -0
  66. package/package.json +182 -0
  67. package/src/azure.ts +329 -0
  68. package/src/content-disposition.ts +300 -0
  69. package/src/fs.ts +469 -0
  70. package/src/gcs.ts +290 -0
  71. package/src/hono.ts +123 -0
  72. package/src/http.ts +351 -0
  73. package/src/index.ts +85 -0
  74. package/src/kernel.ts +1498 -0
  75. package/src/memory.ts +148 -0
  76. package/src/mime.ts +160 -0
  77. package/src/node.ts +261 -0
  78. package/src/object-store.ts +665 -0
  79. package/src/r2.ts +232 -0
  80. package/src/s3.ts +324 -0
  81. package/src/web.ts +1603 -0
package/src/web.ts ADDED
@@ -0,0 +1,1603 @@
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
+
31
+ import {
32
+ evaluateConditionalRequest,
33
+ buildRangeResponseHeaders,
34
+ buildContentDisposition,
35
+ generateETag,
36
+ clientWantsDigest,
37
+ sanitizeHeaderValue,
38
+ isRangeFresh,
39
+ parseRanges,
40
+ ObjectChangedError,
41
+ build416Headers,
42
+ buildMultipartHeaders,
43
+ buildMultipartPartHeader,
44
+ multipartEpilogue,
45
+ generateMultipartBoundary,
46
+ MAX_RANGES_DEFAULT,
47
+ OPEN_ENDED,
48
+ parseRetryAfterSeconds,
49
+ type ObjectStore,
50
+ type ObjectMetadata,
51
+ type ParsedRange,
52
+ } from "./index.js";
53
+
54
+ // Re-export kernel types so consumers only need one import
55
+ export type {
56
+ CancelSignal,
57
+ ObjectStore,
58
+ ObjectMetadata,
59
+ ObjectStream,
60
+ ParsedRange,
61
+ ETagSource,
62
+ } from "./index.js";
63
+
64
+ // ─── Constants ───────────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * One reusable UTF-8 encoder for the module (multipart part headers, plain-text
68
+ * error bodies), mirroring the kernel's hoisted encoder. `TextEncoder` is
69
+ * stateless, so a single instance is safe to share and avoids a per-response
70
+ * allocation.
71
+ */
72
+ const UTF8_ENCODER = new TextEncoder();
73
+
74
+ /** Reason phrases for HTTP statuses used by this adapter. */
75
+ const STATUS_TEXT: Record<number, string> = {
76
+ 200: "OK",
77
+ 206: "Partial Content",
78
+ 304: "Not Modified",
79
+ 412: "Precondition Failed",
80
+ 416: "Range Not Satisfiable",
81
+ 499: "Client Closed Request",
82
+ 502: "Bad Gateway",
83
+ };
84
+
85
+ // ─── Options ────────────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * The request surface the orchestrator actually consumes. A standard fetch
89
+ * `Request` satisfies it structurally; server adapters may pass a
90
+ * lightweight view instead, avoiding per-request construction of fetch
91
+ * primitives (an undici `Request` + `Headers` pair costs a measurable
92
+ * fraction of a small-file serve).
93
+ */
94
+ export interface ServableRequest {
95
+ /** HTTP method. GET and HEAD are served; anything else gets a 405. */
96
+ method: string;
97
+ /** Case-insensitive header lookup: a `Headers` object or any equivalent view. */
98
+ headers: { get(name: string): string | null };
99
+ /** Client-disconnect signal, forwarded to the storage backend. */
100
+ signal?: AbortSignal;
101
+ }
102
+
103
+ /** Metadata resolved by the consumer before the handler runs. */
104
+ export interface ServeContext {
105
+ /** Storage key (path within the bucket). */
106
+ key: string;
107
+ /** Override MIME type. When omitted, defaults to "application/octet-stream". */
108
+ mime?: string;
109
+ /** Filename for Content-Disposition. When omitted, no disposition header is set. */
110
+ filename?: string;
111
+ /**
112
+ * Per-request Cache-Control, overriding {@link ServeObjectOptions.cacheControl}.
113
+ * Lets one handler serve mixed cacheability (immutable content-addressed
114
+ * blobs next to private user uploads) without instantiating N handlers.
115
+ * Used verbatim: the `immutable` option is NOT appended to it.
116
+ */
117
+ cacheControl?: string;
118
+ }
119
+
120
+ export interface ServeObjectOptions {
121
+ /**
122
+ * Content-Disposition strategy.
123
+ * - `"inline"` -- render in the browser (PDF, images, video)
124
+ * - `"attachment"` -- force download
125
+ * - A function mapping MIME to disposition type (for per-type policy)
126
+ *
127
+ * @default "attachment"
128
+ */
129
+ disposition?: "inline" | "attachment" | ((mime: string) => "inline" | "attachment");
130
+
131
+ /**
132
+ * Cache-Control value for 200/206 responses.
133
+ * @default "private, no-cache"
134
+ */
135
+ cacheControl?: string;
136
+
137
+ /**
138
+ * When true, appends `immutable` to the Cache-Control directive.
139
+ * Use for content-addressed storage where the key contains a hash
140
+ * (e.g. `<sha256>.pdf`) and the resource will never change.
141
+ *
142
+ * @default false
143
+ */
144
+ immutable?: boolean;
145
+
146
+ /**
147
+ * Security headers applied to every response that carries a body (200, 206).
148
+ * Receives the MIME type so the policy can vary per format (e.g. relaxed
149
+ * CSP for PDF.js, strict sandbox for images).
150
+ *
151
+ * @default No extra security headers.
152
+ */
153
+ securityHeaders?: (mime: string) => Record<string, string>;
154
+
155
+ /**
156
+ * Cross-Origin-Resource-Policy value for success responses.
157
+ *
158
+ * Controls which origins can embed this resource:
159
+ * - `"same-origin"` -- only your origin (most secure)
160
+ * - `"same-site"` -- same registrable domain
161
+ * - `"cross-origin"` -- any origin (for public CDN assets)
162
+ *
163
+ * Required for pages with `Cross-Origin-Embedder-Policy: require-corp`.
164
+ * When omitted, no CORP header is set (caller decides).
165
+ */
166
+ crossOriginResourcePolicy?: "same-origin" | "same-site" | "cross-origin";
167
+
168
+ /**
169
+ * Timing-Allow-Origin value for success responses.
170
+ *
171
+ * Allows cross-origin pages to read Server-Timing and Resource Timing data
172
+ * via the PerformanceObserver API. Without this, browser security policy
173
+ * zeros out timing metrics for cross-origin resources.
174
+ *
175
+ * Set to `"*"` for public assets, or a specific origin for private APIs.
176
+ */
177
+ timingAllowOrigin?: string;
178
+
179
+ /**
180
+ * When true, emits a `Server-Timing` header with storage and evaluation
181
+ * latency metrics. Also calls `onTiming` if provided.
182
+ *
183
+ * Caution: timing data may leak internal architecture details.
184
+ * Enable only when you control the deployment environment.
185
+ *
186
+ * @default false
187
+ */
188
+ timing?: boolean;
189
+
190
+ /**
191
+ * Timing callback for observability.
192
+ *
193
+ * Called with structured timing data for every request. Use to ship
194
+ * metrics to your RUM/APM backend.
195
+ */
196
+ onTiming?: (metrics: { storeMs: number; evaluateMs: number; totalMs: number }) => void;
197
+
198
+ /**
199
+ * Fallback filename used by buildContentDisposition when no filename is
200
+ * provided and the disposition needs an ASCII fallback.
201
+ *
202
+ * @default "download"
203
+ */
204
+ fallbackFilename?: string;
205
+
206
+ /**
207
+ * Error callback for observability.
208
+ *
209
+ * Called when the storage backend throws during HEAD or GET operations.
210
+ * Use this to log errors with request IDs, correlation tokens, and
211
+ * structured metadata for production monitoring.
212
+ *
213
+ * The error is NOT exposed to the client (the response body is generic).
214
+ *
215
+ * @param error - The original error from the storage backend
216
+ * @param context - Additional context about the failed operation
217
+ * (`audit` = the consumer's own onServe hook threw; the response was
218
+ * still served, the hook failure is surfaced here instead of crashing).
219
+ */
220
+ onError?: (error: unknown, context: { key: string; operation: "head" | "get" | "audit" }) => void;
221
+
222
+ /**
223
+ * Audit callback for compliance logging (SOC 2 CC7.2, ISO 27001 A.8.15).
224
+ *
225
+ * Called on every response that grants access (200, 206, 304, and 302
226
+ * signed-URL redirects) with structured metadata suitable for audit trail
227
+ * ingestion. Not called on errors (use `onError` for those).
228
+ *
229
+ * @example
230
+ * ```ts
231
+ * onServe: (event) => logger.info({ ...event }, "file.served")
232
+ * ```
233
+ */
234
+ onServe?: (event: ServeAuditEvent) => void;
235
+
236
+ /**
237
+ * Transfer-completion callback for true egress accounting and truncation
238
+ * detection.
239
+ *
240
+ * `onServe` fires when headers are committed and reports bytes *granted*
241
+ * (the response Content-Length). This fires once when the response body
242
+ * reaches its terminal state and reports bytes *actually transferred*
243
+ * through it, plus whether it drained fully or the client disconnected
244
+ * early. Use it for egress billing (`bytesTransferred`) or abandonment
245
+ * analytics (`completed === false`).
246
+ *
247
+ * Zero-cost when unset: the body is returned untouched, so byte bodies keep
248
+ * the runtime's static-body fast path. When set, the body is routed through
249
+ * a counting stream (byte bodies are wrapped too, so measurement is uniform
250
+ * across stores) -- a deliberate cost you opt into for the metering.
251
+ *
252
+ * Fires only for 200/206 GET responses (a body was served). Never fires for
253
+ * HEAD, 304, 302, 412, 416, or error responses. A throwing callback cannot
254
+ * corrupt the transfer: its error is routed to `onError` (operation
255
+ * `"audit"`).
256
+ *
257
+ * @example
258
+ * ```ts
259
+ * onTransfer: (e) => {
260
+ * meter.recordEgress(e.key, e.bytesTransferred);
261
+ * if (!e.completed) log.info({ ...e }, "download.abandoned");
262
+ * }
263
+ * ```
264
+ */
265
+ onTransfer?: (event: TransferEvent) => void;
266
+
267
+ /**
268
+ * Maximum number of distinct (coalesced) byte ranges to serve as
269
+ * multipart/byteranges before a multi-range request degrades to a full 200.
270
+ *
271
+ * A range-amplification defense: a client sending thousands of tiny or
272
+ * overlapping ranges would otherwise force a large multipart response.
273
+ * Overlapping/adjacent ranges are coalesced first, and if the result still
274
+ * exceeds this cap (or already covers the whole object) the full 200 is
275
+ * served instead. Matches the intent of nginx `max_ranges` and Go's
276
+ * sum-of-ranges check.
277
+ *
278
+ * @default 50
279
+ */
280
+ maxRanges?: number;
281
+
282
+ /**
283
+ * When true, appends `; charset=utf-8` to textual Content-Type values
284
+ * (text/*, application/json, application/xml, etc.) if not already present.
285
+ *
286
+ * Prevents UTF-7 encoding-sniffing XSS in legacy browsers that probe for
287
+ * a charset declaration and fall back to auto-detection when none is found.
288
+ *
289
+ * @default true
290
+ */
291
+ enforceCharset?: boolean;
292
+ }
293
+
294
+ /** Structured audit event for compliance logging. */
295
+ export interface ServeAuditEvent {
296
+ /** Storage key that was served. */
297
+ key: string;
298
+ /**
299
+ * Request method. Distinguishes a HEAD metadata probe (no bytes transferred)
300
+ * from a GET, which otherwise both surface as `status: 200, bytesServed: 0`
301
+ * for an empty object.
302
+ */
303
+ method: "GET" | "HEAD";
304
+ /**
305
+ * HTTP status code. 302 = access granted via signed-URL redirect.
306
+ * 412/416 = access DENIED (failed precondition / unsatisfiable range):
307
+ * emitted so compliance trails capture denials, not only grants -- a 412
308
+ * is an optimistic-concurrency conflict signal auditors ask for.
309
+ */
310
+ status: 200 | 206 | 302 | 304 | 412 | 416;
311
+ /** MIME type of the served content. */
312
+ mime: string;
313
+ /**
314
+ * Body bytes GRANTED, not confirmed transferred: the body's
315
+ * Content-Length on a 200/206 GET (the event fires when headers are
316
+ * committed, before the stream drains, so a client disconnect can
317
+ * receive fewer bytes). 0 for HEAD, 304, 302, 412, and 416. Treat as
318
+ * access volume, never as exfiltration volume.
319
+ */
320
+ bytesServed: number;
321
+ /** Range start (inclusive), present only on 206. */
322
+ rangeStart?: number;
323
+ /** Range end (inclusive), present only on 206. */
324
+ rangeEnd?: number;
325
+ /** ETag of the served representation, if available. */
326
+ etag?: string;
327
+ }
328
+
329
+ /** Structured transfer-completion event ({@link ServeObjectOptions.onTransfer}). */
330
+ export interface TransferEvent {
331
+ /** Storage key that was served. */
332
+ key: string;
333
+ /** Always GET: only GET responses carry a body to transfer. */
334
+ method: "GET";
335
+ /** HTTP status of the served body. */
336
+ status: 200 | 206;
337
+ /**
338
+ * Bytes GRANTED: the response body's Content-Length (206 range span or 200
339
+ * full size). Compare against {@link bytesTransferred} to detect truncation.
340
+ */
341
+ bytesExpected: number;
342
+ /**
343
+ * Bytes ACTUALLY read through the response body before it reached its
344
+ * terminal state. Equals {@link bytesExpected} on a fully-drained transfer;
345
+ * less when the client disconnected or cancelled early.
346
+ */
347
+ bytesTransferred: number;
348
+ /**
349
+ * `true` if the body drained completely, `false` if it was cancelled /
350
+ * the client disconnected before the last byte. The honest signal for
351
+ * egress billing and abandonment analytics.
352
+ */
353
+ completed: boolean;
354
+ /** Range start (inclusive), present only on 206. */
355
+ rangeStart?: number;
356
+ /** Range end (inclusive), present only on 206. */
357
+ rangeEnd?: number;
358
+ }
359
+
360
+ // ─── Handler ────────────────────────────────────────────────────────────────
361
+
362
+ /**
363
+ * Serve an object from an {@link ObjectStore} with full RFC 7232/7233 support.
364
+ *
365
+ * Returns a function that takes a standard `Request` plus {@link ServeContext}
366
+ * and returns a `Response`. This is framework-agnostic: it works with Next.js
367
+ * App Router, Hono, SvelteKit, Remix, or any runtime that uses the Fetch API.
368
+ *
369
+ * The evaluation chain:
370
+ * 1. HEAD -> get metadata (ETag, Last-Modified, size)
371
+ * 2. Preconditions (If-Match / If-Unmodified-Since) -> 412
372
+ * 3. Freshness (If-None-Match / If-Modified-Since) -> 304
373
+ * 4. If-Range validation -> honor or ignore Range
374
+ * 5. Range parsing -> 206 or 416
375
+ * 6. Stream bytes -> 200 or 206
376
+ *
377
+ * TOCTOU guard: ETag/Last-Modified from the GET response are preferred over
378
+ * HEAD values. If the GET response omits Content-Range for a range request,
379
+ * the handler degrades to 200 (never emits a lying 206).
380
+ */
381
+ export interface RawResponseParts {
382
+ status: number;
383
+ /** Reason phrase (e.g. "Partial Content", "Client Closed Request"). */
384
+ statusText: string;
385
+ headers: Record<string, string>;
386
+ /**
387
+ * `null` for bodyless statuses (HEAD, 304, 412, 416, 302, 405).
388
+ * `<ArrayBuffer>`-backed so `new Response(parts.body)` compiles under DOM lib.
389
+ */
390
+ body: ReadableStream<Uint8Array<ArrayBuffer>> | Uint8Array<ArrayBuffer> | null;
391
+ }
392
+
393
+ export function serveObject(
394
+ store: ObjectStore,
395
+ opts: ServeObjectOptions = {},
396
+ ) {
397
+ const raw = serveObjectRaw(store, opts);
398
+ return async function handleServe(
399
+ req: ServableRequest,
400
+ ctx: ServeContext,
401
+ ): Promise<Response> {
402
+ const parts = await raw(req, ctx);
403
+ // BodyInit accepts both body forms; byte bodies take the runtime's
404
+ // static-body fast path (no stream machinery).
405
+ return new Response(parts.body, {
406
+ status: parts.status,
407
+ statusText: parts.statusText,
408
+ headers: parts.headers,
409
+ });
410
+ };
411
+ }
412
+
413
+ /**
414
+ * The engine behind serveObject, exposed for server adapters: identical
415
+ * protocol behavior, but the result is RawResponseParts instead of a
416
+ * Response. Use this when your server writes status/headers/body natively
417
+ * (the bundled node adapter does) -- constructing a Response + Headers pair
418
+ * per request just to immediately deconstruct them is measurable overhead
419
+ * on hot paths.
420
+ */
421
+ export function serveObjectRaw(
422
+ store: ObjectStore,
423
+ opts: ServeObjectOptions = {},
424
+ ) {
425
+ const {
426
+ disposition: dispositionOpt = "attachment",
427
+ cacheControl: rawCacheControl = "private, no-cache",
428
+ immutable: immutableOpt = false,
429
+ securityHeaders,
430
+ crossOriginResourcePolicy,
431
+ timingAllowOrigin,
432
+ timing: timingEnabled = false,
433
+ onTiming,
434
+ fallbackFilename = "download",
435
+ onError,
436
+ onServe: rawOnServe,
437
+ onTransfer: rawOnTransfer,
438
+ maxRanges = MAX_RANGES_DEFAULT,
439
+ enforceCharset = true,
440
+ } = opts;
441
+
442
+ // A consumer's audit hook must never break the never-throw contract: the
443
+ // bodyless emissions (302/304/412/416/HEAD) fire outside any guard, so a
444
+ // throwing hook would escape the handler. Route hook failures to onError;
445
+ // the response itself is unaffected.
446
+ const onServe = rawOnServe
447
+ ? (event: ServeAuditEvent): void => {
448
+ try {
449
+ rawOnServe(event);
450
+ } catch (err) {
451
+ onError?.(err, { key: event.key, operation: "audit" });
452
+ }
453
+ }
454
+ : undefined;
455
+
456
+ // Same guard for the transfer hook, which fires from inside the body's
457
+ // stream machinery (during the runtime's consumption, after the handler has
458
+ // returned). A throw there would error the response stream mid-flight;
459
+ // route it to onError instead so the transfer completes cleanly.
460
+ const onTransfer = rawOnTransfer
461
+ ? (event: TransferEvent): void => {
462
+ try {
463
+ rawOnTransfer(event);
464
+ } catch (err) {
465
+ onError?.(err, { key: event.key, operation: "audit" });
466
+ }
467
+ }
468
+ : undefined;
469
+
470
+ // Resolve Cache-Control once (append immutable if configured)
471
+ const cacheControl = immutableOpt && !rawCacheControl.includes("immutable")
472
+ ? `${rawCacheControl}, immutable`
473
+ : rawCacheControl;
474
+
475
+ return async function handleServeRaw(
476
+ req: ServableRequest,
477
+ ctx: ServeContext,
478
+ ): Promise<RawResponseParts> {
479
+ // Method validation: only GET and HEAD are valid for file serving.
480
+ // Other methods get 405 with Allow header per RFC 9110 Section 15.5.6.
481
+ const method = req.method;
482
+ if (method !== "GET" && method !== "HEAD") {
483
+ return {
484
+ status: 405,
485
+ statusText: "Method Not Allowed",
486
+ headers: {
487
+ Allow: "GET, HEAD",
488
+ "Content-Length": "0",
489
+ "Cache-Control": "no-store",
490
+ "Content-Security-Policy": "default-src 'none'",
491
+ "X-Content-Type-Options": "nosniff",
492
+ },
493
+ body: null,
494
+ };
495
+ }
496
+
497
+ const { key } = ctx;
498
+ const isHead = method === "HEAD";
499
+ const mime = ctx.mime ?? "application/octet-stream";
500
+
501
+ // Resolve Content-Disposition
502
+ const dispositionType = typeof dispositionOpt === "function"
503
+ ? dispositionOpt(mime)
504
+ : dispositionOpt;
505
+ const disposition = ctx.filename
506
+ ? buildContentDisposition(ctx.filename, { type: dispositionType, fallback: fallbackFilename })
507
+ : dispositionType;
508
+
509
+ // Extra security headers for body responses
510
+ const extraHeaders = securityHeaders ? securityHeaders(mime) : {};
511
+
512
+ // Response-building context shared by all response paths
513
+ const rctx: ResponseContext = {
514
+ mime, disposition, extraHeaders,
515
+ cacheControl: ctx.cacheControl ?? cacheControl,
516
+ crossOriginResourcePolicy, timingAllowOrigin, enforceCharset,
517
+ digestWanted: clientWantsDigest(req.headers),
518
+ };
519
+
520
+ // ── Signed-URL fallback for backends that cannot stream ranges ───────
521
+ if (store.supportsRange === false) {
522
+ if (store.createSignedUrl) {
523
+ const result = await store.createSignedUrl(key, {
524
+ expiresInSeconds: 60,
525
+ downloadFilename: ctx.filename,
526
+ });
527
+ if ("url" in result) {
528
+ // A signed-URL redirect grants file access: audit it like a serve.
529
+ onServe?.({ key, method: isHead ? "HEAD" : "GET", status: 302, mime, bytesServed: 0 });
530
+ return {
531
+ status: 302,
532
+ statusText: "Found",
533
+ headers: {
534
+ // The signed URL is backend-derived: sanitize it like every
535
+ // other metadata-sourced header so a malformed provider
536
+ // response cannot inject a header or crash the writer.
537
+ Location: sanitizeHeaderValue(result.url),
538
+ "Cache-Control": "no-store, no-cache",
539
+ "Accept-Ranges": "none",
540
+ "Content-Length": "0",
541
+ },
542
+ body: null,
543
+ };
544
+ }
545
+ }
546
+ return plainTextError(502, "Bad Gateway", "Storage backend does not support streaming");
547
+ }
548
+
549
+ // ── Detect request characteristics ──────────────────────────────────
550
+ const headers = req.headers;
551
+ const hasConditional = Boolean(
552
+ headers.get("if-none-match") || headers.get("if-modified-since")
553
+ || headers.get("if-match") || headers.get("if-unmodified-since"),
554
+ );
555
+ const rangeHeader = headers.get("range");
556
+ const hasIfRange = Boolean(headers.get("if-range"));
557
+ // Multi-range (comma-separated) is served as multipart/byteranges. It
558
+ // always needs the HEAD-resolved total to clamp bounds and cannot use the
559
+ // single-range fast path.
560
+ const isMultiRange = !isHead && Boolean(rangeHeader && rangeHeader.includes(","));
561
+ // Range requests need HEAD to resolve: parseRangeHeader requires
562
+ // totalSize to clamp bounds and detect unsatisfiable ranges.
563
+ const needsHead = hasConditional || hasIfRange || isHead || Boolean(rangeHeader);
564
+
565
+ // ── Path B: plain range on an authoritative-range store ──────────────
566
+ // A range GET with no conditionals and no If-Range needs nothing from a
567
+ // HEAD: stores whose 206 bounds/total come from the backend's actual
568
+ // Content-Range can serve the seek in ONE round-trip (validators,
569
+ // bounds, and digest all come from the GET itself -- inherently
570
+ // TOCTOU-atomic). This halves latency on the hottest media path
571
+ // (video seeking, PDF.js chunked loading). Suffix ranges (`bytes=-N`)
572
+ // and anything the strict parser rejects fall through to Path A, whose
573
+ // HEAD-resolved evaluation handles them.
574
+ if (rangeHeader && !hasConditional && !hasIfRange && !isHead && store.authoritativeRange) {
575
+ const fastRange = parseFastRange(rangeHeader);
576
+ if (fastRange) {
577
+ let parts: RawResponseParts | null = null;
578
+ // Capture (don't report yet) the speculative failure. A 502 falls
579
+ // through to Path A, which re-runs and reports authoritatively --
580
+ // reporting here too would double-count. But a terminal 404/503 is
581
+ // served straight from Path B and never re-runs, so on the hottest
582
+ // path (every authoritative-range seek) those failures would be
583
+ // invisible to onError -- exactly the telemetry a monitoring
584
+ // consumer wires up. Capture here, report once below if terminal.
585
+ let speculativeErr: unknown;
586
+ try {
587
+ parts = await streamFromStore({
588
+ store, key, range: fastRange, ctx: rctx, signal: req.signal,
589
+ onError: onError ? (err) => { speculativeErr = err; } : undefined,
590
+ timingCtx: timingEnabled ? { storeMs: 0, evaluateMs: 0, onTiming } : undefined,
591
+ auditCtx: onServe ? { onServe, mime } : undefined,
592
+ // A speculative success IS the served response, so it must meter.
593
+ // A failure returns 502 (no store body) and falls to Path A, which
594
+ // meters the real transfer -- no double-fire.
595
+ onTransfer,
596
+ });
597
+ } catch {
598
+ // ObjectChangedError without a pin, or any other escape: fall
599
+ // through to Path A, which reports and responds correctly.
600
+ }
601
+ // A 502 here usually means the backend rejected the range natively
602
+ // (e.g. start beyond EOF -> S3 InvalidRange). Path A's
603
+ // HEAD-resolved evaluation turns that into the correct 416 with
604
+ // real bounds (or surfaces the genuine store failure with full
605
+ // error reporting). The retry only ever costs an extra attempt on
606
+ // error paths.
607
+ if (parts && parts.status !== 502) {
608
+ // Terminal outcome served from Path B (200/206 success, or a 404/503
609
+ // that Path A would never revisit): surface a captured 404/503 error
610
+ // exactly once so throttle/not-found storms stay visible to onError.
611
+ if ((parts.status === 404 || parts.status === 503) && speculativeErr !== undefined) {
612
+ onError?.(speculativeErr, { key, operation: "get" });
613
+ }
614
+ return parts;
615
+ }
616
+ }
617
+ }
618
+
619
+ // ── Path A: HEAD required ───────────────────────────────────────────
620
+ // The GET is pinned to the HEAD's raw ETag (GetObjectOptions.ifMatch),
621
+ // making the HEAD->GET pair atomic on backends with conditional reads.
622
+ // If the object changes in that window the store throws
623
+ // ObjectChangedError; re-validate ONCE against the new state so the
624
+ // client gets a coherent answer (e.g. a stale If-Range now correctly
625
+ // yields a full 200 of the new bytes) instead of an error.
626
+ for (let attempt = 0; needsHead; attempt++) {
627
+ let meta: ObjectMetadata;
628
+ const t0 = timingEnabled ? performance.now() : 0;
629
+ try {
630
+ meta = await store.headObject(key, { signal: req.signal });
631
+ } catch (err) {
632
+ if (isAbortError(err)) return clientClosed();
633
+ onError?.(err, { key, operation: "head" });
634
+ return storeErrorResponse(err);
635
+ }
636
+ const storeMs = timingEnabled ? performance.now() - t0 : 0;
637
+ const etag = deriveETag(meta);
638
+
639
+ // RFC 7232/7233 full evaluation chain. Conditionals apply to HEAD
640
+ // exactly as to GET (RFC 9110 Section 13.1) -- a conditional HEAD must
641
+ // still yield 304/412. Range, however, is only defined for GET
642
+ // (RFC 9110 Section 14.2), so it is masked out for HEAD requests.
643
+ const evalHeaders = isHead ? withoutRangeHeaders(headers) : headers;
644
+ const t1 = timingEnabled ? performance.now() : 0;
645
+ let evaluation: ReturnType<typeof evaluateConditionalRequest>;
646
+ try {
647
+ evaluation = evaluateConditionalRequest(evalHeaders, {
648
+ totalSize: meta.contentLength,
649
+ contentType: mime,
650
+ etag,
651
+ lastModified: meta.lastModified,
652
+ cacheControl: rctx.cacheControl,
653
+ digest: meta.digest,
654
+ });
655
+ } catch (err) {
656
+ // The kernel rejects corrupt store metadata (NaN/negative sizes)
657
+ // with a RangeError. That is an adapter bug, but a handler must
658
+ // always produce a Response -- an escaping throw becomes an
659
+ // unhandled rejection (and a process crash under Express 4).
660
+ onError?.(err, { key, operation: "head" });
661
+ return storeErrorResponse(err);
662
+ }
663
+ const evaluateMs = timingEnabled ? performance.now() - t1 : 0;
664
+
665
+ // Early exits: 412, 304, 416
666
+ if (evaluation.status === 304) {
667
+ onServe?.({ key, method: isHead ? "HEAD" : "GET", status: 304, mime, bytesServed: 0, etag });
668
+ return {
669
+ status: 304,
670
+ statusText: STATUS_TEXT[304],
671
+ headers: evaluation.headers,
672
+ body: null,
673
+ };
674
+ }
675
+ if (evaluation.status === 412) {
676
+ // Denials are audit events too: a 412 is an optimistic-concurrency
677
+ // conflict (failed If-Match), exactly what SOC 2 change-control
678
+ // trails want captured alongside grants.
679
+ onServe?.({ key, method: isHead ? "HEAD" : "GET", status: 412, mime, bytesServed: 0, etag });
680
+ return {
681
+ status: 412,
682
+ statusText: STATUS_TEXT[412],
683
+ headers: {
684
+ ...evaluation.headers,
685
+ ...DENY_HEADERS,
686
+ },
687
+ body: null,
688
+ };
689
+ }
690
+
691
+ if (evaluation.status === 416) {
692
+ onServe?.({ key, method: "GET", status: 416, mime, bytesServed: 0, etag });
693
+ return {
694
+ status: evaluation.status,
695
+ statusText: "Range Not Satisfiable",
696
+ headers: {
697
+ ...evaluation.headers,
698
+ ...DENY_HEADERS,
699
+ },
700
+ body: null,
701
+ };
702
+ }
703
+
704
+ // HEAD method: preconditions passed -- return headers only, no body
705
+ // (PDF.js size probing, cache priming).
706
+ if (isHead) {
707
+ onServe?.({ key, method: "HEAD", status: 200, mime, bytesServed: 0, etag });
708
+ return buildHeadResponse(meta, etag, rctx);
709
+ }
710
+
711
+ // Stream bytes from store, pinned to the representation just validated.
712
+ // Only STRONG validators pin: RFC 9110 Section 13.1.1 mandates strong
713
+ // comparison for If-Match, so a weak `W/` ETag would fail the
714
+ // precondition on every attempt (guaranteed 412 -> retry -> 502).
715
+ // Weak validators cannot assert byte equality anyway; the response-side
716
+ // guard still protects those reads.
717
+ const pinEtag = meta.etag && !meta.etag.startsWith("W/") ? meta.etag : undefined;
718
+
719
+ // ── Multi-range: serve multipart/byteranges ──────────────────────────
720
+ // evaluateConditionalRequest already settled 412/304 above (conditionals
721
+ // are range-independent) and, because parseRangeHeader rejects comma
722
+ // ranges, left this as a would-be 200. Honor the ranges here. A stale
723
+ // If-Range (validator moved) means ignore the Range -> fall through to
724
+ // the full 200 below; parseRanges returning null (amplification, or the
725
+ // ranges cover the whole file) does the same.
726
+ if (isMultiRange && isRangeFresh(headers, etag, meta.lastModified)) {
727
+ const set = parseRanges(rangeHeader, meta.contentLength, maxRanges);
728
+ if (set === "unsatisfiable") {
729
+ onServe?.({ key, method: "GET", status: 416, mime, bytesServed: 0, etag });
730
+ return {
731
+ status: 416,
732
+ statusText: "Range Not Satisfiable",
733
+ headers: {
734
+ ...build416Headers(meta.contentLength).headers,
735
+ ...DENY_HEADERS,
736
+ },
737
+ body: null,
738
+ };
739
+ }
740
+ if (set !== null) {
741
+ try {
742
+ if (set.ranges.length === 1) {
743
+ // Overlapping ranges coalesced to one: a normal single 206.
744
+ return await streamFromStore({
745
+ store, key, range: set.ranges[0],
746
+ ifMatch: pinEtag, pin: meta.pin,
747
+ headEtag: etag, headLastModified: meta.lastModified,
748
+ reprDigest: meta.digest, ctx: rctx, signal: req.signal, onError,
749
+ timingCtx: timingEnabled ? { storeMs, evaluateMs, onTiming } : undefined,
750
+ auditCtx: onServe ? { onServe, mime } : undefined,
751
+ onTransfer,
752
+ });
753
+ }
754
+ return await serveMultipart({
755
+ store, key, ranges: set.ranges, totalSize: meta.contentLength,
756
+ mime, etag, lastModified: meta.lastModified, digest: meta.digest,
757
+ ifMatch: pinEtag, pin: meta.pin, ctx: rctx, signal: req.signal,
758
+ onError, auditCtx: onServe ? { onServe, mime } : undefined, onTransfer,
759
+ });
760
+ } catch (err) {
761
+ // A pinned first-part read lost its race: re-validate once, exactly
762
+ // like single-range serving.
763
+ if (isObjectChangedError(err) && attempt === 0) continue;
764
+ onError?.(err, { key, operation: "get" });
765
+ return storeErrorResponse(err);
766
+ }
767
+ }
768
+ // set === null: serve the full 200 (fall through).
769
+ }
770
+
771
+ try {
772
+ return await streamFromStore({
773
+ store, key, range: evaluation.range ?? undefined,
774
+ ifMatch: pinEtag, pin: meta.pin,
775
+ headEtag: etag, headLastModified: meta.lastModified,
776
+ reprDigest: meta.digest, ctx: rctx, signal: req.signal, onError,
777
+ timingCtx: timingEnabled ? { storeMs, evaluateMs, onTiming } : undefined,
778
+ auditCtx: onServe ? { onServe, mime } : undefined,
779
+ onTransfer,
780
+ });
781
+ } catch (err) {
782
+ // Re-validate ONCE, and only for a pinned-read race. Any other error
783
+ // that escapes streamFromStore -- e.g. a corrupt-metadata RangeError
784
+ // rethrown from buildHeaders -- is deterministic, so a retry just wastes
785
+ // a HEAD+GET and drops the first failure from onError. Report it now.
786
+ // Mirrors the multipart catch above.
787
+ if (isObjectChangedError(err) && attempt === 0) continue;
788
+ onError?.(err, { key, operation: "get" });
789
+ return storeErrorResponse(err);
790
+ }
791
+ }
792
+
793
+ // ── Path C: No Range, no conditional headers ─────────────────────────
794
+ // Same never-throw contract as Path A: corrupt GET metadata (header
795
+ // builder RangeError), a throwing onServe hook, or a store throwing
796
+ // ObjectChangedError without a pin must become a 502, not a rejected
797
+ // handler (which crashes Express 4 processes).
798
+ try {
799
+ return await streamFromStore({
800
+ store, key, ctx: rctx, signal: req.signal, onError,
801
+ timingCtx: timingEnabled ? { storeMs: 0, evaluateMs: 0, onTiming } : undefined,
802
+ auditCtx: onServe ? { onServe, mime } : undefined,
803
+ onTransfer,
804
+ });
805
+ } catch (err) {
806
+ onError?.(err, { key, operation: "get" });
807
+ return storeErrorResponse(err);
808
+ }
809
+ };
810
+ }
811
+
812
+ // ─── Internal Helpers ───────────────────────────────────────────────────────
813
+
814
+ /**
815
+ * Wrap a headers view so Range and If-Range read as absent.
816
+ *
817
+ * Used for HEAD requests: RFC 9110 Section 14.2 defines range handling only
818
+ * for GET, so the evaluation chain must not see the Range header (a 206 or
819
+ * 416 for HEAD would be wrong), while conditionals still apply.
820
+ */
821
+ function withoutRangeHeaders(
822
+ headers: { get(name: string): string | null },
823
+ ): { get(name: string): string | null } {
824
+ return {
825
+ get(name: string): string | null {
826
+ const lower = name.toLowerCase();
827
+ if (lower === "range" || lower === "if-range") return null;
828
+ return headers.get(lower);
829
+ },
830
+ };
831
+ }
832
+
833
+ /**
834
+ * Parse a Range header for the single-round-trip fast path (no totalSize
835
+ * available yet). Accepts only `bytes=a-b` and `bytes=a-` (open end becomes
836
+ * MAX_SAFE_INTEGER; RFC 9110 Section 14.1.2 lets the server clamp a
837
+ * last-byte-pos past EOF, and authoritative-range backends do). Everything
838
+ * else -- suffix ranges, multi-range, malformed specs -- returns null so the
839
+ * validating HEAD path evaluates it with the real object size.
840
+ */
841
+ function parseFastRange(header: string): { start: number; end: number } | null {
842
+ const m = /^bytes=(\d+)-(\d*)$/.exec(header.trim());
843
+ if (!m) return null;
844
+ const start = Number(m[1]);
845
+ if (!Number.isSafeInteger(start)) return null;
846
+ const end = m[2] ? Number(m[2]) : OPEN_ENDED;
847
+ if (!Number.isSafeInteger(end) || end < start) return null;
848
+ return { start, end };
849
+ }
850
+
851
+ /** Derive an ETag from storage metadata using the kernel's formatter. */
852
+ function deriveETag(meta: ObjectMetadata): string | undefined {
853
+ return generateETag({
854
+ hash: meta.etag,
855
+ size: meta.contentLength,
856
+ mtime: meta.lastModified,
857
+ });
858
+ }
859
+
860
+ /** Shared response-building context resolved once per request. */
861
+ interface ResponseContext {
862
+ readonly mime: string;
863
+ readonly disposition: string;
864
+ readonly extraHeaders: Record<string, string>;
865
+ readonly cacheControl: string;
866
+ readonly crossOriginResourcePolicy?: string;
867
+ readonly timingAllowOrigin?: string;
868
+ readonly enforceCharset: boolean;
869
+ /** RFC 9530 Section 4: whether the client's Want-* headers accept sha-256. */
870
+ readonly digestWanted: boolean;
871
+ }
872
+
873
+ /** Protocol metadata for building response headers. */
874
+ interface ProtocolMeta {
875
+ /** Full size, or `undefined` for an unknown-total partial (`bytes a-b/*`). */
876
+ totalSize: number | undefined;
877
+ range?: ParsedRange;
878
+ etag?: string;
879
+ lastModified?: string;
880
+ digest?: string;
881
+ serverTiming?: string;
882
+ }
883
+
884
+ /**
885
+ * Locked-down headers for bodyless denial responses (304 excepted, which
886
+ * carries only validators). A `default-src 'none'` CSP plus `nosniff` so a
887
+ * 412/416 body can never be sniffed or execute anything, and `no-store` so a
888
+ * transient denial is never cached (a 416 in particular advertises
889
+ * `Accept-Ranges: bytes` per RFC 7233 and must not linger in a shared cache).
890
+ * Spread onto the protocol headers the kernel produced for the status.
891
+ */
892
+ const DENY_HEADERS = {
893
+ "Content-Security-Policy": "default-src 'none'",
894
+ "X-Content-Type-Options": "nosniff",
895
+ "Cache-Control": "no-store",
896
+ } as const;
897
+
898
+ /**
899
+ * Append `; charset=utf-8` to a textual Content-Type when charset enforcement
900
+ * is on and none is present. Shared by the single-range/200 path and each
901
+ * multipart part so their Content-Type (and thus the precomputed
902
+ * Content-Length) is derived identically.
903
+ */
904
+ function withCharset(mime: string, ctx: ResponseContext): string {
905
+ return ctx.enforceCharset && isTextualMime(mime) && !mime.includes("charset")
906
+ ? `${mime}; charset=utf-8`
907
+ : mime;
908
+ }
909
+
910
+ /**
911
+ * Layer the adapter's success-response header tail (Content-Disposition,
912
+ * nosniff, caller `extraHeaders`, CORP, TAO) onto a protocol header base.
913
+ * Shared by the single-range/200 and multipart paths so a future security
914
+ * header can never be added to one and silently forgotten on the other.
915
+ */
916
+ function applyAdapterHeaders(base: Record<string, string>, ctx: ResponseContext): Record<string, string> {
917
+ const headers: Record<string, string> = {
918
+ ...base,
919
+ "Content-Disposition": ctx.disposition,
920
+ "X-Content-Type-Options": "nosniff",
921
+ ...ctx.extraHeaders,
922
+ };
923
+ if (ctx.crossOriginResourcePolicy) headers["Cross-Origin-Resource-Policy"] = ctx.crossOriginResourcePolicy;
924
+ if (ctx.timingAllowOrigin) headers["Timing-Allow-Origin"] = ctx.timingAllowOrigin;
925
+ return headers;
926
+ }
927
+
928
+ /**
929
+ * Build response headers by composing the kernel's protocol headers with
930
+ * adapter-specific extras.
931
+ */
932
+ function buildHeaders(
933
+ ctx: ResponseContext,
934
+ meta: ProtocolMeta,
935
+ ): Record<string, string> {
936
+ const { headers: protocol } = buildRangeResponseHeaders({
937
+ totalSize: meta.totalSize,
938
+ range: meta.range ?? null,
939
+ contentType: withCharset(ctx.mime, ctx),
940
+ etag: meta.etag,
941
+ lastModified: meta.lastModified,
942
+ // RFC 9530 Section 4: respect Want-Repr-Digest negotiation. Weight 0 or
943
+ // an algorithm list without sha-256 means "do not send"; honoring that
944
+ // here keeps adapter responses consistent with the kernel orchestrator.
945
+ digest: ctx.digestWanted ? meta.digest : undefined,
946
+ cacheControl: ctx.cacheControl,
947
+ });
948
+
949
+ const headers = applyAdapterHeaders(protocol, ctx);
950
+ if (meta.serverTiming) headers["Server-Timing"] = meta.serverTiming;
951
+ return headers;
952
+ }
953
+
954
+ /** Build a HEAD-only response (no body). */
955
+ function buildHeadResponse(meta: ObjectMetadata, etag: string | undefined, ctx: ResponseContext): RawResponseParts {
956
+ return {
957
+ status: 200,
958
+ statusText: "OK",
959
+ headers: buildHeaders(ctx, {
960
+ totalSize: meta.contentLength,
961
+ etag,
962
+ lastModified: meta.lastModified,
963
+ digest: meta.digest,
964
+ }),
965
+ body: null,
966
+ };
967
+ }
968
+
969
+ /** Timing context passed to streamFromStore when timing is enabled. */
970
+ interface TimingContext {
971
+ storeMs: number;
972
+ evaluateMs: number;
973
+ onTiming?: (metrics: { storeMs: number; evaluateMs: number; totalMs: number }) => void;
974
+ }
975
+
976
+ /** Audit context passed to streamFromStore when onServe is configured. */
977
+ interface AuditContext {
978
+ onServe: (event: ServeAuditEvent) => void;
979
+ mime: string;
980
+ }
981
+
982
+ /** Options for streamFromStore. */
983
+ interface StreamOpts {
984
+ store: ObjectStore;
985
+ key: string;
986
+ range?: { start: number; end: number };
987
+ /** Opaque adapter pin token from the HEAD metadata (GetObjectOptions.pin). */
988
+ pin?: string;
989
+ /**
990
+ * Raw backend ETag to pin the read to (GetObjectOptions.ifMatch). When the
991
+ * store supports it and the object changed, getObject throws
992
+ * ObjectChangedError, which propagates to the caller for re-validation.
993
+ */
994
+ ifMatch?: string;
995
+ headEtag?: string;
996
+ headLastModified?: string;
997
+ reprDigest?: string;
998
+ ctx: ResponseContext;
999
+ signal?: AbortSignal;
1000
+ onError?: (error: unknown, context: { key: string; operation: "head" | "get" }) => void;
1001
+ timingCtx?: TimingContext;
1002
+ auditCtx?: AuditContext;
1003
+ /**
1004
+ * Guarded transfer-completion hook. When present, the served body is routed
1005
+ * through a counting stream that fires this once on terminal state with the
1006
+ * true bytes transferred.
1007
+ */
1008
+ onTransfer?: (event: TransferEvent) => void;
1009
+ }
1010
+
1011
+ /**
1012
+ * Stream bytes from the store, building a proper 200/206 response.
1013
+ *
1014
+ * Maps store failures to responses internally, with one exception:
1015
+ * ObjectChangedError (a pinned read losing its race) is rethrown so the
1016
+ * caller can re-validate against the object's new state.
1017
+ */
1018
+ async function streamFromStore(opts: StreamOpts): Promise<RawResponseParts> {
1019
+ const {
1020
+ store, key, range, ifMatch, pin, headEtag, headLastModified, reprDigest,
1021
+ ctx, signal, onError, timingCtx, auditCtx, onTransfer,
1022
+ } = opts;
1023
+
1024
+ const t0 = timingCtx ? performance.now() : 0;
1025
+ let result: Awaited<ReturnType<ObjectStore["getObject"]>>;
1026
+ try {
1027
+ result = await store.getObject(key, { range, signal, ifMatch, pin });
1028
+ } catch (err) {
1029
+ if (isObjectChangedError(err)) throw err;
1030
+ if (isAbortError(err)) return clientClosed();
1031
+ onError?.(err, { key, operation: "get" });
1032
+ return storeErrorResponse(err);
1033
+ }
1034
+ const getMs = timingCtx ? performance.now() - t0 : 0;
1035
+
1036
+ let serverTiming: string | undefined;
1037
+ if (timingCtx) {
1038
+ const totalMs = (timingCtx.storeMs + timingCtx.evaluateMs + getMs);
1039
+ serverTiming = `store;dur=${(timingCtx.storeMs + getMs).toFixed(1)},eval;dur=${timingCtx.evaluateMs.toFixed(1)}`;
1040
+ timingCtx.onTiming?.({
1041
+ storeMs: timingCtx.storeMs + getMs,
1042
+ evaluateMs: timingCtx.evaluateMs,
1043
+ totalMs,
1044
+ });
1045
+ }
1046
+
1047
+ // Derive the response ETag from the GET result itself: strong from the
1048
+ // backend hash when present, weak from size + mtime otherwise. This keeps
1049
+ // validators consistent between Path A (HEAD-derived) and Path C (GET-only),
1050
+ // so plain 200s from hash-less stores (fs) still carry a revalidator.
1051
+ const finalEtag = generateETag({
1052
+ hash: result.etag,
1053
+ size: result.totalSize,
1054
+ mtime: result.lastModified,
1055
+ }) ?? headEtag;
1056
+ const finalLastModified = result.lastModified ?? headLastModified;
1057
+ const finalDigest = result.digest ?? reprDigest;
1058
+
1059
+ // TOCTOU guard: the range the backend ACTUALLY served is the source of
1060
+ // truth for 206 vs 200 AND for the emitted byte bounds. If a range was
1061
+ // requested but the GET result carries none, the store served full
1062
+ // content: emit 200, never a lying 206. Incoherent bounds (a custom-store
1063
+ // bug) cannot be trusted -- emitting them would corrupt client caches,
1064
+ // so fail loudly instead.
1065
+ const actualRange = range ? result.range ?? null : null;
1066
+ if (actualRange && !isServableRange(actualRange, result.totalSize)) {
1067
+ const err = new Error(
1068
+ `Store returned invalid served range for ${key}: ` +
1069
+ `${actualRange.start}-${actualRange.end}/${result.totalSize}`,
1070
+ );
1071
+ onError?.(err, { key, operation: "get" });
1072
+ // The body was never handed to a response: cancel a stream form or the
1073
+ // backing resource (fs file handle, pooled HTTP socket) stays open
1074
+ // until GC. Byte bodies hold nothing.
1075
+ cancelBody(result.body);
1076
+ return storeErrorResponse(err);
1077
+ }
1078
+ // Byte-count coherence: the emitted Content-Length is derived from the range
1079
+ // span (206) or totalSize (200), but the body streams result.contentLength
1080
+ // bytes. A store that reports a contentLength disagreeing with those would
1081
+ // commit a Content-Length that over- or under-runs the body -- a truncated
1082
+ // response the client cannot distinguish from a complete one. The multipart
1083
+ // path enforces this per part (servedSpanMatches); the hotter single-range
1084
+ // and 200 paths must guarantee it too. (A 200 with undefined totalSize is an
1085
+ // adapter bug the header builder already rejects, so only the defined case
1086
+ // is checked here.)
1087
+ const incoherentByteCount = actualRange
1088
+ ? result.contentLength !== actualRange.end - actualRange.start + 1
1089
+ : result.totalSize !== undefined && result.contentLength !== result.totalSize;
1090
+ if (incoherentByteCount) {
1091
+ const err = new Error(
1092
+ `Store returned incoherent byte count for ${key}: contentLength=${result.contentLength} ` +
1093
+ (actualRange ? `range=${actualRange.start}-${actualRange.end}` : `totalSize=${result.totalSize}`),
1094
+ );
1095
+ onError?.(err, { key, operation: "get" });
1096
+ cancelBody(result.body);
1097
+ return storeErrorResponse(err);
1098
+ }
1099
+ const isPartial = actualRange !== null;
1100
+ const status = isPartial ? 206 : 200;
1101
+ const responseRange = actualRange ?? undefined;
1102
+ const totalSize = result.totalSize;
1103
+
1104
+ // Between here and the returned parts the body has an owner only on the
1105
+ // happy path: a throw from the audit hook or header builder would
1106
+ // otherwise leak a stream form.
1107
+ try {
1108
+ const headers = buildHeaders(ctx, {
1109
+ totalSize,
1110
+ range: responseRange,
1111
+ etag: finalEtag,
1112
+ lastModified: finalLastModified,
1113
+ digest: finalDigest,
1114
+ serverTiming,
1115
+ });
1116
+
1117
+ // Audit AFTER the headers commit, never before. onServe is the "grant"
1118
+ // event, so it must not fire for a response that never materializes: if
1119
+ // buildHeaders throws on corrupt metadata this streamFromStore call is
1120
+ // discarded (Path A re-runs, or a 502 is returned), and an onServe fired
1121
+ // up front would double-count the retry or log a phantom grant on a 502.
1122
+ if (auditCtx) {
1123
+ // streamFromStore only runs for GET: HEAD returns early via
1124
+ // buildHeadResponse, and Path C is unreachable when isHead forces needsHead.
1125
+ auditCtx.onServe({
1126
+ key, method: "GET", status: status as 200 | 206, mime: auditCtx.mime,
1127
+ bytesServed: result.contentLength, etag: finalEtag,
1128
+ ...(responseRange ? { rangeStart: responseRange.start, rangeEnd: responseRange.end } : {}),
1129
+ });
1130
+ }
1131
+
1132
+ // Metering is opt-in: only wrap the body when a transfer hook is present,
1133
+ // so the common path keeps the runtime's static-body fast path. The wrap
1134
+ // happens AFTER buildHeaders and the audit hook, so the catch below still
1135
+ // cancels the untouched original on any earlier throw (once wrapped, the
1136
+ // source reader is locked and owned by the returned stream).
1137
+ const body = onTransfer
1138
+ ? meterBody(result.body, (bytesTransferred, completed) => onTransfer({
1139
+ key,
1140
+ method: "GET",
1141
+ status: status as 200 | 206,
1142
+ bytesExpected: result.contentLength,
1143
+ bytesTransferred,
1144
+ completed,
1145
+ ...(responseRange ? { rangeStart: responseRange.start, rangeEnd: responseRange.end } : {}),
1146
+ }))
1147
+ : result.body;
1148
+
1149
+ return {
1150
+ status,
1151
+ statusText: STATUS_TEXT[status],
1152
+ headers,
1153
+ body,
1154
+ };
1155
+ } catch (err) {
1156
+ cancelBody(result.body);
1157
+ throw err;
1158
+ }
1159
+ }
1160
+
1161
+ /** Options for {@link serveMultipart}. */
1162
+ interface MultipartOpts {
1163
+ store: ObjectStore;
1164
+ key: string;
1165
+ /** Coalesced, satisfiable ranges (length >= 2), clamped to the object size. */
1166
+ ranges: ParsedRange[];
1167
+ totalSize: number;
1168
+ /** The representation's own MIME (goes in every part's Content-Type). */
1169
+ mime: string;
1170
+ etag?: string;
1171
+ lastModified?: string;
1172
+ digest?: string;
1173
+ ifMatch?: string;
1174
+ pin?: string;
1175
+ ctx: ResponseContext;
1176
+ signal?: AbortSignal;
1177
+ onError?: (error: unknown, context: { key: string; operation: "head" | "get" }) => void;
1178
+ auditCtx?: AuditContext;
1179
+ onTransfer?: (event: TransferEvent) => void;
1180
+ }
1181
+
1182
+ /**
1183
+ * Serve multiple byte ranges as a `multipart/byteranges` (206) response.
1184
+ *
1185
+ * The Content-Length is computed exactly by the kernel from the framing and
1186
+ * the range spans, so the response is never chunked. The first part is fetched
1187
+ * EAGERLY so a pinned-read `ObjectChangedError` surfaces before headers commit
1188
+ * (giving the caller the same one-shot re-validation single-range serving
1189
+ * gets); the remaining parts stream lazily, one `getObject` per range, each
1190
+ * pinned to the same representation via `ifMatch`.
1191
+ *
1192
+ * Relies on each store serving exactly the requested (already size-clamped)
1193
+ * span per range -- which every bundled adapter does -- so the precomputed
1194
+ * Content-Length matches the streamed body byte-for-byte.
1195
+ */
1196
+ async function serveMultipart(opts: MultipartOpts): Promise<RawResponseParts> {
1197
+ const {
1198
+ store, key, ranges, totalSize, mime, etag, lastModified, digest,
1199
+ ifMatch, pin, ctx, signal, onError, auditCtx, onTransfer,
1200
+ } = opts;
1201
+
1202
+ const boundary = generateMultipartBoundary();
1203
+ // Each part carries the representation's own Content-Type; apply the same
1204
+ // charset enforcement single-range responses use so the value (and thus the
1205
+ // precomputed Content-Length) is identical to what the framing emits.
1206
+ const partContentType = withCharset(mime, ctx);
1207
+
1208
+ // Fetch the first part up front: a pinned read losing its race throws
1209
+ // ObjectChangedError HERE, before any headers are committed, so the caller
1210
+ // can re-validate once. Abort and store errors map to responses.
1211
+ let firstStream: Awaited<ReturnType<ObjectStore["getObject"]>>;
1212
+ try {
1213
+ firstStream = await store.getObject(key, { range: ranges[0], signal, ifMatch, pin });
1214
+ } catch (err) {
1215
+ if (isObjectChangedError(err)) throw err;
1216
+ if (isAbortError(err)) return clientClosed();
1217
+ onError?.(err, { key, operation: "get" });
1218
+ return storeErrorResponse(err);
1219
+ }
1220
+
1221
+ // Validate the first part's served span BEFORE headers commit. On a
1222
+ // non-pinning store (one that cannot honor ifMatch/pin), a concurrent
1223
+ // overwrite between parts would otherwise splice bytes across
1224
+ // representations or under-run the precomputed Content-Length. A first-part
1225
+ // mismatch re-validates once via the orchestrator's retry loop.
1226
+ if (!servedSpanMatches(firstStream, ranges[0]!)) {
1227
+ cancelBody(firstStream.body);
1228
+ throw new ObjectChangedError(key);
1229
+ }
1230
+
1231
+ const multipart = buildMultipartHeaders({
1232
+ boundary, ranges, totalSize, contentType: partContentType,
1233
+ etag, lastModified, digest, cacheControl: ctx.cacheControl,
1234
+ });
1235
+
1236
+ // Ownership of the eagerly-fetched first part transfers to the generator the
1237
+ // moment it takes the part's reader (or yields its bytes). Until then, a
1238
+ // consumer cancel would strand firstStream: gen.return() only runs the
1239
+ // reader's finally if control already entered the try. Track the handoff so
1240
+ // the outer cancel can release it in the pre-handoff window (suspendedStart,
1241
+ // or suspended at the part-header yield).
1242
+ let firstPartOwned = false;
1243
+ const enc = UTF8_ENCODER;
1244
+ async function* multipartChunks(): AsyncGenerator<Uint8Array> {
1245
+ for (let i = 0; i < ranges.length; i++) {
1246
+ const range = ranges[i]!;
1247
+ yield enc.encode(buildMultipartPartHeader(boundary, range, totalSize, partContentType));
1248
+ const stream = i === 0
1249
+ ? firstStream
1250
+ : await store.getObject(key, { range, signal, ifMatch, pin });
1251
+ // Lazy parts settle AFTER headers commit, so a mismatch can only be
1252
+ // surfaced as a stream error (a reset). That still beats splicing bytes
1253
+ // from a changed representation or under-running the committed length.
1254
+ // The span check alone would miss a SAME-SIZE overwrite (right byte count,
1255
+ // different bytes); comparing each part's validator against the first
1256
+ // catches that too, since any overwrite changes the fs weak ETag (mtime)
1257
+ // and the S3 strong ETag (content hash). Stores that return no GET
1258
+ // validator fall back to the span check.
1259
+ if (i > 0 && (!servedSpanMatches(stream, range) || !sameRepresentation(firstStream, stream))) {
1260
+ cancelBody(stream.body);
1261
+ throw new ObjectChangedError(key);
1262
+ }
1263
+ const body = stream.body;
1264
+ // Handoff point: cleanup is now guaranteed by the reader's finally below
1265
+ // (stream body) or is unneeded (byte body holds no resource). No await or
1266
+ // yield sits between here and getReader(), so a cancel cannot interleave.
1267
+ if (i === 0) firstPartOwned = true;
1268
+ if (body instanceof Uint8Array) {
1269
+ yield body;
1270
+ } else {
1271
+ const reader = body.getReader();
1272
+ try {
1273
+ for (;;) {
1274
+ const { done, value } = await reader.read();
1275
+ if (done) break;
1276
+ yield value;
1277
+ }
1278
+ } finally {
1279
+ // Release the backend resource on normal completion AND on an early
1280
+ // generator return (client cancelled mid-part).
1281
+ reader.cancel().catch(() => { /* already-settled reader */ });
1282
+ }
1283
+ }
1284
+ yield enc.encode("\r\n");
1285
+ }
1286
+ yield enc.encode(multipartEpilogue(boundary));
1287
+ }
1288
+
1289
+ const gen = multipartChunks();
1290
+ const rawBody = new ReadableStream<Uint8Array<ArrayBuffer>>({
1291
+ async pull(controller) {
1292
+ try {
1293
+ const { done, value } = await gen.next();
1294
+ if (done) controller.close();
1295
+ // Part headers (encoder output) and part bodies are ArrayBuffer-backed;
1296
+ // narrow so the multipart body stays Response-assignable under DOM (F5).
1297
+ else controller.enqueue(value as Uint8Array<ArrayBuffer>);
1298
+ } catch (err) {
1299
+ controller.error(err);
1300
+ }
1301
+ },
1302
+ async cancel(reason) {
1303
+ await gen.return?.(reason as undefined);
1304
+ // Cancelled before the generator took ownership of the eagerly-fetched
1305
+ // first part (suspendedStart, or suspended at the part-header yield):
1306
+ // gen.return ran no finally, so release it here. A byte-body first part
1307
+ // is a no-op in cancelBody; a stream part's file handle/socket is freed.
1308
+ if (!firstPartOwned) cancelBody(firstStream.body);
1309
+ },
1310
+ });
1311
+
1312
+ const headers = applyAdapterHeaders(multipart.headers, ctx);
1313
+
1314
+ // Audit reports the multipart body's granted length (framing + all parts).
1315
+ auditCtx?.onServe({
1316
+ key, method: "GET", status: 206, mime: auditCtx.mime,
1317
+ bytesServed: multipart.contentLength, etag,
1318
+ });
1319
+
1320
+ const body = onTransfer
1321
+ ? meterBody(rawBody, (bytesTransferred, completed) => onTransfer({
1322
+ key, method: "GET", status: 206,
1323
+ bytesExpected: multipart.contentLength, bytesTransferred, completed,
1324
+ }))
1325
+ : rawBody;
1326
+
1327
+ return { status: 206, statusText: STATUS_TEXT[206], headers, body };
1328
+ }
1329
+
1330
+ /**
1331
+ * Wrap a response body in a counting stream that reports the true bytes
1332
+ * transferred exactly once when the body reaches its terminal state.
1333
+ *
1334
+ * `report(bytesTransferred, completed)` fires on:
1335
+ * - full drain (`completed: true`) -- the consumer read every byte,
1336
+ * - cancel / client disconnect (`completed: false`) -- fewer bytes reached
1337
+ * the client than the Content-Length promised,
1338
+ * - source error (`completed: false`) -- a mid-transfer backend failure.
1339
+ *
1340
+ * A byte body is wrapped into a one-shot stream so metering is uniform across
1341
+ * stream and byte stores (the caller only pays this when a transfer hook is
1342
+ * registered; unmetered byte bodies keep the static-body fast path). The
1343
+ * `settled` latch guarantees the report fires once even if the consumer
1344
+ * cancels after the stream already closed.
1345
+ */
1346
+ function meterBody(
1347
+ source: ReadableStream<Uint8Array> | Uint8Array,
1348
+ report: (bytesTransferred: number, completed: boolean) => void,
1349
+ ): ReadableStream<Uint8Array<ArrayBuffer>> {
1350
+ let transferred = 0;
1351
+ let settled = false;
1352
+ const settle = (completed: boolean): void => {
1353
+ if (settled) return;
1354
+ settled = true;
1355
+ report(transferred, completed);
1356
+ };
1357
+
1358
+ // Normalize a byte body to a one-shot stream and meter it through the SAME
1359
+ // reader path, so `completed:true` fires only when the consumer has pulled
1360
+ // the terminal (done) result -- i.e. after it read the last chunk -- never
1361
+ // merely because the chunk was buffered ahead of a read. A dedicated
1362
+ // byte-path that settled inside its enqueue would over-report a disconnect
1363
+ // that lands after the chunk was queued but before it was consumed.
1364
+ const stream = source instanceof Uint8Array
1365
+ ? new ReadableStream<Uint8Array>({
1366
+ start(controller) {
1367
+ if (source.byteLength > 0) controller.enqueue(source);
1368
+ controller.close();
1369
+ },
1370
+ })
1371
+ : source;
1372
+
1373
+ const reader = stream.getReader();
1374
+ return new ReadableStream<Uint8Array<ArrayBuffer>>({
1375
+ async pull(controller) {
1376
+ let res: Awaited<ReturnType<typeof reader.read>>;
1377
+ try {
1378
+ res = await reader.read();
1379
+ } catch (err) {
1380
+ settle(false);
1381
+ controller.error(err);
1382
+ return;
1383
+ }
1384
+ if (res.done) {
1385
+ settle(true);
1386
+ controller.close();
1387
+ } else {
1388
+ transferred += res.value.byteLength;
1389
+ // Backend byte chunks are ArrayBuffer-backed; narrow so the metered
1390
+ // body stays `new Response(...)`-assignable under DOM lib (F5).
1391
+ controller.enqueue(res.value as Uint8Array<ArrayBuffer>);
1392
+ }
1393
+ },
1394
+ async cancel(reason) {
1395
+ settle(false);
1396
+ await reader.cancel(reason);
1397
+ },
1398
+ });
1399
+ }
1400
+
1401
+ /**
1402
+ * True when a store served EXACTLY the requested span: same inclusive bounds
1403
+ * and matching byte count. A mismatch means the object changed under a
1404
+ * non-pinning store (or the store's accounting is broken), so the committed
1405
+ * multipart framing and Content-Length no longer describe the bytes.
1406
+ */
1407
+ function servedSpanMatches(
1408
+ stream: { range?: { start: number; end: number }; contentLength: number },
1409
+ range: ParsedRange,
1410
+ ): boolean {
1411
+ const served = stream.range;
1412
+ return !!served
1413
+ && served.start === range.start
1414
+ && served.end === range.end
1415
+ && stream.contentLength === range.end - range.start + 1;
1416
+ }
1417
+
1418
+ /**
1419
+ * True when two parts of a multipart response came from the same
1420
+ * representation, judged by the strongest validator both expose. Any overwrite
1421
+ * changes the fs weak ETag (mtime) and the S3 strong ETag (content hash), so an
1422
+ * ETag disagreement means the object changed mid-stream. When a store returns
1423
+ * no GET ETag (or no Last-Modified) on one side, there is nothing to compare
1424
+ * and the caller's span check is the only guard -- so this returns `true` and
1425
+ * does not manufacture a mismatch from missing metadata.
1426
+ */
1427
+ function sameRepresentation(
1428
+ a: { etag?: string; lastModified?: string },
1429
+ b: { etag?: string; lastModified?: string },
1430
+ ): boolean {
1431
+ if (a.etag && b.etag) return a.etag === b.etag;
1432
+ if (a.lastModified && b.lastModified) return a.lastModified === b.lastModified;
1433
+ return true;
1434
+ }
1435
+
1436
+ /** Release a body that will never reach a response (stream forms only). */
1437
+ function cancelBody(body: ReadableStream<Uint8Array> | Uint8Array): void {
1438
+ if (!(body instanceof ReadableStream)) return;
1439
+ try {
1440
+ // A locked stream (a reader was taken) throws SYNCHRONOUSLY here rather
1441
+ // than rejecting, so .catch alone would let it escape; an already-errored
1442
+ // stream rejects. Swallow both -- teardown is best-effort by definition.
1443
+ body.cancel().catch(() => { /* already-errored streams reject cancel */ });
1444
+ } catch { /* locked: the reader owns teardown */ }
1445
+ }
1446
+
1447
+ /** Bodyless 499 parts, built per return (headers object is caller-owned). */
1448
+ function clientClosed(): RawResponseParts {
1449
+ return {
1450
+ status: 499,
1451
+ statusText: "Client Closed Request",
1452
+ headers: { "Content-Length": "0" },
1453
+ body: null,
1454
+ };
1455
+ }
1456
+
1457
+ // ─── Error Helpers ──────────────────────────────────────────────────────────
1458
+
1459
+ /**
1460
+ * Validate a store-reported served range against the representation size:
1461
+ * inclusive integer bounds, ordered, and inside the object. Anything else
1462
+ * means the store's byte accounting is broken.
1463
+ *
1464
+ * When `totalSize` is `undefined` the backend served an unknown-total partial
1465
+ * (`bytes a-b/*`): there is no EOF to bound-check against, so the ordered
1466
+ * bounds the authoritative backend reported are trusted as-is.
1467
+ */
1468
+ function isServableRange(r: { start: number; end: number }, totalSize: number | undefined): boolean {
1469
+ if (!Number.isSafeInteger(r.start) || !Number.isSafeInteger(r.end)) return false;
1470
+ if (r.start < 0 || r.start > r.end) return false;
1471
+ if (totalSize === undefined) return true;
1472
+ return Number.isSafeInteger(totalSize) && r.end < totalSize;
1473
+ }
1474
+
1475
+ /**
1476
+ * Check if an error is an AbortError (client disconnected).
1477
+ * Works across runtimes: DOMException in browsers/Workers, AbortError in Node.
1478
+ */
1479
+ function isAbortError(err: unknown): boolean {
1480
+ if (err instanceof DOMException && err.name === "AbortError") return true;
1481
+ if (err instanceof Error && err.name === "AbortError") return true;
1482
+ return false;
1483
+ }
1484
+
1485
+ /**
1486
+ * Check if an error is a pinned-read ObjectChangedError.
1487
+ *
1488
+ * Matched by name rather than instanceof so third-party stores can throw
1489
+ * their own equivalently-named error without importing the kernel class.
1490
+ */
1491
+ function isObjectChangedError(err: unknown): boolean {
1492
+ return err instanceof Error && err.name === "ObjectChangedError";
1493
+ }
1494
+
1495
+ /**
1496
+ * Build a plain-text error response with a computed Content-Length.
1497
+ *
1498
+ * The body is encoded once and Content-Length comes from the encoded byte
1499
+ * count, so the header stays truthful even if a message ever gains
1500
+ * non-ASCII characters (String.length counts UTF-16 units, not bytes).
1501
+ */
1502
+ function plainTextError(
1503
+ status: number,
1504
+ statusText: string,
1505
+ body: string,
1506
+ extraHeaders?: Record<string, string>,
1507
+ ): RawResponseParts {
1508
+ const bytes = UTF8_ENCODER.encode(body);
1509
+ return {
1510
+ status,
1511
+ statusText,
1512
+ headers: {
1513
+ "Content-Type": "text/plain; charset=utf-8",
1514
+ "Content-Length": String(bytes.byteLength),
1515
+ "Accept-Ranges": "none",
1516
+ // 404 is heuristically cacheable (RFC 9111 Section 4.2.2): without
1517
+ // this, a CDN can cache a transient miss and keep serving it after
1518
+ // the object appears. Errors must never outlive their cause.
1519
+ "Cache-Control": "no-store",
1520
+ "X-Content-Type-Options": "nosniff",
1521
+ "Content-Security-Policy": "default-src 'none'",
1522
+ ...extraHeaders,
1523
+ },
1524
+ body: bytes,
1525
+ };
1526
+ }
1527
+
1528
+ /**
1529
+ * Build an error response for store failures.
1530
+ *
1531
+ * Discriminates three cases: "object not found" (404), "backend transiently
1532
+ * unavailable" (503, retryable), and everything else (502, the upstream
1533
+ * returned something invalid). A 404 hint comes from a `status` of 404 or an
1534
+ * error named `ObjectNotFoundError`/`NotFound`; a 503 hint from a `status` of
1535
+ * 503 or an error named `StoreUnavailableError`, which additionally carries an
1536
+ * optional `retryAfterSeconds` echoed as `Retry-After`.
1537
+ */
1538
+ function storeErrorResponse(err: unknown): RawResponseParts {
1539
+ if (isNotFoundStoreError(err)) {
1540
+ return plainTextError(404, "Not Found", "Not Found");
1541
+ }
1542
+ if (isUnavailableStoreError(err)) {
1543
+ const secs = retryAfterSeconds(err);
1544
+ return plainTextError(
1545
+ 503,
1546
+ "Service Unavailable",
1547
+ "Storage backend unavailable",
1548
+ secs !== undefined ? { "Retry-After": String(secs) } : undefined,
1549
+ );
1550
+ }
1551
+ return plainTextError(502, "Bad Gateway", "Storage backend error");
1552
+ }
1553
+
1554
+ /**
1555
+ * Check if a store error represents a missing object (404).
1556
+ */
1557
+ function isNotFoundStoreError(err: unknown): boolean {
1558
+ if (typeof err === "object" && err !== null && "status" in err) {
1559
+ return (err as { status: unknown }).status === 404;
1560
+ }
1561
+ if (err instanceof Error) {
1562
+ return err.name === "ObjectNotFoundError" || err.name === "NotFound";
1563
+ }
1564
+ return false;
1565
+ }
1566
+
1567
+ /**
1568
+ * Check if a store error signals a transient, retryable backend condition
1569
+ * (throttling/overload/timeout). Matched by `status` 503 or by name so a
1570
+ * third-party store can throw an equivalently-named error without importing
1571
+ * the kernel class.
1572
+ */
1573
+ function isUnavailableStoreError(err: unknown): boolean {
1574
+ if (typeof err === "object" && err !== null && "status" in err) {
1575
+ if ((err as { status: unknown }).status === 503) return true;
1576
+ }
1577
+ return err instanceof Error && err.name === "StoreUnavailableError";
1578
+ }
1579
+
1580
+ /**
1581
+ * Extract a non-negative integer `Retry-After` (delay-seconds, RFC 9110
1582
+ * Section 10.2.3) from a store error's `retryAfterSeconds`, or `undefined`
1583
+ * when absent or malformed. Delegates to the shared parser: fractional hints
1584
+ * are floored, 0 is kept, and a huge finite hint that would serialize as
1585
+ * `1e+21` (a duck-typed third-party error that skipped the StoreUnavailableError
1586
+ * constructor's normalization) is rejected rather than emitted as a malformed
1587
+ * header.
1588
+ */
1589
+ function retryAfterSeconds(err: unknown): number | undefined {
1590
+ if (typeof err !== "object" || err === null || !("retryAfterSeconds" in err)) return undefined;
1591
+ return parseRetryAfterSeconds((err as { retryAfterSeconds: unknown }).retryAfterSeconds);
1592
+ }
1593
+
1594
+ /**
1595
+ * Check if a MIME type is textual and should have charset=utf-8 enforced.
1596
+ */
1597
+ function isTextualMime(mime: string): boolean {
1598
+ if (mime.startsWith("text/")) return true;
1599
+ if (mime === "application/json" || mime === "application/xml") return true;
1600
+ if (mime.endsWith("+json") || mime.endsWith("+xml")) return true;
1601
+ if (mime === "application/javascript" || mime === "application/ecmascript") return true;
1602
+ return false;
1603
+ }