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
@@ -0,0 +1,665 @@
1
+ /**
2
+ * Storage backend contract for partial-content adapters.
3
+ *
4
+ * Implementations adapt a concrete store (S3, R2, Hetzner, GCS, fs) down to
5
+ * this surface. The kernel and framework adapters depend only on these types,
6
+ * never on a storage SDK.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+ import { parseContentRange, type ParsedRange } from "./kernel.js";
11
+
12
+ // ─── Cancel Signal ──────────────────────────────────────────────────────────
13
+
14
+ /**
15
+ * Structural type matching the standard `AbortSignal` interface.
16
+ *
17
+ * Using a structural type instead of the global `AbortSignal` keeps the kernel
18
+ * free of DOM/lib dependencies. At call sites, `req.signal` (which is a real
19
+ * `AbortSignal`) satisfies this interface automatically.
20
+ */
21
+ export interface CancelSignal {
22
+ readonly aborted: boolean;
23
+ throwIfAborted(): void;
24
+ addEventListener(type: "abort", listener: () => void): void;
25
+ removeEventListener(type: "abort", listener: () => void): void;
26
+ }
27
+
28
+ // ─── Error Types ────────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Thrown when an object does not exist in a storage backend.
32
+ *
33
+ * All built-in adapters (S3, R2, GCS, Azure, fs) throw this error
34
+ * for missing objects. Framework adapters (e.g. `partial-content/web`)
35
+ * use the `status` property to distinguish "object not found" (404)
36
+ * from a transiently unavailable backend (503, {@link StoreUnavailableError})
37
+ * and other store failures (502).
38
+ */
39
+ export class ObjectNotFoundError extends Error {
40
+ /** HTTP status hint for downstream handlers. */
41
+ readonly status = 404 as const;
42
+ /** The storage key that was not found. */
43
+ readonly key: string;
44
+
45
+ constructor(key: string, cause?: unknown) {
46
+ super(`Object not found: ${key}`);
47
+ this.name = "ObjectNotFoundError";
48
+ this.key = key;
49
+ if (cause !== undefined) this.cause = cause;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Thrown when a pinned read ({@link GetObjectOptions.ifMatch}) finds the
55
+ * object changed since its validator was captured.
56
+ *
57
+ * Adapters map their backend's native rejection to this error (S3 412
58
+ * PreconditionFailed, R2 `onlyIf` body-less response, Azure 412
59
+ * ConditionNotMet, GCS etag mismatch). The web adapter treats it as a
60
+ * signal to re-validate the request against the object's new state.
61
+ */
62
+ export class ObjectChangedError extends Error {
63
+ /** HTTP status hint: the pinned read's precondition failed. */
64
+ readonly status = 412 as const;
65
+ /** The storage key whose object changed. */
66
+ readonly key: string;
67
+
68
+ constructor(key: string, cause?: unknown) {
69
+ super(`Object changed since validation: ${key}`);
70
+ this.name = "ObjectChangedError";
71
+ this.key = key;
72
+ if (cause !== undefined) this.cause = cause;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Thrown when a storage backend is transiently unavailable: overloaded,
78
+ * throttling, or timing out after the adapter's own retries are exhausted.
79
+ *
80
+ * A generic store failure (a malformed response, an unparseable
81
+ * `Content-Range`, an empty body) is reported by the framework adapter as
82
+ * `502 Bad Gateway` -- "the upstream returned something invalid." This error
83
+ * is the distinct RETRYABLE case: the upstream is healthy but momentarily
84
+ * cannot serve. The web adapter maps it to `503 Service Unavailable` and, when
85
+ * {@link retryAfterSeconds} is set, emits a `Retry-After` header so clients and
86
+ * shared caches back off instead of hammering an origin that is already shedding
87
+ * load. Adapters map their backend's throttle signals here (S3/R2/Hetzner
88
+ * `503 SlowDown`, `429 TooManyRequests`, request timeouts).
89
+ */
90
+ export class StoreUnavailableError extends Error {
91
+ /** HTTP status hint: the backend is transiently unavailable. */
92
+ readonly status = 503 as const;
93
+ /** The storage key whose read failed. */
94
+ readonly key: string;
95
+ /**
96
+ * Suggested back-off in whole seconds, echoed as `Retry-After`. Normalized at
97
+ * construction: a non-negative finite hint is floored to an integer (a
98
+ * fractional `2.9` -> `2`), and a NaN/negative/infinite/out-of-safe-range hint
99
+ * (from a hostile backend header or a buggy third-party classifier) is dropped
100
+ * entirely. So every consumer -- the web adapter AND anyone reading
101
+ * `.retryAfterSeconds` directly off the frozen contract -- sees a clean
102
+ * non-negative integer or `undefined`. Absent means the adapter emits `503`
103
+ * without a `Retry-After` (RFC 9110 Section 15.6.4 permits its absence).
104
+ */
105
+ readonly retryAfterSeconds?: number;
106
+
107
+ constructor(key: string, opts?: { retryAfterSeconds?: number; cause?: unknown }) {
108
+ super(`Storage backend unavailable: ${key}`);
109
+ this.name = "StoreUnavailableError";
110
+ this.key = key;
111
+ const hint = parseRetryAfterSeconds(opts?.retryAfterSeconds);
112
+ if (hint !== undefined) this.retryAfterSeconds = hint;
113
+ if (opts?.cause !== undefined) this.cause = opts.cause;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Normalize a `Retry-After` value to whole non-negative seconds, or `undefined`.
119
+ *
120
+ * The single sanctioned parser shared by every adapter and the web layer.
121
+ * Accepts the delay-seconds form (a number, or a numeric string) and -- when
122
+ * `allowHttpDate` is set -- the HTTP-date form (RFC 9110 Section 10.2.3) as a
123
+ * non-negative delta from now. Everything else yields `undefined`: NaN,
124
+ * Infinity, negatives, and any value whose floor exceeds `Number.MAX_SAFE_INTEGER`
125
+ * (so a huge finite hint can never serialize as `1e+21`, which violates the
126
+ * `delay-seconds = DIGIT+` grammar), plus non-numeric text. A hostile header or
127
+ * a buggy third-party classifier therefore can never emit a malformed header.
128
+ */
129
+ export function parseRetryAfterSeconds(
130
+ raw: unknown,
131
+ opts?: { allowHttpDate?: boolean },
132
+ ): number | undefined {
133
+ if (typeof raw === "number") {
134
+ if (!Number.isFinite(raw) || raw < 0) return undefined;
135
+ const floored = Math.floor(raw);
136
+ return Number.isSafeInteger(floored) ? floored : undefined;
137
+ }
138
+ if (typeof raw !== "string") return undefined;
139
+ const trimmed = raw.trim();
140
+ if (/^\d+$/.test(trimmed)) {
141
+ const n = Number(trimmed);
142
+ return Number.isSafeInteger(n) ? n : undefined;
143
+ }
144
+ if (opts?.allowHttpDate) {
145
+ const when = Date.parse(trimmed);
146
+ if (Number.isNaN(when)) return undefined;
147
+ return Math.max(0, Math.round((when - Date.now()) / 1000));
148
+ }
149
+ return undefined;
150
+ }
151
+
152
+ // ─── Error Classification ─────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * Per-backend predicates that map a thrown SDK error to the contract's error
156
+ * types. An adapter supplies one set and reuses it for both `headObject` and
157
+ * `getObject`: a HEAD without a conditional never produces a `changed` (412),
158
+ * so sharing the set costs nothing and keeps the two paths provably symmetric.
159
+ */
160
+ export interface StoreErrorClassifiers {
161
+ /** The object does not exist (maps to {@link ObjectNotFoundError}, 404). */
162
+ notFound(err: unknown): boolean;
163
+ /**
164
+ * A pinned read's precondition failed (maps to {@link ObjectChangedError},
165
+ * 412). Omit for backends whose pin is an etag/metadata comparison rather
166
+ * than a native conditional error (e.g. GCS).
167
+ */
168
+ changed?(err: unknown): boolean;
169
+ /**
170
+ * Transient throttle/overload (maps to {@link StoreUnavailableError}, 503).
171
+ * Return `true` for a throttle with no back-off hint, or
172
+ * `{ retryAfterSeconds }` to surface a backend's `Retry-After` so the 503
173
+ * echoes it and shared caches back off for the advised interval. Return
174
+ * `false` when the error is not a throttle.
175
+ */
176
+ throttled(err: unknown): boolean | { retryAfterSeconds: number };
177
+ }
178
+
179
+ /**
180
+ * Run a backend read and normalize its failures to the contract's error types.
181
+ *
182
+ * This is the single ordered classification pipeline every SDK-backed adapter
183
+ * shares: `notFound` -> `changed` -> `throttled` -> rethrow. Centralizing it
184
+ * means the "which errors are handled, in what order" contract is structural
185
+ * rather than copy-pasted into each adapter's head/get catch blocks, so a
186
+ * classifier can never be present on one path and silently missing on another.
187
+ * The predicates MUST be mutually exclusive on a given backend (a 404, 412, and
188
+ * 429/503 are distinct); the order only decides precedence if they are not.
189
+ *
190
+ * @example
191
+ * ```typescript
192
+ * const meta = await classifyStoreRead(key, () => client.headObject(key), {
193
+ * notFound: isNotFound,
194
+ * changed: isPreconditionFailed,
195
+ * throttled: isThrottled,
196
+ * });
197
+ * ```
198
+ */
199
+ export async function classifyStoreRead<T>(
200
+ key: string,
201
+ op: () => Promise<T>,
202
+ classifiers: StoreErrorClassifiers,
203
+ ): Promise<T> {
204
+ try {
205
+ return await op();
206
+ } catch (err) {
207
+ if (classifiers.notFound(err)) throw new ObjectNotFoundError(key, err);
208
+ if (classifiers.changed?.(err)) throw new ObjectChangedError(key, err);
209
+ const throttled = classifiers.throttled(err);
210
+ if (throttled) {
211
+ throw new StoreUnavailableError(key, {
212
+ cause: err,
213
+ // `throttled` may return the backend's advised back-off; a bare `true`
214
+ // means "throttled, no hint" and emits a 503 without `Retry-After`.
215
+ retryAfterSeconds: typeof throttled === "object" ? throttled.retryAfterSeconds : undefined,
216
+ });
217
+ }
218
+ throw err;
219
+ }
220
+ }
221
+
222
+ // ─── Read Options ───────────────────────────────────────────────────────────
223
+
224
+ /** Options for `headObject`. */
225
+ export interface HeadObjectOptions {
226
+ /**
227
+ * Cancel signal. Pass `req.signal` to cancel the backend request when the
228
+ * client disconnects.
229
+ */
230
+ signal?: CancelSignal;
231
+ }
232
+
233
+ /**
234
+ * Options for `getObject`.
235
+ *
236
+ * `ifMatch` pins the read to the representation whose validator was captured
237
+ * by a prior `headObject` -- pass the RAW backend ETag from
238
+ * {@link ObjectMetadata.etag}, not a derived/formatted one, and only a
239
+ * STRONG validator (never `W/`-prefixed). Adapters map it to their backend's
240
+ * native conditional read (S3 `IfMatch`, R2 `onlyIf.etagMatches`, Azure
241
+ * `conditions.ifMatch`, GCS etag + generation pinning), making the HEAD->GET
242
+ * pair atomic: either the exact validated bytes are streamed, or the adapter
243
+ * throws {@link ObjectChangedError}.
244
+ */
245
+ export interface GetObjectOptions {
246
+ /** Validated byte range to stream. Omit for full content. */
247
+ range?: ParsedRange;
248
+ /**
249
+ * Cancel signal. Cancels the backend stream when the client disconnects,
250
+ * preventing orphaned TCP connections.
251
+ */
252
+ signal?: CancelSignal;
253
+ /** Raw backend ETag the object must still match (strong validators only). */
254
+ ifMatch?: string;
255
+ /**
256
+ * Opaque pin token from a prior `headObject` on the same key
257
+ * ({@link ObjectMetadata.pin}), passed back verbatim. Adapters that issue
258
+ * pins use it to stream the exact validated representation without
259
+ * re-fetching metadata; when the pinned version no longer exists they
260
+ * throw {@link ObjectChangedError}. Adapters that never issue pins
261
+ * ignore it.
262
+ */
263
+ pin?: string;
264
+ }
265
+
266
+ // ─── Node Stream Conversion ─────────────────────────────────────────────────
267
+
268
+ /**
269
+ * Convert a Node.js readable stream (async iterable) to a web ReadableStream
270
+ * with proper backpressure, Buffer coercion, signal propagation, and cleanup.
271
+ *
272
+ * This is the single implementation shared by all Node.js-based adapters
273
+ * (fs, GCS, Azure, S3 fallback). It handles:
274
+ *
275
+ * - **Pull-based backpressure**: the web ReadableStream's `pull()` method
276
+ * drives the async iterator, so chunks are only read when the consumer
277
+ * is ready.
278
+ * - **Buffer to Uint8Array coercion**: Node.js streams yield `Buffer`
279
+ * instances. We coerce to `Uint8Array` for cross-runtime compatibility
280
+ * (Buffer extends Uint8Array in Node, but the `instanceof` check
281
+ * ensures correctness in edge cases).
282
+ * - **AbortSignal propagation**: when the client disconnects (signal aborts),
283
+ * the underlying node stream is destroyed immediately to stop I/O.
284
+ * - **Cancel cleanup**: when the web ReadableStream is cancelled (e.g. by
285
+ * `Response.body.cancel()`), the node stream is destroyed.
286
+ *
287
+ * @param iterable - The async iterable (Node.js Readable stream). Typed
288
+ * `AsyncIterable<Uint8Array>` (Node `Buffer` extends `Uint8Array`, so Buffer
289
+ * streams satisfy it) to keep the `Buffer` name -- and its `@types/node`
290
+ * coupling -- out of the package's public type surface.
291
+ * @param opts.destroy - Destroys the underlying stream on cancel/abort. When
292
+ * omitted, a `destroy()` method on the iterable is auto-detected and used.
293
+ * @param opts.signal - Optional AbortSignal for client-disconnect detection
294
+ * @param opts.expectedBytes - Exact byte count the source promised. When set,
295
+ * a graceful end that delivered a different total (a file truncated or grown
296
+ * in place mid-read) errors the web stream instead of closing it short, so a
297
+ * torn body can never masquerade as a complete response under the committed
298
+ * `Content-Length`. A client abort tears down via a thrown iterator error,
299
+ * not a graceful end, so it is unaffected by this check.
300
+ */
301
+ export function nodeStreamToWeb(
302
+ iterable: AsyncIterable<Uint8Array>,
303
+ opts?: {
304
+ destroy?: () => void;
305
+ signal?: {
306
+ addEventListener(type: "abort", listener: () => void): void;
307
+ removeEventListener?(type: "abort", listener: () => void): void;
308
+ };
309
+ expectedBytes?: number;
310
+ },
311
+ ): ReadableStream<Uint8Array<ArrayBuffer>> {
312
+ const { signal, expectedBytes } = opts ?? {};
313
+ let seen = 0;
314
+ // Auto-detect the Node-stream destroy capability when the caller doesn't
315
+ // supply one: every adapter was hand-rolling the same
316
+ // `typeof stream.destroy === "function"` guard.
317
+ const nativeDestroy = (iterable as AsyncIterable<Uint8Array> & { destroy?: () => void }).destroy;
318
+ const destroy = opts?.destroy ?? (
319
+ typeof nativeDestroy === "function" ? () => nativeDestroy.call(iterable) : undefined
320
+ );
321
+
322
+ // Propagate abort signal to the underlying stream. The listener MUST be
323
+ // removed once the stream settles: a consumer that reuses one long-lived
324
+ // signal across many getObject calls would otherwise accumulate one
325
+ // listener (and one retained stream reference) per completed transfer
326
+ // until the signal is aborted or GC'd.
327
+ const abortListener = signal && destroy ? destroy : undefined;
328
+ if (signal && abortListener) signal.addEventListener("abort", abortListener);
329
+ const detach = () => {
330
+ if (signal && abortListener) signal.removeEventListener?.("abort", abortListener);
331
+ };
332
+
333
+ const iterator = iterable[Symbol.asyncIterator]();
334
+ return new ReadableStream<Uint8Array<ArrayBuffer>>({
335
+ async pull(controller) {
336
+ let result: IteratorResult<Uint8Array>;
337
+ try {
338
+ result = await iterator.next();
339
+ } catch (err) {
340
+ // Stop backend I/O when iteration fails, then error the web stream.
341
+ // Without this, a mid-transfer backend failure leaks the socket.
342
+ detach();
343
+ destroy?.();
344
+ throw err;
345
+ }
346
+ if (result.done) {
347
+ detach();
348
+ // A graceful end that under- or over-ran the promised length means the
349
+ // source changed under an already-committed Content-Length: fail the
350
+ // body loudly (a reset the client sees as a failed transfer) rather
351
+ // than deliver a truncated stream that looks complete.
352
+ if (expectedBytes !== undefined && seen !== expectedBytes) {
353
+ destroy?.();
354
+ controller.error(new Error(
355
+ `stream delivered ${seen} bytes, expected ${expectedBytes} (source changed mid-read)`,
356
+ ));
357
+ return;
358
+ }
359
+ controller.close();
360
+ } else {
361
+ // Node.js streams yield Buffer; ensure Uint8Array for cross-runtime compat.
362
+ // Node Buffers and adapter chunks are ArrayBuffer-backed, so the narrow
363
+ // is runtime-safe and lets the web stream advertise a BodyInit-assignable
364
+ // Uint8Array<ArrayBuffer> (see F5 rationale in guardStreamLength).
365
+ const raw = result.value instanceof Uint8Array ? result.value : Buffer.from(result.value);
366
+ const chunk = raw as Uint8Array<ArrayBuffer>;
367
+ seen += chunk.byteLength;
368
+ controller.enqueue(chunk);
369
+ }
370
+ },
371
+ async cancel(reason) {
372
+ detach();
373
+ try {
374
+ await iterator.return?.(reason);
375
+ } catch {
376
+ // Iterator cleanup failed; socket will be GC'd
377
+ }
378
+ destroy?.();
379
+ },
380
+ });
381
+ }
382
+
383
+ /**
384
+ * Wrap a web `ReadableStream` so a graceful end that delivered a byte count
385
+ * other than `expectedBytes` errors the stream instead of closing it short.
386
+ *
387
+ * The Node-stream path gets this guard inside {@link nodeStreamToWeb}. This is
388
+ * the equivalent for adapters whose SDK hands back a web `ReadableStream`
389
+ * directly (S3 in Bun/Deno or via `transformToWebStream`, R2's native binding,
390
+ * a browser `Blob` stream) rather than a Node `Readable`. Without it, an
391
+ * S3-compatible backend that ends a body cleanly but short of the committed
392
+ * `Content-Length` (some do in-flight body retries) would under-run the
393
+ * response undetected -- the exact truncation `expectedBytes` exists to catch.
394
+ * Passing `undefined` disables the check and returns the stream unwrapped.
395
+ */
396
+ export function guardStreamLength(
397
+ stream: ReadableStream<Uint8Array>,
398
+ expectedBytes: number | undefined,
399
+ ): ReadableStream<Uint8Array<ArrayBuffer>> {
400
+ // Return position narrows to <ArrayBuffer> (backend byte chunks are always
401
+ // ArrayBuffer-backed) so the guarded body stays `new Response(...)`-assignable
402
+ // under DOM lib; the input stays wide for callers.
403
+ if (expectedBytes === undefined) return stream as ReadableStream<Uint8Array<ArrayBuffer>>;
404
+ let seen = 0;
405
+ return stream.pipeThrough(
406
+ new TransformStream<Uint8Array, Uint8Array<ArrayBuffer>>({
407
+ transform(chunk, controller) {
408
+ seen += chunk.byteLength;
409
+ controller.enqueue(chunk as Uint8Array<ArrayBuffer>);
410
+ },
411
+ flush(controller) {
412
+ // A short (or long) graceful end means the source diverged from the
413
+ // committed length: fail the body loudly rather than deliver a
414
+ // truncated stream that looks complete. Mirrors nodeStreamToWeb.
415
+ if (seen !== expectedBytes) {
416
+ controller.error(new Error(
417
+ `stream delivered ${seen} bytes, expected ${expectedBytes} (source changed mid-read)`,
418
+ ));
419
+ }
420
+ },
421
+ }),
422
+ );
423
+ }
424
+
425
+ // ─── Storage Metadata ───────────────────────────────────────────────────────
426
+
427
+ /**
428
+ * Metadata from a HEAD request -- enough to evaluate conditionals before
429
+ * any body transfer.
430
+ */
431
+ export interface ObjectMetadata {
432
+ /** Full object size in bytes. */
433
+ contentLength: number;
434
+ /** Backend ETag, if any (already quoted or `W/`-prefixed -- pass through `generateETag`). */
435
+ etag?: string;
436
+ /** Last-Modified, in whatever format the backend emits (`Date.parse`-able). */
437
+ lastModified?: string;
438
+ /**
439
+ * RFC 9530 SHA-256 digest of the full representation (raw base64, no prefix).
440
+ *
441
+ * When provided, the adapter emits `Repr-Digest: sha-256=:<base64>:` on
442
+ * success responses for end-to-end integrity verification.
443
+ *
444
+ * S3: map from `x-amz-checksum-sha256`
445
+ * GCS: map from `x-goog-hash` (extract sha256 component)
446
+ */
447
+ digest?: string;
448
+ /**
449
+ * Opaque adapter token pinning a later `getObject` to this exact
450
+ * representation. The orchestrator round-trips it verbatim into
451
+ * {@link GetObjectOptions.pin} without interpreting it.
452
+ *
453
+ * Only needed by stores whose version identifier is not the ETag: GCS
454
+ * encodes its generation (plus the size/validators `getObject` would
455
+ * otherwise re-fetch), turning the HEAD->GET pair into a single backend
456
+ * read. ETag-pinning stores (S3, R2, Azure, http) omit it -- `ifMatch`
457
+ * already carries their pin.
458
+ */
459
+ pin?: string;
460
+ }
461
+
462
+ // ─── Stream Result ──────────────────────────────────────────────────────────
463
+
464
+ /**
465
+ * The byte range a backend actually served, inclusive on both ends
466
+ * (`bytes start-end/total` without the total: {@link ObjectStream.totalSize}
467
+ * carries it, or is `undefined` when the backend reported `bytes a-b/*`).
468
+ */
469
+ export interface ServedRange {
470
+ /** First byte position served (0-based, inclusive). */
471
+ start: number;
472
+ /** Last byte position served (inclusive). */
473
+ end: number;
474
+ }
475
+
476
+ /** Served bounds plus honest total parsed from a backend `Content-Range`. */
477
+ export interface ResolvedContentRange {
478
+ /** The byte range the backend actually served (inclusive bounds). */
479
+ served: ServedRange;
480
+ /**
481
+ * Full representation size, or `undefined` for the `bytes a-b/*`
482
+ * unknown-total sentinel (a streaming origin that does not know its length).
483
+ */
484
+ totalSize: number | undefined;
485
+ }
486
+
487
+ /**
488
+ * Parse a backend `Content-Range` response header into served bounds plus an
489
+ * honest total, applying the parser's `-1` unknown-total sentinel -> `undefined`
490
+ * mapping that every SDK adapter needs identically. Returns `null` ONLY when the
491
+ * header is present but unparseable -- the exact "byte accounting is
492
+ * untrustworthy, tear down and fail loudly" signal, which each adapter pairs
493
+ * with its own live-body cleanup (S3 `stream.cancel()`, Azure
494
+ * `destroyAzureDownload`, http `drain`) before throwing.
495
+ *
496
+ * Adapters that treat an ABSENT header as a full 200 (S3, Azure) check for the
497
+ * header before calling; callers that already know the response is partial
498
+ * (http's 206 branch) treat both an absent header and a `null` here as the same
499
+ * malformed-206 failure. Adapters that know their bounds natively (fs, memory,
500
+ * GCS, R2) construct {@link ServedRange} directly and never call this.
501
+ */
502
+ export function resolveServedRange(contentRange: string): ResolvedContentRange | null {
503
+ const parsed = parseContentRange(contentRange);
504
+ if (!parsed) return null;
505
+ return {
506
+ served: { start: parsed.start, end: parsed.end },
507
+ totalSize: parsed.totalSize < 0 ? undefined : parsed.totalSize,
508
+ };
509
+ }
510
+
511
+ /**
512
+ * A body-transfer result from `getObject`.
513
+ *
514
+ * Every field is sourced from the **same GET response** -- including `etag`
515
+ * and `lastModified`. Consumers MUST prefer these over any prior HEAD values
516
+ * to avoid advertising stale validators against freshly-fetched bytes
517
+ * (the HEAD->GET TOCTOU window).
518
+ */
519
+ export interface ObjectStream {
520
+ /**
521
+ * The body: a `ReadableStream` for streamed transfers, or a plain
522
+ * `Uint8Array` when the adapter already has the exact bytes in memory
523
+ * (small files, in-memory stores). Byte bodies let consumers skip stream
524
+ * machinery entirely -- server adapters write them in a single syscall
525
+ * and `new Response(bytes)` takes the fetch runtime's static-body fast
526
+ * path. Return whichever form is natural; never buffer large transfers
527
+ * just to produce bytes.
528
+ *
529
+ * Typed `<ArrayBuffer>`-backed (not the wider `ArrayBufferLike`) so a
530
+ * consumer can pass it straight to `new Response(...)` under `lib: ["DOM"]`
531
+ * on TS >= 5.7, where `BodyInit` requires an `ArrayBuffer`-backed view.
532
+ */
533
+ body: ReadableStream<Uint8Array<ArrayBuffer>> | Uint8Array<ArrayBuffer>;
534
+ /** Bytes in THIS response (range length for 206, full size for 200). */
535
+ contentLength: number;
536
+ /**
537
+ * Full object size, independent of any range, or `undefined` when the
538
+ * backend served a partial response with an unknown total (`bytes a-b/*`,
539
+ * RFC 7233 Section 4.2) -- a streaming origin that does not know its full
540
+ * length. Object stores (S3, R2, GCS, Azure) always know their sizes and
541
+ * set a number; only proxied origins (the http adapter) legitimately leave
542
+ * it `undefined`. Valid ONLY alongside a `range`: a full response carries a
543
+ * concrete size in `contentLength`, so `undefined` with no `range` is an
544
+ * adapter bug (the orchestrator emits 200 and needs the length).
545
+ */
546
+ totalSize: number | undefined;
547
+ /**
548
+ * The byte range the backend ACTUALLY served (inclusive bounds), or
549
+ * `undefined` if it served full content.
550
+ *
551
+ * This is the source of truth for 206 vs 200: if a range was requested but
552
+ * `range` is `undefined`, the backend ignored it -- emit 200, never a
553
+ * lying 206. Adapters that receive a `Content-Range` string from their
554
+ * backend parse it with {@link parseContentRange} and fail loudly on
555
+ * garbage; adapters that know the bounds natively (fs, memory, GCS, R2)
556
+ * construct this directly -- malformed range strings cannot exist in the
557
+ * contract.
558
+ */
559
+ range?: ServedRange;
560
+ /** ETag as returned by the GET (authoritative for this body). */
561
+ etag?: string;
562
+ /** Last-Modified as returned by the GET (authoritative for this body). */
563
+ lastModified?: string;
564
+ /**
565
+ * RFC 9530 SHA-256 digest of the full representation (raw base64, no prefix).
566
+ *
567
+ * When provided by the GET response, the adapter can emit `Repr-Digest`
568
+ * even on Path C (no prior HEAD). This closes the digest gap for
569
+ * first-visit requests that skip the HEAD round-trip.
570
+ *
571
+ * S3: map from `x-amz-checksum-sha256` on GetObject response.
572
+ */
573
+ digest?: string;
574
+ }
575
+
576
+ // ─── Store Contract ─────────────────────────────────────────────────────────
577
+
578
+ /**
579
+ * Storage backend abstraction consumed by partial-content adapters.
580
+ *
581
+ * This contract is **read-only by design**. It covers the operations needed
582
+ * to evaluate conditional requests and stream object content. Write operations
583
+ * (PUT, PATCH, DELETE) remain the consumer's responsibility -- use
584
+ * `evaluateConditionalWrite()` to evaluate preconditions, then perform the
585
+ * write through your storage SDK directly.
586
+ *
587
+ * Implementations adapt a concrete store (S3/R2/Hetzner/GCS/fs) down to this
588
+ * surface. The kernel and framework adapters depend only on this interface,
589
+ * never on a storage SDK.
590
+ *
591
+ * @example
592
+ * ```typescript
593
+ * import type { ObjectStore } from "partial-content";
594
+ *
595
+ * const store: ObjectStore = {
596
+ * async headObject(key, opts) { ... },
597
+ * async getObject(key, opts) { ... },
598
+ * };
599
+ * ```
600
+ */
601
+ export interface ObjectStore {
602
+ /**
603
+ * Fetch metadata without a body. Used to evaluate conditional requests
604
+ * (304/412) and If-Range before deciding whether to transfer bytes.
605
+ *
606
+ * @param key - Object key within the bucket.
607
+ * @param opts - Cancellation ({@link HeadObjectOptions}).
608
+ */
609
+ headObject(key: string, opts?: HeadObjectOptions): Promise<ObjectMetadata>;
610
+
611
+ /**
612
+ * Stream an object, optionally a byte range.
613
+ *
614
+ * The adapter receives a `ParsedRange` in `opts.range` and formats it for
615
+ * the backend (e.g. `bytes=${start}-${end}`). `range.end` may exceed the
616
+ * object size (RFC 9110 lets servers clamp a last-byte-pos past EOF, and
617
+ * the fast range path uses an open end deliberately); adapters clamp it
618
+ * and report the bounds ACTUALLY served. `range.start` beyond EOF is the
619
+ * caller's error: local adapters throw a RangeError, remote backends
620
+ * reject natively. Returns the actual transfer result -- including the
621
+ * real `Content-Range` -- so the caller can build a truthful 200/206
622
+ * from a single round-trip.
623
+ *
624
+ * @param key - Object key within the bucket.
625
+ * @param opts - Range, cancellation, and pinning
626
+ * ({@link GetObjectOptions}). Stores that cannot pin reads may ignore
627
+ * `ifMatch`; the web adapter keeps a response-side guard (actual
628
+ * Content-Range + GET validators) for that case.
629
+ */
630
+ getObject(key: string, opts?: GetObjectOptions): Promise<ObjectStream>;
631
+
632
+ /**
633
+ * Capability flag. When `false`, the framework adapter degrades gracefully
634
+ * (e.g. signed-URL redirect) instead of attempting range streaming.
635
+ * Omit or set `true` for any S3-compatible backend.
636
+ *
637
+ * @default true
638
+ */
639
+ readonly supportsRange?: boolean;
640
+
641
+ /**
642
+ * Capability flag: this adapter's ranged responses carry bounds and total
643
+ * size taken from the BACKEND's actual response (`Content-Range`), not
644
+ * echoed from the request. When `true`, the framework adapter skips the
645
+ * validating HEAD for plain range requests (no conditionals, no
646
+ * If-Range) and serves the seek in a single round-trip -- validators,
647
+ * bounds, and digest all come from the GET response itself, which is
648
+ * also inherently TOCTOU-atomic. Leave unset for stores that need the
649
+ * orchestrator's pre-clamped ranges (fs, memory) or that fetch metadata
650
+ * themselves anyway (GCS).
651
+ *
652
+ * @default false
653
+ */
654
+ readonly authoritativeRange?: boolean;
655
+
656
+ /**
657
+ * Optional degradation path for backends that cannot stream ranges through
658
+ * the origin. Returns a short-lived URL the client is redirected to.
659
+ */
660
+ createSignedUrl?(
661
+ key: string,
662
+ opts: { expiresInSeconds: number; downloadFilename?: string },
663
+ ): Promise<{ ok: true; url: string } | { ok: false; error: string }>;
664
+ }
665
+