@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 +1 -1
- package/README.md +81 -148
- package/dist/cjs/index.cjs +171 -432
- package/dist/esm/index.js +160 -421
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/s3-files-api.d.ts +22 -53
- package/dist/s3-files-api.d.ts.map +1 -1
- package/package.json +15 -15
- package/src/index.ts +1 -2
- package/src/s3-files-api.ts +253 -176
- package/dist/s3-file-handle.d.ts +0 -75
- package/dist/s3-file-handle.d.ts.map +0 -1
- package/src/s3-file-handle.ts +0 -491
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
# @statewalker/webrun-files-s3
|
|
2
2
|
|
|
3
|
-
S3
|
|
3
|
+
S3 implementation of the `FilesApi` interface from `@statewalker/webrun-files`.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
This package provides
|
|
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
|
-
- **
|
|
11
|
-
- **
|
|
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
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
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:
|
|
30
|
+
region: 'us-east-1',
|
|
32
31
|
credentials: {
|
|
33
|
-
accessKeyId:
|
|
34
|
-
secretAccessKey:
|
|
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
|
|
38
|
+
const files = new S3FilesApi({
|
|
40
39
|
client: s3Client,
|
|
41
|
-
bucket:
|
|
42
|
-
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
|
|
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
|
|
55
|
-
console.log(
|
|
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
|
|
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
|
|
67
|
-
import { S3FilesApi } from
|
|
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:
|
|
72
|
-
region:
|
|
64
|
+
endpoint: 'http://localhost:9000',
|
|
65
|
+
region: 'us-east-1',
|
|
73
66
|
credentials: {
|
|
74
|
-
accessKeyId:
|
|
75
|
-
secretAccessKey:
|
|
67
|
+
accessKeyId: 'minioadmin',
|
|
68
|
+
secretAccessKey: 'minioadmin',
|
|
76
69
|
},
|
|
77
70
|
forcePathStyle: true, // Required for MinIO
|
|
78
71
|
});
|
|
79
72
|
|
|
80
|
-
const
|
|
73
|
+
const files = new S3FilesApi({
|
|
81
74
|
client: s3Client,
|
|
82
|
-
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
|
|
92
|
-
import { S3FilesApi } from
|
|
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:
|
|
86
|
+
const s3Client = new S3Client({ region: 'us-east-1' });
|
|
96
87
|
|
|
97
|
-
const
|
|
88
|
+
const files = new S3FilesApi({
|
|
98
89
|
client: s3Client,
|
|
99
|
-
bucket:
|
|
90
|
+
bucket: 'my-bucket',
|
|
100
91
|
});
|
|
101
92
|
```
|
|
102
93
|
|
|
103
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
```typescript
|
|
164
|
-
class S3FilesApi implements IFilesApi {
|
|
110
|
+
class S3FilesApi implements FilesApi {
|
|
165
111
|
constructor(options: S3FilesApiOptions);
|
|
166
112
|
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
move(source:
|
|
176
|
-
copy(source:
|
|
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
|
|
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-
|
|
228
|
-
const
|
|
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
|
-
|
|
177
|
+
Files are uploaded using a streaming approach that minimizes memory usage:
|
|
237
178
|
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
182
|
+
```typescript
|
|
183
|
+
// Small file - uses PutObject
|
|
184
|
+
await writeText(files, '/data/file.txt', 'small content');
|
|
243
185
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
295
|
-
import { S3Client, CreateBucketCommand } from
|
|
296
|
-
import { S3FilesApi } from
|
|
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:
|
|
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:
|
|
243
|
+
await s3Client.send(new CreateBucketCommand({ Bucket: 'test-bucket' }));
|
|
311
244
|
|
|
312
|
-
const
|
|
245
|
+
const files = new S3FilesApi({
|
|
313
246
|
client: s3Client,
|
|
314
|
-
bucket:
|
|
247
|
+
bucket: 'test-bucket',
|
|
315
248
|
});
|
|
316
249
|
```
|
|
317
250
|
|