@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.
Files changed (2) hide show
  1. package/README.md +93 -201
  2. package/package.json +8 -4
package/README.md CHANGED
@@ -1,258 +1,150 @@
1
1
  # @statewalker/webrun-files-s3
2
2
 
3
- S3 implementation of the `FilesApi` interface from `@statewalker/webrun-files`.
3
+ ## What it is
4
4
 
5
- ## Overview
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
- 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:
9
+ ## Why it exists
8
10
 
9
- - **Virtual directory structure** using key prefixes
10
- - **Range reads** via HTTP Range headers for efficient partial access
11
- - **Server-side copy** for copy/move operations (no data transfer through client)
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
- ## Installation
16
+ ## How to use
14
17
 
15
18
  ```bash
16
- npm install @statewalker/webrun-files-s3 @statewalker/webrun-files @aws-sdk/client-s3
19
+ pnpm add @statewalker/webrun-files-s3 @statewalker/webrun-files @aws-sdk/client-s3
17
20
  ```
18
21
 
19
- ## Usage
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
- ### Basic Usage
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
- // Create S3 client
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: s3Client,
49
+ client: new S3Client({ region: 'us-east-1' }),
40
50
  bucket: 'my-bucket',
41
- prefix: 'my-app/data', // optional key prefix
51
+ prefix: 'my-app/data',
42
52
  });
43
53
 
44
- // Write a file
45
- await writeText(files, '/docs/hello.txt', 'Hello, S3!');
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
- ### With S3-Compatible Storage (MinIO)
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: s3Client,
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
- ### With AWS IAM Roles (EC2, Lambda, ECS)
76
+ ### Stream a large upload and a range read
80
77
 
81
78
  ```typescript
82
- import { S3Client } from '@aws-sdk/client-s3';
83
- import { S3FilesApi } from '@statewalker/webrun-files-s3';
79
+ await files.write('/data/large.bin', generateChunks()); // AsyncIterable<Uint8Array>
84
80
 
85
- // Credentials are automatically loaded from environment/IAM role
86
- const s3Client = new S3Client({ region: 'us-east-1' });
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
- ## How It Works
86
+ ## Internals
127
87
 
128
- ### Path to Key Mapping
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: "my-app/data"
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
- ### Directory Listing
139
-
140
- S3 doesn't have real directories, but this implementation simulates them using:
141
-
142
- - **ListObjectsV2** with `Delimiter="/"` to get "subdirectories" via `CommonPrefixes`
143
- - Files are returned from `Contents`
144
-
145
- With `{ recursive: true }` the delimiter is omitted and only file entries are yielded: directories,
146
- including empty ones created by `mkdir()`, do not appear in a recursive listing.
147
-
148
- Entries come out in path order (code-point order, as every `FilesApi` lists), and `{ after }` is
149
- sent to S3 as `StartAfter`, so resuming a listing does not re-list what came before. Keys list in
150
- UTF-8 byte order, which is already that order for files; but a directory `a` arrives as the common
151
- prefix `a/` *after* keys such as `a-x` and `a.txt` that must follow it. A non-recursive listing
152
- therefore holds back the few entries a directory still to come could precede, across page
153
- boundaries, and releases them in order. When a key `a` and keys under `a/` both exist, the path
154
- `/a` is listed once, as the file — the same answer `stats()` gives.
155
-
156
- Because `FileStats` is a discriminated union, this implementation has to commit
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
- ```typescript
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
- - **Copy** uses `CopyObject` for single files or multiple `CopyObject` calls for directories
208
- - **Move** is implemented as copy + delete
209
- - Both operations happen server-side without transferring data through the client
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
- ### Directory Creation
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
- ## S3-Compatible Storage
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
- This implementation works with any S3-compatible storage:
136
+ ### Testing
223
137
 
224
- | Service | Configuration Notes |
225
- |---------|---------------------|
226
- | **AWS S3** | Standard configuration |
227
- | **MinIO** | Set `forcePathStyle: true` |
228
- | **DigitalOcean Spaces** | Use `endpoint: "https://<region>.digitaloceanspaces.com"` |
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
- ## Testing
144
+ ### Dependencies
234
145
 
235
- The package has integration tests only. They need Docker and run against a real S3-compatible
236
- server, [RustFS](https://github.com/rustfs/rustfs), started through
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.2",
3
+ "version": "0.10.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "S3-backed FilesApi implementation for webrun-files",
7
- "homepage": "https://github.com/statewalker/webrun-files",
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@github.com:statewalker/webrun-files.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.1"
37
+ "@statewalker/webrun-files": "^0.10.2"
34
38
  },
35
39
  "peerDependencies": {
36
40
  "@aws-sdk/client-s3": "^3.1032.0"