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
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
|
+
};
|