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/r2.ts ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Cloudflare R2 native ObjectStore adapter for partial-content.
3
+ *
4
+ * Uses Cloudflare's native R2 bindings (`R2Bucket`) directly, without the
5
+ * AWS SDK. This eliminates the ~50-package `@aws-sdk/client-s3` dependency
6
+ * and its cold-start overhead in Workers.
7
+ *
8
+ * For S3-compatible access to R2 (e.g. from Node.js outside Workers),
9
+ * use `partial-content/s3` instead.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import { r2Store } from "partial-content/r2";
14
+ * import { serveObject } from "partial-content/hono";
15
+ *
16
+ * // In a Cloudflare Worker with R2 binding:
17
+ * app.get("/files/:key", serveObject(
18
+ * r2Store({ bucket: env.MY_BUCKET }),
19
+ * { key: (c) => c.req.param("key") },
20
+ * ));
21
+ * ```
22
+ *
23
+ * @packageDocumentation
24
+ */
25
+
26
+ import {
27
+ isOpenEndedRange,
28
+ guardStreamLength,
29
+ ObjectNotFoundError,
30
+ ObjectChangedError,
31
+ type ObjectStore,
32
+ type ObjectMetadata,
33
+ type ObjectStream,
34
+ type ParsedRange,
35
+ } from "./index.js";
36
+
37
+ // Re-export for convenience
38
+ export { ObjectNotFoundError, ObjectChangedError };
39
+
40
+ // ─── R2 Types ───────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Minimal R2Bucket interface from Cloudflare Workers types.
44
+ *
45
+ * Declared locally to avoid a dependency on `@cloudflare/workers-types`.
46
+ * The shapes match the runtime behavior; type-checking is the caller's
47
+ * responsibility (their Worker project imports the full types).
48
+ */
49
+ interface R2Bucket {
50
+ head(key: string): Promise<R2Object | null>;
51
+ /**
52
+ * With `onlyIf`, a failed precondition resolves to a body-less `R2Object`
53
+ * (not null, not an error) -- callers must check for `body`.
54
+ */
55
+ get(key: string, options?: R2GetOptions): Promise<R2Object | R2ObjectBody | null>;
56
+ }
57
+
58
+ interface R2Object {
59
+ key: string;
60
+ size: number;
61
+ etag: string;
62
+ uploaded: Date;
63
+ checksums: R2Checksums;
64
+ httpMetadata?: R2HttpMetadata;
65
+ }
66
+
67
+ interface R2ObjectBody extends R2Object {
68
+ body: ReadableStream<Uint8Array>;
69
+ range: R2Range;
70
+ }
71
+
72
+ interface R2Range {
73
+ offset: number;
74
+ length: number;
75
+ }
76
+
77
+ interface R2GetOptions {
78
+ // `length` is optional in the real R2 binding: offset alone reads to the
79
+ // end of the object (the open-ended fast-path form).
80
+ range?: { offset: number; length?: number };
81
+ onlyIf?: { etagMatches?: string };
82
+ }
83
+
84
+ interface R2Checksums {
85
+ sha256?: ArrayBuffer;
86
+ }
87
+
88
+ interface R2HttpMetadata {
89
+ contentType?: string;
90
+ }
91
+
92
+ // ─── Options ────────────────────────────────────────────────────────────────
93
+
94
+ export interface R2StoreOptions {
95
+ /** The R2 bucket binding from the Worker environment. */
96
+ bucket: R2Bucket;
97
+ }
98
+
99
+ // ─── Helpers ────────────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Convert an ArrayBuffer SHA-256 checksum to base64 for RFC 9530 Repr-Digest.
103
+ *
104
+ * `checksums.sha256` is R2's whole-object SHA-256 as raw bytes, so unlike the
105
+ * S3 adapter there is no `<base64>-<partCount>` composite string form to
106
+ * reject: a 32-byte buffer always describes the full representation. If R2
107
+ * ever exposes a per-part checksum in this field, this digest would need the
108
+ * same whole-object guard S3 applies.
109
+ */
110
+ function arrayBufferToBase64(buffer: ArrayBuffer): string {
111
+ const bytes = new Uint8Array(buffer);
112
+ let binary = "";
113
+ for (let i = 0; i < bytes.length; i++) {
114
+ binary += String.fromCharCode(bytes[i]);
115
+ }
116
+ return btoa(binary);
117
+ }
118
+
119
+ // ─── Factory ────────────────────────────────────────────────────────────────
120
+
121
+ /**
122
+ * Create an {@link ObjectStore} backed by a Cloudflare R2 bucket.
123
+ *
124
+ * @example
125
+ * ```typescript
126
+ * import { r2Store } from "partial-content/r2";
127
+ *
128
+ * export default {
129
+ * async fetch(request, env) {
130
+ * const store = r2Store({ bucket: env.MY_BUCKET });
131
+ * const meta = await store.headObject("reports/q4.pdf");
132
+ * // ...
133
+ * },
134
+ * };
135
+ * ```
136
+ */
137
+ export function r2Store(opts: R2StoreOptions): ObjectStore {
138
+ const { bucket } = opts;
139
+
140
+ // No `classifyStoreRead` here (unlike s3/gcs/azure): the R2 Workers binding
141
+ // signals its recoverable outcomes structurally, not as classifiable errors.
142
+ // Not-found is a `null` return, a failed `onlyIf` pin is a body-less object,
143
+ // and both are handled inline below. R2 exposes NO documented throttle/503
144
+ // error shape on the native binding (rate limiting surfaces at the Workers
145
+ // platform layer, before the handler runs), so there is nothing to map to
146
+ // `StoreUnavailableError`. Inventing a classifier for an undocumented error
147
+ // shape would be guessing; the README correctly omits R2 from the throttle-
148
+ // classification list. Use `partial-content/s3` (R2's S3 endpoint) if you
149
+ // need SlowDown/429 -> 503 mapping.
150
+ return {
151
+ supportsRange: true,
152
+ // 206 bounds/total come from the R2ObjectBody's actual size/offset: the
153
+ // orchestrator may skip the validating HEAD for plain range requests.
154
+ authoritativeRange: true,
155
+
156
+ async headObject(key: string, opts?: { signal?: AbortSignal }): Promise<ObjectMetadata> {
157
+ opts?.signal?.throwIfAborted();
158
+ const obj = await bucket.head(key);
159
+ if (!obj) throw new ObjectNotFoundError(key);
160
+
161
+ return {
162
+ contentLength: obj.size,
163
+ etag: obj.etag,
164
+ lastModified: obj.uploaded.toUTCString(),
165
+ digest: obj.checksums.sha256
166
+ ? arrayBufferToBase64(obj.checksums.sha256)
167
+ : undefined,
168
+ };
169
+ },
170
+
171
+ async getObject(key: string, opts?: { range?: ParsedRange; signal?: AbortSignal; ifMatch?: string }): Promise<ObjectStream> {
172
+ const { range, ifMatch } = opts ?? {};
173
+ opts?.signal?.throwIfAborted();
174
+ // An open-ended fast-path range (OPEN_ENDED sentinel end) reads to the
175
+ // end of the object: pass offset alone so R2 streams the tail rather
176
+ // than requesting a ~9e15 length.
177
+ const r2Range = range
178
+ ? (isOpenEndedRange(range)
179
+ ? { offset: range.start }
180
+ : { offset: range.start, length: range.end - range.start + 1 })
181
+ : undefined;
182
+
183
+ const getOptions: R2GetOptions | undefined = r2Range || ifMatch
184
+ ? {
185
+ ...(r2Range ? { range: r2Range } : {}),
186
+ // Pin the read to the validated representation. On mismatch R2
187
+ // returns the object WITHOUT a body (checked below).
188
+ ...(ifMatch ? { onlyIf: { etagMatches: ifMatch } } : {}),
189
+ }
190
+ : undefined;
191
+
192
+ const obj = await bucket.get(key, getOptions);
193
+ if (!obj) throw new ObjectNotFoundError(key);
194
+
195
+ // onlyIf precondition failed: R2 resolves with metadata but no body.
196
+ if (!("body" in obj) || !obj.body) {
197
+ throw new ObjectChangedError(key);
198
+ }
199
+
200
+ const body = obj as R2ObjectBody;
201
+ const totalSize = obj.size;
202
+
203
+ // Content-Range must reflect what R2 ACTUALLY returned, not what was
204
+ // requested. `body.range` is R2's authoritative offset/length for this
205
+ // response; if R2 clamped the range (object changed between HEAD and
206
+ // GET), fabricating from the request would corrupt the client's bytes.
207
+ const returned = range ? body.range : undefined;
208
+ const start = returned?.offset ?? range?.start;
209
+ const length = returned?.length
210
+ ?? (range
211
+ ? (isOpenEndedRange(range) ? totalSize - range.start : range.end - range.start + 1)
212
+ : undefined);
213
+
214
+ const isRanged = start !== undefined && length !== undefined;
215
+ const contentLength = isRanged ? length : totalSize;
216
+
217
+ return {
218
+ // Guard the committed length: if R2 ever ends the body short of the
219
+ // computed contentLength, error the stream rather than under-run it.
220
+ body: guardStreamLength(body.body, contentLength),
221
+ contentLength,
222
+ totalSize,
223
+ range: isRanged ? { start, end: start + length - 1 } : undefined,
224
+ etag: obj.etag,
225
+ lastModified: obj.uploaded.toUTCString(),
226
+ digest: obj.checksums.sha256
227
+ ? arrayBufferToBase64(obj.checksums.sha256)
228
+ : undefined,
229
+ };
230
+ },
231
+ };
232
+ }
package/src/s3.ts ADDED
@@ -0,0 +1,324 @@
1
+ /**
2
+ * S3-compatible ObjectStore adapter for partial-content.
3
+ *
4
+ * Implements the read-only {@link ObjectStore} interface from the kernel,
5
+ * wrapping `@aws-sdk/client-s3` HeadObject/GetObject commands. Covers:
6
+ * - AWS S3
7
+ * - Cloudflare R2 (S3-compatible mode)
8
+ * - Hetzner Object Storage
9
+ * - MinIO / Backblaze B2 / Wasabi
10
+ *
11
+ * For native R2 bindings (without the AWS SDK), use `partial-content/r2`.
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * import { S3Client } from "@aws-sdk/client-s3";
16
+ * import { s3Store } from "partial-content/s3";
17
+ *
18
+ * const client = new S3Client({ region: "eu-central-1" });
19
+ * const store = s3Store({ client, bucket: "documents" });
20
+ * ```
21
+ *
22
+ * @packageDocumentation
23
+ */
24
+
25
+ import {
26
+ S3Client,
27
+ GetObjectCommand,
28
+ HeadObjectCommand,
29
+ NoSuchKey,
30
+ NotFound,
31
+ } from "@aws-sdk/client-s3";
32
+ import {
33
+ ObjectNotFoundError,
34
+ ObjectChangedError,
35
+ StoreUnavailableError,
36
+ classifyStoreRead,
37
+ nodeStreamToWeb,
38
+ guardStreamLength,
39
+ resolveServedRange,
40
+ buildContentDisposition,
41
+ isOpenEndedRange,
42
+ type ObjectStore,
43
+ type ObjectMetadata,
44
+ type ObjectStream,
45
+ type ParsedRange,
46
+ type StoreErrorClassifiers,
47
+ } from "./index.js";
48
+
49
+ // Re-export for convenience
50
+ export { ObjectNotFoundError, ObjectChangedError, StoreUnavailableError };
51
+
52
+ // ─── S3 Body -> Web ReadableStream ──────────────────────────────────────────
53
+
54
+ /**
55
+ * Convert an S3 SDK response Body to a web ReadableStream.
56
+ *
57
+ * The SDK Body is a web ReadableStream in Bun/Deno, a Node Readable in
58
+ * Node.js, and may expose `transformToWebStream()` as a convenience.
59
+ * We try each path in order of preference.
60
+ */
61
+ function toWebStream(body: unknown, signal?: AbortSignal, expectedBytes?: number): ReadableStream<Uint8Array<ArrayBuffer>> {
62
+ // Every branch enforces the committed length: an S3-compatible backend that
63
+ // ends a body cleanly but short of ContentLength (some do in-flight body
64
+ // retries) must error the stream, not silently under-run the response. The
65
+ // web-stream branches are the ones Node aws-sdk v3 and Bun/Deno actually take,
66
+ // so guarding only the Node-Readable fallback would leave the guard dead.
67
+ if (body instanceof ReadableStream) {
68
+ return guardStreamLength(body, expectedBytes);
69
+ }
70
+ if (typeof (body as { transformToWebStream?: () => ReadableStream }).transformToWebStream === "function") {
71
+ return guardStreamLength(
72
+ (body as { transformToWebStream: () => ReadableStream<Uint8Array> }).transformToWebStream(),
73
+ expectedBytes,
74
+ );
75
+ }
76
+ // Node Readable fallback: the shared utility auto-detects destroy() and
77
+ // applies the same length guard internally.
78
+ return nodeStreamToWeb(body as AsyncIterable<Buffer | Uint8Array>, { signal, expectedBytes });
79
+ }
80
+
81
+ // ─── S3 ObjectStore Options ─────────────────────────────────────────────────
82
+
83
+ export interface S3StoreOptions {
84
+ /** Pre-configured S3Client instance (BYOC, no config coupling). */
85
+ client: S3Client;
86
+ /** The S3 bucket name. */
87
+ bucket: string;
88
+ }
89
+
90
+ // ─── Factory ────────────────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Create an {@link ObjectStore} backed by an S3-compatible bucket.
94
+ *
95
+ * @example
96
+ * ```typescript
97
+ * import { S3Client } from "@aws-sdk/client-s3";
98
+ * import { s3Store } from "partial-content/s3";
99
+ *
100
+ * const client = new S3Client({ region: "eu-central-1" });
101
+ * const store = s3Store({ client, bucket: "documents" });
102
+ *
103
+ * // Use with partial-content's evaluateConditionalRequest:
104
+ * const meta = await store.headObject("reports/q4.pdf");
105
+ * ```
106
+ */
107
+ export function s3Store(opts: S3StoreOptions): ObjectStore {
108
+ const { client, bucket } = opts;
109
+
110
+ return {
111
+ supportsRange: true,
112
+ // 206 bounds/total are parsed from S3's actual Content-Range: the
113
+ // orchestrator may skip the validating HEAD for plain range requests.
114
+ authoritativeRange: true,
115
+
116
+ async headObject(key: string, opts?: { signal?: AbortSignal }): Promise<ObjectMetadata> {
117
+ const response = await classifyStoreRead(key, () => client.send(
118
+ // ChecksumMode is required for S3 to return ChecksumSHA256 at all;
119
+ // without it the digest would silently never be emitted.
120
+ new HeadObjectCommand({ Bucket: bucket, Key: key, ChecksumMode: "ENABLED" }),
121
+ { abortSignal: opts?.signal },
122
+ ), s3Classifiers);
123
+
124
+ if (response.ContentLength == null) {
125
+ throw new Error(`HeadObject returned no ContentLength for ${key}`);
126
+ }
127
+
128
+ return {
129
+ contentLength: response.ContentLength,
130
+ etag: response.ETag,
131
+ lastModified: response.LastModified?.toUTCString(),
132
+ digest: toReprDigest(response.ChecksumSHA256),
133
+ };
134
+ },
135
+
136
+ async getObject(key: string, opts?: { range?: ParsedRange; signal?: AbortSignal; ifMatch?: string }): Promise<ObjectStream> {
137
+ const { range, signal, ifMatch } = opts ?? {};
138
+ // Open-ended fast-path ranges (`bytes=a-`) carry the OPEN_ENDED
139
+ // sentinel end; emit the bare open form so no 16-digit last-byte-pos
140
+ // reaches the wire (S3 clamps it, but strict proxies may reject it).
141
+ const rangeHeader = range
142
+ ? (isOpenEndedRange(range) ? `bytes=${range.start}-` : `bytes=${range.start}-${range.end}`)
143
+ : undefined;
144
+
145
+ const response = await classifyStoreRead(key, () => client.send(
146
+ new GetObjectCommand({
147
+ Bucket: bucket,
148
+ Key: key,
149
+ ChecksumMode: "ENABLED",
150
+ // Pin the read to the validated representation: S3 rejects with
151
+ // 412 PreconditionFailed if the object changed since HEAD.
152
+ ...(ifMatch ? { IfMatch: ifMatch } : {}),
153
+ ...(rangeHeader ? { Range: rangeHeader } : {}),
154
+ }),
155
+ { abortSignal: signal },
156
+ ), s3Classifiers);
157
+
158
+ if (!response.Body) {
159
+ throw new Error(`S3 GetObject returned empty body for ${key}`);
160
+ }
161
+
162
+ const stream = toWebStream(response.Body, signal, response.ContentLength);
163
+
164
+ // A null ContentLength on a GET with a live body is a degenerate response:
165
+ // fabricating `0` would commit `Content-Length: 0` over real bytes (a wire
166
+ // framing error) and silently disable the short-read guard. headObject
167
+ // already fails loudly on this; mirror it -- cancel the body and throw.
168
+ if (response.ContentLength == null) {
169
+ stream.cancel().catch(() => { /* already-errored streams reject cancel */ });
170
+ throw new Error(`S3 GetObject returned no ContentLength for ${key}`);
171
+ }
172
+ const contentLength = response.ContentLength;
173
+
174
+ // Extract served bounds + total size from Content-Range
175
+ // (e.g. "bytes 0-999/5000") via the shared resolver, or fall back to
176
+ // ContentLength for full responses. A Content-Range S3 emits but the
177
+ // resolver cannot parse means the byte accounting is untrustworthy:
178
+ // cancel the live body and fail loudly rather than guess.
179
+ let totalSize: number | undefined;
180
+ let served: { start: number; end: number } | undefined;
181
+ if (response.ContentRange) {
182
+ const resolved = resolveServedRange(response.ContentRange);
183
+ if (!resolved) {
184
+ stream.cancel().catch(() => { /* already-errored streams reject cancel */ });
185
+ throw new Error(`S3 returned unparseable Content-Range for ${key}: ${response.ContentRange}`);
186
+ }
187
+ served = resolved.served;
188
+ totalSize = resolved.totalSize;
189
+ } else {
190
+ totalSize = contentLength;
191
+ }
192
+
193
+ return {
194
+ body: stream,
195
+ contentLength,
196
+ totalSize,
197
+ range: served,
198
+ etag: response.ETag,
199
+ lastModified: response.LastModified?.toUTCString(),
200
+ // Repr-Digest MUST hash the full representation. AWS omits the checksum
201
+ // on ranged GETs, but a non-conforming S3-compatible backend could
202
+ // return a range-scoped one; never advertise it as a whole-object
203
+ // digest. HEAD (always whole-object) and full 200s still carry it.
204
+ digest: served ? undefined : toReprDigest(response.ChecksumSHA256),
205
+ };
206
+ },
207
+
208
+ async createSignedUrl(key, signOpts) {
209
+ try {
210
+ // A signed URL is a 302 redirect target: the client fetches bytes
211
+ // DIRECTLY from S3, bypassing the serve route's security headers
212
+ // (nosniff, CSP, CORP). Force a download disposition AND an inert
213
+ // content type so a stored SVG/HTML polyglot cannot render inline off
214
+ // the header-less origin response. Both are S3 query-parameter
215
+ // overrides honored on the presigned GET; the stored object is
216
+ // untouched. `downloadFilename` only customizes the name.
217
+ const command = new GetObjectCommand({
218
+ Bucket: bucket,
219
+ Key: key,
220
+ ResponseContentType: "application/octet-stream",
221
+ ResponseContentDisposition: buildContentDisposition(
222
+ signOpts.downloadFilename ?? "download",
223
+ { type: "attachment" },
224
+ ),
225
+ });
226
+
227
+ // Lazy import: the presigner is an optional peer needed ONLY for
228
+ // signed URLs. A static import would crash `partial-content/s3` at
229
+ // module load for every consumer who installed just
230
+ // @aws-sdk/client-s3 (the documented baseline) and never presigns.
231
+ const { getSignedUrl } = await import("@aws-sdk/s3-request-presigner");
232
+ const url = await getSignedUrl(client, command, {
233
+ expiresIn: signOpts.expiresInSeconds,
234
+ });
235
+
236
+ return { ok: true as const, url };
237
+ } catch (err) {
238
+ return {
239
+ ok: false as const,
240
+ error: err instanceof Error ? err.message : String(err),
241
+ };
242
+ }
243
+ },
244
+ };
245
+ }
246
+
247
+ // ─── Internal Helpers ───────────────────────────────────────────────────────
248
+
249
+ /** A raw base64-encoded SHA-256 hash: exactly 43 base64 chars plus padding. */
250
+ const SHA256_BASE64_RE = /^[A-Za-z0-9+/]{43}=$/;
251
+
252
+ /**
253
+ * Validate an S3 checksum for use as an RFC 9530 representation digest.
254
+ *
255
+ * Multipart uploads produce composite checksums ("checksum-of-checksums")
256
+ * in the form `<base64>-<partCount>`, which do NOT hash the object bytes
257
+ * and are invalid inside `Repr-Digest: sha-256=:...:`. Only a plain
258
+ * base64 SHA-256 of the full object passes through.
259
+ */
260
+ function toReprDigest(checksum: string | undefined): string | undefined {
261
+ return checksum && SHA256_BASE64_RE.test(checksum) ? checksum : undefined;
262
+ }
263
+
264
+ /**
265
+ * Check if an S3 error indicates the object does not exist.
266
+ *
267
+ * Covers:
268
+ * - AWS SDK v3 `NoSuchKey` and `NotFound` error classes
269
+ * - Generic S3-compatible providers that set `$metadata.httpStatusCode: 404`
270
+ * - Providers that set `name: "NotFound"` without using the SDK error classes
271
+ */
272
+ function isNotFoundError(err: unknown): boolean {
273
+ if (err instanceof NoSuchKey || err instanceof NotFound) return true;
274
+ if (err instanceof Error && err.name === "NotFound") return true;
275
+ const meta = (err as { $metadata?: { httpStatusCode?: number } }).$metadata;
276
+ if (meta?.httpStatusCode === 404) return true;
277
+ return false;
278
+ }
279
+
280
+ /**
281
+ * Check if an S3 error is a failed IfMatch precondition (object changed).
282
+ * The SDK surfaces this as name "PreconditionFailed" with HTTP 412.
283
+ */
284
+ function isPreconditionFailedError(err: unknown): boolean {
285
+ if (err instanceof Error && err.name === "PreconditionFailed") return true;
286
+ const meta = (err as { $metadata?: { httpStatusCode?: number } }).$metadata;
287
+ return meta?.httpStatusCode === 412;
288
+ }
289
+
290
+ /**
291
+ * Check if an S3 error is a transient throttle/overload the client should
292
+ * retry (mapped to a 503, not a 502). The AWS SDK exhausts its own adaptive
293
+ * retries first, so reaching here means sustained pressure.
294
+ *
295
+ * Covers:
296
+ * - `$retryable.throttling` (the SDK's own throttle classification)
297
+ * - HTTP 503 (`SlowDown`) and 429 (`TooManyRequests`) on `$metadata`
298
+ * - the named throttle errors S3-compatible backends raise without setting
299
+ * an HTTP status code
300
+ */
301
+ function isThrottledError(err: unknown): boolean {
302
+ if ((err as { $retryable?: { throttling?: boolean } }).$retryable?.throttling === true) return true;
303
+ const meta = (err as { $metadata?: { httpStatusCode?: number } }).$metadata;
304
+ if (meta?.httpStatusCode === 503 || meta?.httpStatusCode === 429) return true;
305
+ if (err instanceof Error) {
306
+ const name = err.name;
307
+ return (
308
+ name === "SlowDown" ||
309
+ name === "ThrottlingException" ||
310
+ name === "TooManyRequestsException" ||
311
+ name === "RequestThrottled" ||
312
+ name === "RequestThrottledException" ||
313
+ name === "ProvisionedThroughputExceededException"
314
+ );
315
+ }
316
+ return false;
317
+ }
318
+
319
+ /** The ordered error-classification set shared by headObject and getObject. */
320
+ const s3Classifiers: StoreErrorClassifiers = {
321
+ notFound: isNotFoundError,
322
+ changed: isPreconditionFailedError,
323
+ throttled: isThrottledError,
324
+ };