@statewalker/webrun-files-s3 0.5.0 → 0.7.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2022 statewalker
3
+ Copyright (c) 2022-2026 statewalker
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,15 +1,14 @@
1
1
  # @statewalker/webrun-files-s3
2
2
 
3
- S3-backed implementation of the `IFilesApi` interface from `@statewalker/webrun-files`.
3
+ S3 implementation of the `FilesApi` interface from `@statewalker/webrun-files`.
4
4
 
5
5
  ## Overview
6
6
 
7
- This package provides an `IFilesApi` implementation that stores files in Amazon S3 or S3-compatible object storage services (MinIO, DigitalOcean Spaces, Backblaze B2, etc.). It maps filesystem-like operations to S3 API calls, providing:
7
+ This package provides a `FilesApi` implementation that stores files in Amazon S3 or S3-compatible object storage services (MinIO, DigitalOcean Spaces, Backblaze B2, Cloudflare R2, etc.). It maps filesystem-like operations to S3 API calls, providing:
8
8
 
9
9
  - **Virtual directory structure** using key prefixes
10
- - **Streaming reads** with HTTP Range headers for efficient partial access
11
- - **Streaming multipart uploads** for large files without buffering entirely in memory
12
- - **Copy operations** using S3's server-side copy (no data transfer through client)
10
+ - **Range reads** via HTTP Range headers for efficient partial access
11
+ - **Server-side copy** for copy/move operations (no data transfer through client)
13
12
 
14
13
  ## Installation
15
14
 
@@ -22,40 +21,35 @@ npm install @statewalker/webrun-files-s3 @statewalker/webrun-files @aws-sdk/clie
22
21
  ### Basic Usage
23
22
 
24
23
  ```typescript
25
- import { S3Client } from "@aws-sdk/client-s3";
26
- import { FilesApi } from "@statewalker/webrun-files";
27
- import { S3FilesApi } from "@statewalker/webrun-files-s3";
24
+ import { S3Client } from '@aws-sdk/client-s3';
25
+ import { S3FilesApi } from '@statewalker/webrun-files-s3';
26
+ import { readText, writeText } from '@statewalker/webrun-files';
28
27
 
29
28
  // Create S3 client
30
29
  const s3Client = new S3Client({
31
- region: "us-east-1",
30
+ region: 'us-east-1',
32
31
  credentials: {
33
- accessKeyId: "YOUR_ACCESS_KEY",
34
- secretAccessKey: "YOUR_SECRET_KEY",
32
+ accessKeyId: 'YOUR_ACCESS_KEY',
33
+ secretAccessKey: 'YOUR_SECRET_KEY',
35
34
  },
36
35
  });
37
36
 
38
37
  // Create S3-backed files API
39
- const s3FilesApi = new S3FilesApi({
38
+ const files = new S3FilesApi({
40
39
  client: s3Client,
41
- bucket: "my-bucket",
42
- prefix: "my-app/data", // optional key prefix
40
+ bucket: 'my-bucket',
41
+ prefix: 'my-app/data', // optional key prefix
43
42
  });
44
43
 
45
- // Wrap with FilesApi for convenience methods
46
- const api = new FilesApi(s3FilesApi);
47
-
48
44
  // Write a file
49
- await api.write("/docs/hello.txt", [
50
- new TextEncoder().encode("Hello, S3!")
51
- ]);
45
+ await writeText(files, '/docs/hello.txt', 'Hello, S3!');
52
46
 
53
47
  // Read a file
54
- const content = await api.readFile("/docs/hello.txt");
55
- console.log(new TextDecoder().decode(content)); // "Hello, S3!"
48
+ const content = await readText(files, '/docs/hello.txt');
49
+ console.log(content); // "Hello, S3!"
56
50
 
57
51
  // List directory contents
58
- for await (const entry of api.list("/docs")) {
52
+ for await (const entry of files.list('/docs')) {
59
53
  console.log(entry.name, entry.kind, entry.size);
60
54
  }
61
55
  ```
@@ -63,132 +57,69 @@ for await (const entry of api.list("/docs")) {
63
57
  ### With S3-Compatible Storage (MinIO)
64
58
 
65
59
  ```typescript
66
- import { S3Client } from "@aws-sdk/client-s3";
67
- import { S3FilesApi } from "@statewalker/webrun-files-s3";
68
- import { FilesApi } from "@statewalker/webrun-files";
60
+ import { S3Client } from '@aws-sdk/client-s3';
61
+ import { S3FilesApi } from '@statewalker/webrun-files-s3';
69
62
 
70
63
  const s3Client = new S3Client({
71
- endpoint: "http://localhost:9000",
72
- region: "us-east-1",
64
+ endpoint: 'http://localhost:9000',
65
+ region: 'us-east-1',
73
66
  credentials: {
74
- accessKeyId: "minioadmin",
75
- secretAccessKey: "minioadmin",
67
+ accessKeyId: 'minioadmin',
68
+ secretAccessKey: 'minioadmin',
76
69
  },
77
70
  forcePathStyle: true, // Required for MinIO
78
71
  });
79
72
 
80
- const s3FilesApi = new S3FilesApi({
73
+ const files = new S3FilesApi({
81
74
  client: s3Client,
82
- bucket: "my-bucket",
75
+ bucket: 'my-bucket',
83
76
  });
84
-
85
- const api = new FilesApi(s3FilesApi);
86
77
  ```
87
78
 
88
79
  ### With AWS IAM Roles (EC2, Lambda, ECS)
89
80
 
90
81
  ```typescript
91
- import { S3Client } from "@aws-sdk/client-s3";
92
- import { S3FilesApi } from "@statewalker/webrun-files-s3";
82
+ import { S3Client } from '@aws-sdk/client-s3';
83
+ import { S3FilesApi } from '@statewalker/webrun-files-s3';
93
84
 
94
85
  // Credentials are automatically loaded from environment/IAM role
95
- const s3Client = new S3Client({ region: "us-east-1" });
86
+ const s3Client = new S3Client({ region: 'us-east-1' });
96
87
 
97
- const s3FilesApi = new S3FilesApi({
88
+ const files = new S3FilesApi({
98
89
  client: s3Client,
99
- bucket: "my-bucket",
90
+ bucket: 'my-bucket',
100
91
  });
101
92
  ```
102
93
 
103
- ### Random Access with FileHandle
104
-
105
- ```typescript
106
- const api = new FilesApi(s3FilesApi);
107
-
108
- // Open a file for random access
109
- const handle = await api.open("/large-file.bin");
110
-
111
- console.log("File size:", handle.size);
112
-
113
- // Read a specific range (uses HTTP Range header)
114
- for await (const chunk of handle.createReadStream({ start: 1000, end: 2000 })) {
115
- console.log("Chunk:", chunk.length);
116
- }
117
-
118
- // Append data
119
- await handle.appendFile([new TextEncoder().encode(" - appended")]);
120
-
121
- await handle.close();
122
- ```
123
-
124
- ## Configuration
94
+ ## API Reference
125
95
 
126
- ### S3FilesApiOptions
96
+ ### S3FilesApi
127
97
 
128
98
  ```typescript
129
99
  interface S3FilesApiOptions {
130
- /**
131
- * Pre-configured S3Client instance.
132
- * Allows full control over credentials, region, endpoint.
133
- */
100
+ /** Pre-configured S3Client instance. */
134
101
  client: S3Client;
135
-
136
- /**
137
- * S3 bucket name.
138
- */
102
+ /** S3 bucket name. */
139
103
  bucket: string;
140
-
141
- /**
142
- * Optional key prefix (acts as root directory).
143
- * All paths will be relative to this prefix.
144
- * @example "projects/my-app/data"
145
- */
104
+ /** Optional key prefix (acts as root directory). */
146
105
  prefix?: string;
147
-
148
- /**
149
- * Minimum part size for multipart uploads.
150
- * S3 requires minimum 5MB for all parts except the last.
151
- * @default 5 * 1024 * 1024 (5MB)
152
- */
106
+ /** Part size for multipart uploads (default: 5MB, S3 minimum). */
153
107
  multipartPartSize?: number;
154
108
  }
155
- ```
156
109
 
157
- ## API Reference
158
-
159
- ### S3FilesApi
160
-
161
- Main class implementing `IFilesApi` for S3 storage.
162
-
163
- ```typescript
164
- class S3FilesApi implements IFilesApi {
110
+ class S3FilesApi implements FilesApi {
165
111
  constructor(options: S3FilesApiOptions);
166
112
 
167
- // Core IFilesApi methods
168
- list(file: FileRef, options?: ListOptions): AsyncGenerator<FileInfo>;
169
- stats(file: FileRef): Promise<FileInfo | undefined>;
170
- remove(file: FileRef): Promise<boolean>;
171
- open(file: FileRef): Promise<FileHandle>;
172
-
173
- // Optional methods (all implemented)
174
- mkdir(file: FileRef): Promise<void>;
175
- move(source: FileRef, target: FileRef): Promise<boolean>;
176
- copy(source: FileRef, target: FileRef, options?: CopyOptions): Promise<boolean>;
177
- }
178
- ```
179
-
180
- ### S3FileHandle
181
-
182
- File handle class for random access operations on S3 objects.
183
-
184
- ```typescript
185
- class S3FileHandle implements FileHandle {
186
- readonly size: number;
187
-
188
- close(): Promise<void>;
189
- appendFile(data: BinaryStream, options?: AppendOptions): Promise<number>;
190
- createReadStream(options?: ReadStreamOptions): AsyncGenerator<Uint8Array>;
191
- createWriteStream(data: BinaryStream, options?: WriteStreamOptions): Promise<number>;
113
+ // All FilesApi methods
114
+ read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array>;
115
+ write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
116
+ mkdir(path: string): Promise<void>;
117
+ list(path: string, options?: ListOptions): AsyncIterable<FileInfo>;
118
+ stats(path: string): Promise<FileStats | undefined>;
119
+ exists(path: string): Promise<boolean>;
120
+ remove(path: string): Promise<boolean>;
121
+ move(source: string, target: string): Promise<boolean>;
122
+ copy(source: string, target: string): Promise<boolean>;
192
123
  }
193
124
  ```
194
125
 
@@ -214,7 +145,7 @@ S3 doesn't have real directories, but this implementation simulates them using:
214
145
  ```typescript
215
146
  // List /docs with prefix "my-app"
216
147
  // S3 request: ListObjectsV2(Prefix="my-app/docs/", Delimiter="/")
217
- for await (const entry of api.list("/docs")) {
148
+ for await (const entry of files.list('/docs')) {
218
149
  // entry.kind is "file" or "directory"
219
150
  }
220
151
  ```
@@ -224,31 +155,30 @@ for await (const entry of api.list("/docs")) {
224
155
  Reads use `GetObject` with HTTP Range headers for efficient partial access:
225
156
 
226
157
  ```typescript
227
- // Read bytes 1000-1999 from a file
228
- const handle = await api.open("/large-file.bin");
229
- for await (const chunk of handle.createReadStream({ start: 1000, end: 2000 })) {
158
+ // Read bytes 1000-1499 from a file
159
+ for await (const chunk of files.read('/large-file.bin', { start: 1000, length: 500 })) {
230
160
  // Streams directly from S3, no full file download
231
161
  }
232
162
  ```
233
163
 
234
164
  ### Writing Files
235
165
 
236
- Since S3 objects are immutable, writes use streaming multipart upload:
166
+ Files are uploaded using a streaming approach that minimizes memory usage:
237
167
 
238
- 1. **CreateMultipartUpload** - Start the upload
239
- 2. **UploadPart** - Upload each 5MB+ chunk as it arrives
240
- 3. **CompleteMultipartUpload** - Finalize
168
+ - **Small files** (< 5MB): Uses simple `PutObject` for efficiency
169
+ - **Large files** (>= 5MB): Uses streaming multipart upload, buffering only one part at a time
241
170
 
242
- For partial writes (preserving content before a position):
171
+ ```typescript
172
+ // Small file - uses PutObject
173
+ await writeText(files, '/data/file.txt', 'small content');
243
174
 
244
- 1. **UploadPartCopy** - Copy existing data as parts (no download needed)
245
- 2. **UploadPart** - Upload new data
246
- 3. **CompleteMultipartUpload** - Finalize
175
+ // Large file - automatically uses multipart upload
176
+ const largeContent = generateLargeContent(); // AsyncIterable<Uint8Array>
177
+ await files.write('/data/large-file.bin', largeContent);
178
+ // Only one 5MB part is buffered at a time
179
+ ```
247
180
 
248
- This approach:
249
- - Buffers only one 5MB part at a time
250
- - Uses server-side copy to preserve existing content
251
- - Supports files of any size
181
+ The `multipartPartSize` option controls part size (default: 5MB, S3 minimum).
252
182
 
253
183
  ### Copy and Move
254
184
 
@@ -261,7 +191,7 @@ This approach:
261
191
  S3 directories are implicit (they exist if files exist within them). The `mkdir()` method creates an empty directory marker object:
262
192
 
263
193
  ```typescript
264
- await api.mkdir("/empty-dir");
194
+ await files.mkdir('/empty-dir');
265
195
  // Creates object: "prefix/empty-dir/" with 0 bytes
266
196
  ```
267
197
 
@@ -278,28 +208,20 @@ This implementation works with any S3-compatible storage:
278
208
  | **Cloudflare R2** | Use account-specific endpoint |
279
209
  | **Wasabi** | Use region-specific endpoint |
280
210
 
281
- ## Performance Considerations
282
-
283
- - **Multipart threshold**: Files larger than 5MB use multipart upload
284
- - **Part size**: Default 5MB per part (configurable via `multipartPartSize`)
285
- - **Range reads**: Always use `createReadStream({ start, end })` for partial reads
286
- - **Server-side copy**: Copy/move operations don't transfer data through client
287
- - **Pagination**: Directory listings handle pagination automatically
288
-
289
211
  ## Testing
290
212
 
291
213
  Tests use [testcontainers](https://github.com/testcontainers/testcontainers-node) with MinIO:
292
214
 
293
215
  ```typescript
294
- import { MinioContainer } from "@testcontainers/minio";
295
- import { S3Client, CreateBucketCommand } from "@aws-sdk/client-s3";
296
- import { S3FilesApi } from "@statewalker/webrun-files-s3";
216
+ import { MinioContainer } from '@testcontainers/minio';
217
+ import { S3Client, CreateBucketCommand } from '@aws-sdk/client-s3';
218
+ import { S3FilesApi } from '@statewalker/webrun-files-s3';
297
219
 
298
220
  const minioContainer = await new MinioContainer().start();
299
221
 
300
222
  const s3Client = new S3Client({
301
223
  endpoint: minioContainer.getConnectionUrl(),
302
- region: "us-east-1",
224
+ region: 'us-east-1',
303
225
  credentials: {
304
226
  accessKeyId: minioContainer.getUsername(),
305
227
  secretAccessKey: minioContainer.getPassword(),
@@ -307,11 +229,11 @@ const s3Client = new S3Client({
307
229
  forcePathStyle: true,
308
230
  });
309
231
 
310
- await s3Client.send(new CreateBucketCommand({ Bucket: "test-bucket" }));
232
+ await s3Client.send(new CreateBucketCommand({ Bucket: 'test-bucket' }));
311
233
 
312
- const s3FilesApi = new S3FilesApi({
234
+ const files = new S3FilesApi({
313
235
  client: s3Client,
314
- bucket: "test-bucket",
236
+ bucket: 'test-bucket',
315
237
  });
316
238
  ```
317
239