@statewalker/webrun-files-s3 0.5.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.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * S3 implementation of IFilesApi
3
+ *
4
+ * Provides a filesystem-like interface over S3 object storage.
5
+ * Supports all IFilesApi methods including the optional move, copy, and mkdir.
6
+ */
7
+ import type { S3Client } from "@aws-sdk/client-s3";
8
+ import type { CopyOptions, FileHandle, FileInfo, FileRef, IFilesApi, ListOptions } from "@statewalker/webrun-files";
9
+ export interface S3FilesApiOptions {
10
+ /**
11
+ * Pre-configured S3Client instance.
12
+ * Allows full control over credentials, region, endpoint.
13
+ */
14
+ client: S3Client;
15
+ /**
16
+ * S3 bucket name.
17
+ */
18
+ bucket: string;
19
+ /**
20
+ * Optional key prefix (acts as root directory).
21
+ * All paths will be relative to this prefix.
22
+ * @example "projects/my-app/data"
23
+ */
24
+ prefix?: string;
25
+ /**
26
+ * Minimum part size for multipart uploads (5MB minimum for S3).
27
+ * @default 5 * 1024 * 1024 (5MB)
28
+ */
29
+ multipartPartSize?: number;
30
+ }
31
+ export declare class S3FilesApi implements IFilesApi {
32
+ private client;
33
+ private bucket;
34
+ private prefix;
35
+ private multipartPartSize;
36
+ constructor(options: S3FilesApiOptions);
37
+ /**
38
+ * Converts a virtual path to an S3 key.
39
+ * @example resolveKey("/docs/file.txt") => "prefix/docs/file.txt"
40
+ */
41
+ private resolveKey;
42
+ /**
43
+ * Converts an S3 key back to a virtual path.
44
+ */
45
+ private keyToPath;
46
+ /**
47
+ * Lists objects in a virtual directory.
48
+ *
49
+ * Uses S3's delimiter feature to simulate directory listing.
50
+ * CommonPrefixes represent subdirectories.
51
+ */
52
+ list(file: FileRef, options?: ListOptions): AsyncGenerator<FileInfo>;
53
+ /**
54
+ * Gets object metadata using HEAD request.
55
+ */
56
+ stats(file: FileRef): Promise<FileInfo | undefined>;
57
+ /**
58
+ * Deletes an object or all objects under a prefix (directory).
59
+ */
60
+ remove(file: FileRef): Promise<boolean>;
61
+ /**
62
+ * Opens an S3 object for random access.
63
+ * Creates the object if it doesn't exist.
64
+ */
65
+ open(file: FileRef): Promise<FileHandle>;
66
+ /**
67
+ * Moves an object using Copy + Delete.
68
+ */
69
+ move(source: FileRef, target: FileRef): Promise<boolean>;
70
+ /**
71
+ * Copies an object or directory using S3 CopyObject.
72
+ */
73
+ copy(source: FileRef, target: FileRef, options?: CopyOptions): Promise<boolean>;
74
+ /**
75
+ * Creates a directory marker in S3.
76
+ *
77
+ * S3 doesn't have real directories, but some tools expect
78
+ * a zero-byte object with trailing slash as a directory marker.
79
+ * However, for our purposes we don't actually need to create markers
80
+ * since directories are implied by files within them.
81
+ */
82
+ mkdir(file: FileRef): Promise<void>;
83
+ }
84
+ //# sourceMappingURL=s3-files-api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"s3-files-api.d.ts","sourceRoot":"","sources":["../src/s3-files-api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAQnD,OAAO,KAAK,EACV,WAAW,EACX,UAAU,EACV,QAAQ,EACR,OAAO,EACP,SAAS,EACT,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAInC,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,MAAM,EAAE,QAAQ,CAAC;IAEjB;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;IAEf;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,qBAAa,UAAW,YAAW,SAAS;IAC1C,OAAO,CAAC,MAAM,CAAW;IACzB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,iBAAiB,CAAS;gBAEtB,OAAO,EAAE,iBAAiB;IAQtC;;;OAGG;IACH,OAAO,CAAC,UAAU;IAalB;;OAEG;IACH,OAAO,CAAC,SAAS;IAWjB;;;;;OAKG;IACI,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,GAAE,WAAgB,GAAG,cAAc,CAAC,QAAQ,CAAC;IAqE/E;;OAEG;IACG,KAAK,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,GAAG,SAAS,CAAC;IA+DzD;;OAEG;IACG,MAAM,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAwE7C;;;OAGG;IACG,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,UAAU,CAAC;IA+B9C;;OAEG;IACG,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAO9D;;OAEG;IACG,IAAI,CACR,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,OAAO,EACf,OAAO,GAAE,WAAgB,GACxB,OAAO,CAAC,OAAO,CAAC;IAiEnB;;;;;;;OAOG;IACG,KAAK,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;CAgB1C"}
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@statewalker/webrun-files-s3",
3
+ "version": "0.5.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "S3-backed IFilesApi implementation for webrun-files",
7
+ "homepage": "https://github.com/statewalker/webrun-files",
8
+ "author": {
9
+ "name": "Mikhail Kotelnikov",
10
+ "email": "mikhail.kotelnikov@gmail.com"
11
+ },
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git@github.com:statewalker/webrun-files.git"
16
+ },
17
+ "main": "./dist/cjs/index.cjs",
18
+ "module": "./dist/esm/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/esm/index.js",
24
+ "require": "./dist/cjs/index.cjs"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "src"
30
+ ],
31
+ "dependencies": {
32
+ "@statewalker/webrun-files": "0.5.0"
33
+ },
34
+ "peerDependencies": {
35
+ "@aws-sdk/client-s3": "^3.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@aws-sdk/client-s3": "^3.0.0",
39
+ "@testcontainers/minio": "^10.0.0",
40
+ "@types/node": "^24.10.1",
41
+ "rimraf": "^6.1.2",
42
+ "rolldown": "^1.0.0-beta.53",
43
+ "testcontainers": "^10.0.0",
44
+ "typescript": "^5.6.0",
45
+ "vitest": "^4.0.12",
46
+ "@statewalker/webrun-files-tests": "1.0.0"
47
+ },
48
+ "sideEffects": false,
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "scripts": {
53
+ "build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
54
+ "test": "vitest run",
55
+ "lint": "biome lint src tests"
56
+ }
57
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * webrun-files-s3 - S3-backed IFilesApi implementation
3
+ */
4
+
5
+ export { S3FilesApi, type S3FilesApiOptions } from "./s3-files-api.js";
6
+ export { S3FileHandle, type S3FileHandleOptions } from "./s3-file-handle.js";
@@ -0,0 +1,491 @@
1
+ /**
2
+ * S3 FileHandle implementation
3
+ *
4
+ * Provides random access read/write operations for S3 objects.
5
+ * Uses HTTP Range headers for efficient partial reads.
6
+ * Since S3 objects are immutable, writes require re-uploading the entire object.
7
+ */
8
+
9
+ import type { S3Client } from "@aws-sdk/client-s3";
10
+ import {
11
+ AbortMultipartUploadCommand,
12
+ CompleteMultipartUploadCommand,
13
+ CreateMultipartUploadCommand,
14
+ GetObjectCommand,
15
+ UploadPartCommand,
16
+ UploadPartCopyCommand,
17
+ } from "@aws-sdk/client-s3";
18
+ import type {
19
+ AppendOptions,
20
+ BinaryStream,
21
+ FileHandle,
22
+ ReadStreamOptions,
23
+ WriteStreamOptions,
24
+ } from "@statewalker/webrun-files";
25
+ import { toBinaryAsyncIterable } from "@statewalker/webrun-files";
26
+
27
+ export interface S3FileHandleOptions {
28
+ client: S3Client;
29
+ bucket: string;
30
+ key: string;
31
+ size: number;
32
+ multipartPartSize: number;
33
+ }
34
+
35
+ /**
36
+ * Helper to stream S3 response body as async iterable of Uint8Array chunks.
37
+ * Handles both Node.js Readable streams and browser ReadableStreams.
38
+ * Yields chunks as they arrive without buffering the entire response.
39
+ */
40
+ async function* streamBody(body: unknown): AsyncGenerator<Uint8Array> {
41
+ if (!body) return;
42
+
43
+ // AWS SDK v3 body supports async iteration
44
+ if (Symbol.asyncIterator in (body as object)) {
45
+ for await (const chunk of body as AsyncIterable<Uint8Array>) {
46
+ yield chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk as ArrayBufferLike);
47
+ }
48
+ return;
49
+ }
50
+
51
+ // If it's already a Uint8Array or ArrayBuffer
52
+ if (body instanceof Uint8Array) {
53
+ yield body;
54
+ return;
55
+ }
56
+ if (body instanceof ArrayBuffer) {
57
+ yield new Uint8Array(body);
58
+ return;
59
+ }
60
+
61
+ throw new Error("Unsupported body type");
62
+ }
63
+
64
+ export class S3FileHandle implements FileHandle {
65
+ private client: S3Client;
66
+ private bucket: string;
67
+ private key: string;
68
+ private _size: number;
69
+ private multipartPartSize: number;
70
+ private closed = false;
71
+
72
+ constructor(options: S3FileHandleOptions) {
73
+ this.client = options.client;
74
+ this.bucket = options.bucket;
75
+ this.key = options.key;
76
+ this._size = options.size;
77
+ this.multipartPartSize = options.multipartPartSize;
78
+ }
79
+
80
+ get size(): number {
81
+ return this._size;
82
+ }
83
+
84
+ async close(): Promise<void> {
85
+ this.closed = true;
86
+ }
87
+
88
+ /**
89
+ * Reads a range of bytes from the S3 object.
90
+ * Uses HTTP Range header for efficient partial reads.
91
+ * Streams data directly from S3 without buffering the entire response.
92
+ */
93
+ async *createReadStream(
94
+ options: ReadStreamOptions = {},
95
+ ): AsyncGenerator<Uint8Array> {
96
+ if (this.closed) {
97
+ throw new Error("FileHandle is closed");
98
+ }
99
+
100
+ const { start = 0, end = Infinity, signal } = options;
101
+ const actualEnd = Math.min(end, this._size);
102
+
103
+ if (start >= actualEnd || this._size === 0) return;
104
+
105
+ const command = new GetObjectCommand({
106
+ Bucket: this.bucket,
107
+ Key: this.key,
108
+ Range: `bytes=${start}-${actualEnd - 1}`,
109
+ });
110
+
111
+ const response = await this.client.send(command, { abortSignal: signal });
112
+
113
+ if (!response.Body) return;
114
+
115
+ // Stream directly from S3 response body
116
+ for await (const chunk of streamBody(response.Body)) {
117
+ if (signal?.aborted) {
118
+ throw new Error("Operation aborted");
119
+ }
120
+ yield chunk;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Writes content to the S3 object using streaming.
126
+ *
127
+ * S3 objects are immutable, so this operation replaces the entire object.
128
+ * Uses streaming multipart upload to avoid buffering the entire file in memory.
129
+ * Only buffers one part at a time (5MB by default).
130
+ *
131
+ * Note: start position > 0 uses UploadPartCopy to preserve existing content
132
+ * without downloading it.
133
+ */
134
+ async createWriteStream(
135
+ data: BinaryStream,
136
+ options: WriteStreamOptions = {},
137
+ ): Promise<number> {
138
+ if (this.closed) {
139
+ throw new Error("FileHandle is closed");
140
+ }
141
+
142
+ const { start = 0, signal } = options;
143
+
144
+ // Use streaming multipart upload
145
+ return this.streamingMultipartUpload(data, start, signal);
146
+ }
147
+
148
+ /**
149
+ * Appends content to the end of the S3 object using streaming.
150
+ *
151
+ * Uses UploadPartCopy to preserve existing content without downloading it,
152
+ * then streams new data as additional parts.
153
+ */
154
+ async appendFile(data: BinaryStream, options: AppendOptions = {}): Promise<number> {
155
+ if (this.closed) {
156
+ throw new Error("FileHandle is closed");
157
+ }
158
+
159
+ const { signal } = options;
160
+
161
+ // Use streaming multipart upload starting at current size
162
+ // This will use UploadPartCopy for existing content
163
+ return this.streamingMultipartUpload(data, this._size, signal);
164
+ }
165
+
166
+ // ========================================
167
+ // Private helpers
168
+ // ========================================
169
+
170
+ /**
171
+ * Streaming multipart upload that buffers only one part at a time.
172
+ * Uses UploadPartCopy to preserve existing content without downloading it
173
+ * for large files. For small existing content (< part size), downloads and
174
+ * combines with new data to meet S3's minimum part size requirement.
175
+ */
176
+ private async streamingMultipartUpload(
177
+ data: BinaryStream,
178
+ start: number,
179
+ signal?: AbortSignal,
180
+ ): Promise<number> {
181
+ // Initiate multipart upload
182
+ const createCommand = new CreateMultipartUploadCommand({
183
+ Bucket: this.bucket,
184
+ Key: this.key,
185
+ });
186
+
187
+ const { UploadId } = await this.client.send(createCommand, {
188
+ abortSignal: signal,
189
+ });
190
+
191
+ if (!UploadId) {
192
+ throw new Error("Failed to initiate multipart upload");
193
+ }
194
+
195
+ const parts: { ETag: string; PartNumber: number }[] = [];
196
+ let partNumber = 1;
197
+ let bytesWritten = 0;
198
+ let totalSize = 0;
199
+
200
+ // Buffer for accumulating data before uploading parts
201
+ let buffer = new Uint8Array(this.multipartPartSize);
202
+ let bufferOffset = 0;
203
+
204
+ try {
205
+ // Handle preserving existing content
206
+ if (start > 0 && this._size > 0) {
207
+ const preserveEnd = Math.min(start, this._size);
208
+
209
+ // Use UploadPartCopy for full-sized parts (>= 5MB)
210
+ // Download and buffer remaining bytes that don't fill a complete part
211
+ const fullPartsEnd = Math.floor(preserveEnd / this.multipartPartSize) * this.multipartPartSize;
212
+
213
+ // Copy full parts using UploadPartCopy (no download needed)
214
+ let copyOffset = 0;
215
+ while (copyOffset < fullPartsEnd) {
216
+ if (signal?.aborted) {
217
+ throw new Error("Operation aborted");
218
+ }
219
+
220
+ const copyEnd = copyOffset + this.multipartPartSize;
221
+
222
+ const copyCommand = new UploadPartCopyCommand({
223
+ Bucket: this.bucket,
224
+ Key: this.key,
225
+ UploadId,
226
+ PartNumber: partNumber,
227
+ CopySource: `${this.bucket}/${this.key}`,
228
+ CopySourceRange: `bytes=${copyOffset}-${copyEnd - 1}`,
229
+ });
230
+
231
+ const { CopyPartResult } = await this.client.send(copyCommand, {
232
+ abortSignal: signal,
233
+ });
234
+
235
+ if (CopyPartResult?.ETag) {
236
+ parts.push({ ETag: CopyPartResult.ETag, PartNumber: partNumber });
237
+ partNumber++;
238
+ totalSize += this.multipartPartSize;
239
+ }
240
+
241
+ copyOffset = copyEnd;
242
+ }
243
+
244
+ // Download remaining bytes (less than part size) and add to buffer
245
+ if (fullPartsEnd < preserveEnd) {
246
+ const remainingBytes = await this.downloadRange(fullPartsEnd, preserveEnd, signal);
247
+ buffer.set(remainingBytes, bufferOffset);
248
+ bufferOffset += remainingBytes.length;
249
+ totalSize += remainingBytes.length;
250
+ }
251
+
252
+ // Handle padding if start is beyond current size
253
+ if (start > this._size) {
254
+ const paddingSize = start - this._size;
255
+ // Add padding to buffer, uploading full parts as needed
256
+ let paddingOffset = 0;
257
+ while (paddingOffset < paddingSize) {
258
+ const spaceInBuffer = this.multipartPartSize - bufferOffset;
259
+ const bytesToAdd = Math.min(spaceInBuffer, paddingSize - paddingOffset);
260
+
261
+ // Zero-fill the buffer (it's already zeros from allocation)
262
+ bufferOffset += bytesToAdd;
263
+ paddingOffset += bytesToAdd;
264
+ totalSize += bytesToAdd;
265
+
266
+ if (bufferOffset >= this.multipartPartSize) {
267
+ await this.uploadPart(UploadId, partNumber, buffer, parts, signal);
268
+ partNumber++;
269
+ buffer = new Uint8Array(this.multipartPartSize);
270
+ bufferOffset = 0;
271
+ }
272
+ }
273
+ }
274
+ } else if (start > 0) {
275
+ // File is empty but start > 0, need to pad with zeros
276
+ let paddingOffset = 0;
277
+ while (paddingOffset < start) {
278
+ const spaceInBuffer = this.multipartPartSize - bufferOffset;
279
+ const bytesToAdd = Math.min(spaceInBuffer, start - paddingOffset);
280
+
281
+ bufferOffset += bytesToAdd;
282
+ paddingOffset += bytesToAdd;
283
+ totalSize += bytesToAdd;
284
+
285
+ if (bufferOffset >= this.multipartPartSize) {
286
+ await this.uploadPart(UploadId, partNumber, buffer, parts, signal);
287
+ partNumber++;
288
+ buffer = new Uint8Array(this.multipartPartSize);
289
+ bufferOffset = 0;
290
+ }
291
+ }
292
+ }
293
+
294
+ // Stream new data, buffering only one part at a time
295
+ for await (const chunk of toBinaryAsyncIterable(data)) {
296
+ if (signal?.aborted) {
297
+ throw new Error("Operation aborted");
298
+ }
299
+
300
+ let chunkOffset = 0;
301
+
302
+ while (chunkOffset < chunk.length) {
303
+ const spaceInBuffer = this.multipartPartSize - bufferOffset;
304
+ const bytesToCopy = Math.min(spaceInBuffer, chunk.length - chunkOffset);
305
+
306
+ buffer.set(chunk.subarray(chunkOffset, chunkOffset + bytesToCopy), bufferOffset);
307
+ bufferOffset += bytesToCopy;
308
+ chunkOffset += bytesToCopy;
309
+ bytesWritten += bytesToCopy;
310
+ totalSize += bytesToCopy;
311
+
312
+ // Upload part when buffer is full
313
+ if (bufferOffset >= this.multipartPartSize) {
314
+ await this.uploadPart(UploadId, partNumber, buffer, parts, signal);
315
+ partNumber++;
316
+ buffer = new Uint8Array(this.multipartPartSize);
317
+ bufferOffset = 0;
318
+ }
319
+ }
320
+ }
321
+
322
+ // Upload remaining data in buffer (last part can be smaller than 5MB)
323
+ if (bufferOffset > 0) {
324
+ const lastPart = buffer.subarray(0, bufferOffset);
325
+ await this.uploadPart(UploadId, partNumber, lastPart, parts, signal);
326
+ }
327
+
328
+ // Handle empty file case - S3 requires at least one part
329
+ if (parts.length === 0) {
330
+ await this.uploadPart(UploadId, 1, new Uint8Array(0), parts, signal);
331
+ }
332
+
333
+ // Complete multipart upload
334
+ const completeCommand = new CompleteMultipartUploadCommand({
335
+ Bucket: this.bucket,
336
+ Key: this.key,
337
+ UploadId,
338
+ MultipartUpload: { Parts: parts },
339
+ });
340
+
341
+ await this.client.send(completeCommand, { abortSignal: signal });
342
+
343
+ this._size = totalSize;
344
+ return bytesWritten;
345
+ } catch (error) {
346
+ // Abort on failure to clean up
347
+ await this.abortMultipartUpload(UploadId);
348
+ throw error;
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Download a range of bytes from the S3 object.
354
+ */
355
+ private async downloadRange(
356
+ start: number,
357
+ end: number,
358
+ signal?: AbortSignal,
359
+ ): Promise<Uint8Array> {
360
+ if (end <= start) {
361
+ return new Uint8Array(0);
362
+ }
363
+
364
+ const command = new GetObjectCommand({
365
+ Bucket: this.bucket,
366
+ Key: this.key,
367
+ Range: `bytes=${start}-${end - 1}`,
368
+ });
369
+
370
+ const response = await this.client.send(command, { abortSignal: signal });
371
+
372
+ if (!response.Body) {
373
+ return new Uint8Array(0);
374
+ }
375
+
376
+ // Collect the streamed response into a single buffer
377
+ const chunks: Uint8Array[] = [];
378
+ for await (const chunk of streamBody(response.Body)) {
379
+ chunks.push(chunk);
380
+ }
381
+
382
+ if (chunks.length === 0) return new Uint8Array(0);
383
+ if (chunks.length === 1) return chunks[0];
384
+
385
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
386
+ const result = new Uint8Array(totalLength);
387
+ let offset = 0;
388
+ for (const chunk of chunks) {
389
+ result.set(chunk, offset);
390
+ offset += chunk.length;
391
+ }
392
+ return result;
393
+ }
394
+
395
+ /**
396
+ * Upload a single part and record it in the parts array.
397
+ */
398
+ private async uploadPart(
399
+ uploadId: string,
400
+ partNumber: number,
401
+ data: Uint8Array,
402
+ parts: { ETag: string; PartNumber: number }[],
403
+ signal?: AbortSignal,
404
+ ): Promise<void> {
405
+ const command = new UploadPartCommand({
406
+ Bucket: this.bucket,
407
+ Key: this.key,
408
+ UploadId: uploadId,
409
+ PartNumber: partNumber,
410
+ Body: data,
411
+ ContentLength: data.length,
412
+ });
413
+
414
+ const { ETag } = await this.client.send(command, { abortSignal: signal });
415
+
416
+ if (ETag) {
417
+ parts.push({ ETag, PartNumber: partNumber });
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Abort a multipart upload, ignoring errors.
423
+ */
424
+ private async abortMultipartUpload(uploadId: string): Promise<void> {
425
+ const command = new AbortMultipartUploadCommand({
426
+ Bucket: this.bucket,
427
+ Key: this.key,
428
+ UploadId: uploadId,
429
+ });
430
+
431
+ await this.client.send(command).catch(() => {
432
+ // Ignore abort errors
433
+ });
434
+ }
435
+
436
+ /**
437
+ * Random access read from S3 object using HTTP Range header.
438
+ */
439
+ async read(buffer: Uint8Array, offset: number, length: number, position: number): Promise<number> {
440
+ if (this.closed) {
441
+ throw new Error("FileHandle is closed");
442
+ }
443
+
444
+ // Calculate actual bytes to read
445
+ let len = Math.min(length, buffer.length - offset);
446
+ len = Math.min(len, this._size - position);
447
+ len = Math.max(0, len);
448
+
449
+ if (len === 0) {
450
+ return 0;
451
+ }
452
+
453
+ const command = new GetObjectCommand({
454
+ Bucket: this.bucket,
455
+ Key: this.key,
456
+ Range: `bytes=${position}-${position + len - 1}`,
457
+ });
458
+
459
+ const response = await this.client.send(command);
460
+
461
+ if (!response.Body) {
462
+ return 0;
463
+ }
464
+
465
+ // Collect the streamed response into a single buffer
466
+ const chunks: Uint8Array[] = [];
467
+ for await (const chunk of streamBody(response.Body)) {
468
+ chunks.push(chunk);
469
+ }
470
+
471
+ if (chunks.length === 0) return 0;
472
+
473
+ // Merge chunks if needed
474
+ let data: Uint8Array;
475
+ if (chunks.length === 1) {
476
+ data = chunks[0];
477
+ } else {
478
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
479
+ data = new Uint8Array(totalLength);
480
+ let pos = 0;
481
+ for (const chunk of chunks) {
482
+ data.set(chunk, pos);
483
+ pos += chunk.length;
484
+ }
485
+ }
486
+
487
+ const bytesToCopy = Math.min(data.length, len);
488
+ buffer.set(data.subarray(0, bytesToCopy), offset);
489
+ return bytesToCopy;
490
+ }
491
+ }