@statewalker/webrun-files-s3 0.5.0 → 0.9.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
-
157
- ## API Reference
158
-
159
- ### S3FilesApi
160
109
 
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
 
@@ -211,10 +142,21 @@ S3 doesn't have real directories, but this implementation simulates them using:
211
142
  - **ListObjectsV2** with `Delimiter="/"` to get "subdirectories" via `CommonPrefixes`
212
143
  - Files are returned from `Contents`
213
144
 
145
+ Because `FileStats` is a discriminated union, this implementation has to commit
146
+ to two things a store without real directories could otherwise leave vague:
147
+
148
+ - A **common prefix is the directory variant** - `{ kind: "directory" }` and
149
+ nothing more. There is no size or modification time to report for something
150
+ that exists only as an artefact of key naming.
151
+ - A **zero-byte key ending in `/` is a directory, not a file**. That is the
152
+ marker `mkdir()` writes so an empty directory is visible, and it is skipped
153
+ when reading `Contents`. Every other key is a file, and a genuinely empty
154
+ object is the file variant with `size: 0`.
155
+
214
156
  ```typescript
215
157
  // List /docs with prefix "my-app"
216
158
  // S3 request: ListObjectsV2(Prefix="my-app/docs/", Delimiter="/")
217
- for await (const entry of api.list("/docs")) {
159
+ for await (const entry of files.list('/docs')) {
218
160
  // entry.kind is "file" or "directory"
219
161
  }
220
162
  ```
@@ -224,31 +166,30 @@ for await (const entry of api.list("/docs")) {
224
166
  Reads use `GetObject` with HTTP Range headers for efficient partial access:
225
167
 
226
168
  ```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 })) {
169
+ // Read bytes 1000-1499 from a file
170
+ for await (const chunk of files.read('/large-file.bin', { start: 1000, length: 500 })) {
230
171
  // Streams directly from S3, no full file download
231
172
  }
232
173
  ```
233
174
 
234
175
  ### Writing Files
235
176
 
236
- Since S3 objects are immutable, writes use streaming multipart upload:
177
+ Files are uploaded using a streaming approach that minimizes memory usage:
237
178
 
238
- 1. **CreateMultipartUpload** - Start the upload
239
- 2. **UploadPart** - Upload each 5MB+ chunk as it arrives
240
- 3. **CompleteMultipartUpload** - Finalize
179
+ - **Small files** (< 5MB): Uses simple `PutObject` for efficiency
180
+ - **Large files** (>= 5MB): Uses streaming multipart upload, buffering only one part at a time
241
181
 
242
- For partial writes (preserving content before a position):
182
+ ```typescript
183
+ // Small file - uses PutObject
184
+ await writeText(files, '/data/file.txt', 'small content');
243
185
 
244
- 1. **UploadPartCopy** - Copy existing data as parts (no download needed)
245
- 2. **UploadPart** - Upload new data
246
- 3. **CompleteMultipartUpload** - Finalize
186
+ // Large file - automatically uses multipart upload
187
+ const largeContent = generateLargeContent(); // AsyncIterable<Uint8Array>
188
+ await files.write('/data/large-file.bin', largeContent);
189
+ // Only one 5MB part is buffered at a time
190
+ ```
247
191
 
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
192
+ The `multipartPartSize` option controls part size (default: 5MB, S3 minimum).
252
193
 
253
194
  ### Copy and Move
254
195
 
@@ -261,7 +202,7 @@ This approach:
261
202
  S3 directories are implicit (they exist if files exist within them). The `mkdir()` method creates an empty directory marker object:
262
203
 
263
204
  ```typescript
264
- await api.mkdir("/empty-dir");
205
+ await files.mkdir('/empty-dir');
265
206
  // Creates object: "prefix/empty-dir/" with 0 bytes
266
207
  ```
267
208
 
@@ -278,28 +219,20 @@ This implementation works with any S3-compatible storage:
278
219
  | **Cloudflare R2** | Use account-specific endpoint |
279
220
  | **Wasabi** | Use region-specific endpoint |
280
221
 
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
222
  ## Testing
290
223
 
291
224
  Tests use [testcontainers](https://github.com/testcontainers/testcontainers-node) with MinIO:
292
225
 
293
226
  ```typescript
294
- import { MinioContainer } from "@testcontainers/minio";
295
- import { S3Client, CreateBucketCommand } from "@aws-sdk/client-s3";
296
- import { S3FilesApi } from "@statewalker/webrun-files-s3";
227
+ import { MinioContainer } from '@testcontainers/minio';
228
+ import { S3Client, CreateBucketCommand } from '@aws-sdk/client-s3';
229
+ import { S3FilesApi } from '@statewalker/webrun-files-s3';
297
230
 
298
231
  const minioContainer = await new MinioContainer().start();
299
232
 
300
233
  const s3Client = new S3Client({
301
234
  endpoint: minioContainer.getConnectionUrl(),
302
- region: "us-east-1",
235
+ region: 'us-east-1',
303
236
  credentials: {
304
237
  accessKeyId: minioContainer.getUsername(),
305
238
  secretAccessKey: minioContainer.getPassword(),
@@ -307,11 +240,11 @@ const s3Client = new S3Client({
307
240
  forcePathStyle: true,
308
241
  });
309
242
 
310
- await s3Client.send(new CreateBucketCommand({ Bucket: "test-bucket" }));
243
+ await s3Client.send(new CreateBucketCommand({ Bucket: 'test-bucket' }));
311
244
 
312
- const s3FilesApi = new S3FilesApi({
245
+ const files = new S3FilesApi({
313
246
  client: s3Client,
314
- bucket: "test-bucket",
247
+ bucket: 'test-bucket',
315
248
  });
316
249
  ```
317
250