@statewalker/webrun-files-s3 0.10.2 → 0.10.3
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/README.md +93 -201
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -1,258 +1,150 @@
|
|
|
1
1
|
# @statewalker/webrun-files-s3
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## What it is
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`S3FilesApi`, an implementation of `FilesApi` from `@statewalker/webrun-files` that stores files
|
|
6
|
+
as objects in an Amazon S3 bucket or any S3-compatible store (MinIO, RustFS, Cloudflare R2,
|
|
7
|
+
DigitalOcean Spaces, Backblaze B2, Wasabi). Paths become keys under an optional prefix.
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
## Why it exists
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
S3 has keys, not files and directories, and its API (ranges, multipart uploads, server-side copy,
|
|
12
|
+
delimited listings) is far from a file system's. This package maps the file operations onto it
|
|
13
|
+
once, with streaming in both directions, so code written against `FilesApi` runs on object storage
|
|
14
|
+
unchanged.
|
|
12
15
|
|
|
13
|
-
##
|
|
16
|
+
## How to use
|
|
14
17
|
|
|
15
18
|
```bash
|
|
16
|
-
|
|
19
|
+
pnpm add @statewalker/webrun-files-s3 @statewalker/webrun-files @aws-sdk/client-s3
|
|
17
20
|
```
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
`@aws-sdk/client-s3` (`^3.1032.0`) is a peer dependency: you create and configure the `S3Client`
|
|
23
|
+
(credentials, region, endpoint) and pass it in.
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
One entry point, `@statewalker/webrun-files-s3`: ESM (`dist/esm/index.js`), CommonJS
|
|
26
|
+
(`dist/cjs/index.cjs`), types (`dist/index.d.ts`); sources in `src/`. Runs wherever the AWS SDK v3
|
|
27
|
+
runs.
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
interface S3FilesApiOptions {
|
|
31
|
+
client: S3Client; // a configured client
|
|
32
|
+
bucket: string;
|
|
33
|
+
prefix?: string; // key prefix acting as the root; leading/trailing "/" are stripped
|
|
34
|
+
multipartPartSize?: number; // bytes per multipart part, default 5 MiB (the S3 minimum)
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Examples
|
|
39
|
+
|
|
40
|
+
### AWS
|
|
22
41
|
|
|
23
42
|
```typescript
|
|
24
43
|
import { S3Client } from '@aws-sdk/client-s3';
|
|
25
44
|
import { S3FilesApi } from '@statewalker/webrun-files-s3';
|
|
26
45
|
import { readText, writeText } from '@statewalker/webrun-files';
|
|
27
46
|
|
|
28
|
-
//
|
|
29
|
-
const s3Client = new S3Client({
|
|
30
|
-
region: 'us-east-1',
|
|
31
|
-
credentials: {
|
|
32
|
-
accessKeyId: 'YOUR_ACCESS_KEY',
|
|
33
|
-
secretAccessKey: 'YOUR_SECRET_KEY',
|
|
34
|
-
},
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
// Create S3-backed files API
|
|
47
|
+
// Credentials come from the environment or the IAM role
|
|
38
48
|
const files = new S3FilesApi({
|
|
39
|
-
client:
|
|
49
|
+
client: new S3Client({ region: 'us-east-1' }),
|
|
40
50
|
bucket: 'my-bucket',
|
|
41
|
-
prefix: 'my-app/data',
|
|
51
|
+
prefix: 'my-app/data',
|
|
42
52
|
});
|
|
43
53
|
|
|
44
|
-
//
|
|
45
|
-
await
|
|
46
|
-
|
|
47
|
-
// Read a file
|
|
48
|
-
const content = await readText(files, '/docs/hello.txt');
|
|
49
|
-
console.log(content); // "Hello, S3!"
|
|
54
|
+
await writeText(files, '/docs/hello.txt', 'Hello, S3!'); // key: my-app/data/docs/hello.txt
|
|
55
|
+
console.log(await readText(files, '/docs/hello.txt'));
|
|
50
56
|
|
|
51
|
-
// List directory contents
|
|
52
57
|
for await (const entry of files.list('/docs')) {
|
|
53
58
|
console.log(entry.name, entry.kind, entry.kind === 'file' ? entry.size : '');
|
|
54
59
|
}
|
|
55
60
|
```
|
|
56
61
|
|
|
57
|
-
###
|
|
62
|
+
### An S3-compatible server on a custom endpoint
|
|
58
63
|
|
|
59
64
|
```typescript
|
|
60
|
-
import { S3Client } from '@aws-sdk/client-s3';
|
|
61
|
-
import { S3FilesApi } from '@statewalker/webrun-files-s3';
|
|
62
|
-
|
|
63
|
-
const s3Client = new S3Client({
|
|
64
|
-
endpoint: 'http://localhost:9000',
|
|
65
|
-
region: 'us-east-1',
|
|
66
|
-
credentials: {
|
|
67
|
-
accessKeyId: 'minioadmin',
|
|
68
|
-
secretAccessKey: 'minioadmin',
|
|
69
|
-
},
|
|
70
|
-
forcePathStyle: true, // Required for MinIO
|
|
71
|
-
});
|
|
72
|
-
|
|
73
65
|
const files = new S3FilesApi({
|
|
74
|
-
client:
|
|
66
|
+
client: new S3Client({
|
|
67
|
+
endpoint: 'http://localhost:9000',
|
|
68
|
+
region: 'us-east-1',
|
|
69
|
+
credentials: { accessKeyId: 'minioadmin', secretAccessKey: 'minioadmin' },
|
|
70
|
+
forcePathStyle: true, // MinIO, RustFS and most self-hosted servers need path-style URLs
|
|
71
|
+
}),
|
|
75
72
|
bucket: 'my-bucket',
|
|
76
73
|
});
|
|
77
74
|
```
|
|
78
75
|
|
|
79
|
-
###
|
|
76
|
+
### Stream a large upload and a range read
|
|
80
77
|
|
|
81
78
|
```typescript
|
|
82
|
-
|
|
83
|
-
import { S3FilesApi } from '@statewalker/webrun-files-s3';
|
|
79
|
+
await files.write('/data/large.bin', generateChunks()); // AsyncIterable<Uint8Array>
|
|
84
80
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const files = new S3FilesApi({
|
|
89
|
-
client: s3Client,
|
|
90
|
-
bucket: 'my-bucket',
|
|
91
|
-
});
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
## API Reference
|
|
95
|
-
|
|
96
|
-
### S3FilesApi
|
|
97
|
-
|
|
98
|
-
```typescript
|
|
99
|
-
interface S3FilesApiOptions {
|
|
100
|
-
/** Pre-configured S3Client instance. */
|
|
101
|
-
client: S3Client;
|
|
102
|
-
/** S3 bucket name. */
|
|
103
|
-
bucket: string;
|
|
104
|
-
/** Optional key prefix (acts as root directory). */
|
|
105
|
-
prefix?: string;
|
|
106
|
-
/** Part size for multipart uploads (default: 5MB, S3 minimum). */
|
|
107
|
-
multipartPartSize?: number;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
class S3FilesApi implements FilesApi {
|
|
111
|
-
constructor(options: S3FilesApiOptions);
|
|
112
|
-
|
|
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>;
|
|
81
|
+
for await (const chunk of files.read('/data/large.bin', { start: 1000, length: 500 })) {
|
|
82
|
+
// bytes 1000-1499, fetched with a Range header
|
|
123
83
|
}
|
|
124
84
|
```
|
|
125
85
|
|
|
126
|
-
##
|
|
86
|
+
## Internals
|
|
127
87
|
|
|
128
|
-
###
|
|
129
|
-
|
|
130
|
-
Virtual paths are mapped to S3 keys by combining the optional prefix with the path:
|
|
88
|
+
### How calls map to S3
|
|
131
89
|
|
|
132
90
|
```
|
|
133
|
-
prefix
|
|
134
|
-
path: "/docs/file.txt"
|
|
135
|
-
key: "my-app/data/docs/file.txt"
|
|
91
|
+
path "/docs/file.txt" + prefix "my-app/data" -> key "my-app/data/docs/file.txt"
|
|
136
92
|
```
|
|
137
93
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
to two things a store without real directories could otherwise leave vague:
|
|
158
|
-
|
|
159
|
-
- A **common prefix is the directory variant** - `{ kind: "directory" }` and
|
|
160
|
-
nothing more. There is no size or modification time to report for something
|
|
161
|
-
that exists only as an artefact of key naming.
|
|
162
|
-
- **Any key ending in `/` is a directory, not a file** — such as the zero-byte
|
|
163
|
-
marker `mkdir()` writes so an empty directory is visible — and it is skipped
|
|
164
|
-
when reading `Contents`. Every other key is a file, and a genuinely empty
|
|
94
|
+
| Call | S3 requests |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| `read` | `GetObject`, with `Range: bytes=start-end` when `start`/`length` are given; streams the body |
|
|
97
|
+
| `write` | under 5 MiB: one `PutObject`; otherwise `CreateMultipartUpload` + one `UploadPart` per `multipartPartSize` + `CompleteMultipartUpload`; `AbortMultipartUpload` on any error |
|
|
98
|
+
| `mkdir` | `PutObject` of an empty `dir/` marker key |
|
|
99
|
+
| `list` | `ListObjectsV2` with `Delimiter: "/"` (or none when recursive), `StartAfter` for `after` |
|
|
100
|
+
| `copy` | `CopyObject` per object, server-side |
|
|
101
|
+
| `move` | `copy`, then `remove` |
|
|
102
|
+
| `remove` | `DeleteObject` per key under the path |
|
|
103
|
+
|
|
104
|
+
A write buffers at most one part, so memory stays at about `multipartPartSize` whatever the file
|
|
105
|
+
size.
|
|
106
|
+
|
|
107
|
+
### How directories are represented
|
|
108
|
+
|
|
109
|
+
- A **common prefix is a directory**: `{ kind: "directory" }` and nothing more. There is no size or
|
|
110
|
+
time for something that exists only in key names.
|
|
111
|
+
- **A key ending in `/` is a directory**, such as the marker `mkdir()` writes so an empty
|
|
112
|
+
directory is visible; it is skipped when reading files. Every other key is a file, and an empty
|
|
165
113
|
object is the file variant with `size: 0`.
|
|
114
|
+
- With `{ recursive: true }` only files are yielded. Directories, including empty ones created by
|
|
115
|
+
`mkdir()`, do not appear in a recursive listing.
|
|
166
116
|
|
|
167
|
-
|
|
168
|
-
// List /docs with prefix "my-app"
|
|
169
|
-
// S3 request: ListObjectsV2(Prefix="my-app/docs/", Delimiter="/")
|
|
170
|
-
for await (const entry of files.list('/docs')) {
|
|
171
|
-
// entry.kind is "file" or "directory"
|
|
172
|
-
}
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
### Reading Files
|
|
176
|
-
|
|
177
|
-
Reads use `GetObject` with HTTP Range headers for efficient partial access:
|
|
178
|
-
|
|
179
|
-
```typescript
|
|
180
|
-
// Read bytes 1000-1499 from a file
|
|
181
|
-
for await (const chunk of files.read('/large-file.bin', { start: 1000, length: 500 })) {
|
|
182
|
-
// Streams directly from S3, no full file download
|
|
183
|
-
}
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
### Writing Files
|
|
187
|
-
|
|
188
|
-
Files are uploaded using a streaming approach that minimizes memory usage:
|
|
189
|
-
|
|
190
|
-
- **Small files** (< 5MB): Uses simple `PutObject` for efficiency
|
|
191
|
-
- **Large files** (>= 5MB): Uses streaming multipart upload, buffering only one part at a time
|
|
192
|
-
|
|
193
|
-
```typescript
|
|
194
|
-
// Small file - uses PutObject
|
|
195
|
-
await writeText(files, '/data/file.txt', 'small content');
|
|
196
|
-
|
|
197
|
-
// Large file - automatically uses multipart upload
|
|
198
|
-
const largeContent = generateLargeContent(); // AsyncIterable<Uint8Array>
|
|
199
|
-
await files.write('/data/large-file.bin', largeContent);
|
|
200
|
-
// Only one 5MB part is buffered at a time
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
The `multipartPartSize` option controls part size (default: 5MB, S3 minimum).
|
|
204
|
-
|
|
205
|
-
### Copy and Move
|
|
117
|
+
### Why the non-recursive listing holds entries back
|
|
206
118
|
|
|
207
|
-
-
|
|
208
|
-
|
|
209
|
-
|
|
119
|
+
S3 lists keys in UTF-8 byte order, which is the `FilesApi` order for files. But a directory `a`
|
|
120
|
+
arrives as the common prefix `a/`, after keys such as `a-x` and `a.txt` that must follow it,
|
|
121
|
+
because `-` and `.` sort before `/`. The listing therefore buffers the few entries a directory still
|
|
122
|
+
to come could precede, across page boundaries, and releases them in order. When a key `a` and keys
|
|
123
|
+
under `a/` both exist, `/a` is listed once, as the file, the same answer `stats()` gives.
|
|
210
124
|
|
|
211
|
-
###
|
|
212
|
-
|
|
213
|
-
S3 directories are implicit (they exist if files exist within them). The `mkdir()` method creates an empty directory marker object:
|
|
214
|
-
|
|
215
|
-
```typescript
|
|
216
|
-
await files.mkdir('/empty-dir');
|
|
217
|
-
// Creates object: "prefix/empty-dir/" with 0 bytes
|
|
218
|
-
```
|
|
125
|
+
### What breaks, and how it looks
|
|
219
126
|
|
|
220
|
-
|
|
127
|
+
- **`move` and directory `copy` are not atomic.** They are many `CopyObject` and `DeleteObject`
|
|
128
|
+
calls; a failure midway leaves some objects copied and the source partly removed.
|
|
129
|
+
- **Writing zero chunks creates nothing.** `write(path, [])` sends no request, so the path does not
|
|
130
|
+
exist afterwards. Pass `[new Uint8Array(0)]` to create an empty file.
|
|
131
|
+
- **Single-request copies.** Each object is copied with one `CopyObject`, which S3 limits to 5 GB
|
|
132
|
+
per object.
|
|
133
|
+
- **Errors other than not-found propagate.** `read` treats `404` and `416` as an empty read; any
|
|
134
|
+
other SDK error (access denied, bad credentials, wrong region) is thrown as the SDK's error.
|
|
221
135
|
|
|
222
|
-
|
|
136
|
+
### Testing
|
|
223
137
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
| **Backblaze B2** | Use S3-compatible endpoint |
|
|
230
|
-
| **Cloudflare R2** | Use account-specific endpoint |
|
|
231
|
-
| **Wasabi** | Use region-specific endpoint |
|
|
138
|
+
`pnpm test` runs only unit tests (a fake client exercises listing order across pages).
|
|
139
|
+
`pnpm test:integration` needs Docker: it starts
|
|
140
|
+
[RustFS](https://github.com/rustfs/rustfs) through testcontainers (`rustfs/rustfs:latest`, override
|
|
141
|
+
with `RUSTFS_IMAGE`) and runs the shared `createFilesApiTests` and `createBigFilesApiTests` suites
|
|
142
|
+
against it.
|
|
232
143
|
|
|
233
|
-
|
|
144
|
+
### Dependencies
|
|
234
145
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
[testcontainers](https://github.com/testcontainers/testcontainers-node) as a `GenericContainer` of
|
|
238
|
-
`rustfs/rustfs:latest` (override the image with `RUSTFS_IMAGE`). They run the shared
|
|
239
|
-
`createFilesApiTests` and `createBigFilesApiTests` suites from `@statewalker/webrun-files-tests`.
|
|
240
|
-
|
|
241
|
-
```bash
|
|
242
|
-
pnpm test # unit profile: excludes *.integration.test.ts, so no Docker needed (runs nothing today)
|
|
243
|
-
pnpm test:integration # Docker: RustFS container, all suites including the 256 MiB big-file suite
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
The client the tests build is the one to use against any S3-compatible server on a custom endpoint:
|
|
247
|
-
|
|
248
|
-
```typescript
|
|
249
|
-
const s3Client = new S3Client({
|
|
250
|
-
endpoint: `http://${container.getHost()}:${container.getMappedPort(9000)}`,
|
|
251
|
-
region: 'us-east-1',
|
|
252
|
-
credentials: { accessKeyId: 'rustfsadmin', secretAccessKey: 'rustfsadmin' },
|
|
253
|
-
forcePathStyle: true,
|
|
254
|
-
});
|
|
255
|
-
```
|
|
146
|
+
`@statewalker/webrun-files` for the types and path helpers; `@aws-sdk/client-s3` as a peer, so the
|
|
147
|
+
application controls the SDK version and shares one client.
|
|
256
148
|
|
|
257
149
|
## License
|
|
258
150
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-files-s3",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "S3-backed FilesApi implementation for webrun-files",
|
|
7
|
-
"
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/statewalker/webrun-files/issues"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/statewalker/webrun-files/tree/main/packages/webrun-files-s3#readme",
|
|
8
11
|
"author": {
|
|
9
12
|
"name": "Mikhail Kotelnikov",
|
|
10
13
|
"email": "mikhail.kotelnikov@gmail.com"
|
|
@@ -12,7 +15,8 @@
|
|
|
12
15
|
"license": "MIT",
|
|
13
16
|
"repository": {
|
|
14
17
|
"type": "git",
|
|
15
|
-
"url": "git
|
|
18
|
+
"url": "git+https://github.com/statewalker/webrun-files.git",
|
|
19
|
+
"directory": "packages/webrun-files-s3"
|
|
16
20
|
},
|
|
17
21
|
"main": "./dist/cjs/index.cjs",
|
|
18
22
|
"module": "./dist/esm/index.js",
|
|
@@ -30,7 +34,7 @@
|
|
|
30
34
|
"src"
|
|
31
35
|
],
|
|
32
36
|
"dependencies": {
|
|
33
|
-
"@statewalker/webrun-files": "^0.10.
|
|
37
|
+
"@statewalker/webrun-files": "^0.10.2"
|
|
34
38
|
},
|
|
35
39
|
"peerDependencies": {
|
|
36
40
|
"@aws-sdk/client-s3": "^3.1032.0"
|