partial-content 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +601 -0
- package/SECURITY.md +92 -0
- package/dist/azure.d.ts +79 -0
- package/dist/azure.d.ts.map +1 -0
- package/dist/azure.js +251 -0
- package/dist/azure.js.map +1 -0
- package/dist/content-disposition.d.ts +74 -0
- package/dist/content-disposition.d.ts.map +1 -0
- package/dist/content-disposition.js +253 -0
- package/dist/content-disposition.js.map +1 -0
- package/dist/fs.d.ts +86 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +375 -0
- package/dist/fs.js.map +1 -0
- package/dist/gcs.d.ts +72 -0
- package/dist/gcs.d.ts.map +1 -0
- package/dist/gcs.js +202 -0
- package/dist/gcs.js.map +1 -0
- package/dist/hono.d.ts +92 -0
- package/dist/hono.d.ts.map +1 -0
- package/dist/hono.js +61 -0
- package/dist/hono.js.map +1 -0
- package/dist/http.d.ts +70 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +281 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel.d.ts +541 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +1218 -0
- package/dist/kernel.js.map +1 -0
- package/dist/memory.d.ts +55 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +107 -0
- package/dist/memory.js.map +1 -0
- package/dist/mime.d.ts +49 -0
- package/dist/mime.d.ts.map +1 -0
- package/dist/mime.js +150 -0
- package/dist/mime.js.map +1 -0
- package/dist/node.d.ts +84 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +215 -0
- package/dist/node.js.map +1 -0
- package/dist/object-store.d.ts +472 -0
- package/dist/object-store.d.ts.map +1 -0
- package/dist/object-store.js +335 -0
- package/dist/object-store.js.map +1 -0
- package/dist/r2.d.ts +94 -0
- package/dist/r2.d.ts.map +1 -0
- package/dist/r2.js +150 -0
- package/dist/r2.js.map +1 -0
- package/dist/s3.d.ts +49 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +263 -0
- package/dist/s3.js.map +1 -0
- package/dist/web.d.ts +336 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +1094 -0
- package/dist/web.js.map +1 -0
- package/docs/DESIGN.md +426 -0
- package/package.json +182 -0
- package/src/azure.ts +329 -0
- package/src/content-disposition.ts +300 -0
- package/src/fs.ts +469 -0
- package/src/gcs.ts +290 -0
- package/src/hono.ts +123 -0
- package/src/http.ts +351 -0
- package/src/index.ts +85 -0
- package/src/kernel.ts +1498 -0
- package/src/memory.ts +148 -0
- package/src/mime.ts +160 -0
- package/src/node.ts +261 -0
- package/src/object-store.ts +665 -0
- package/src/r2.ts +232 -0
- package/src/s3.ts +324 -0
- package/src/web.ts +1603 -0
|
@@ -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
|
+
|