@statewalker/webrun-files-s3 0.5.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 +21 -0
- package/README.md +320 -0
- package/dist/cjs/index.cjs +579 -0
- package/dist/esm/index.js +578 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/s3-file-handle.d.ts +75 -0
- package/dist/s3-file-handle.d.ts.map +1 -0
- package/dist/s3-files-api.d.ts +84 -0
- package/dist/s3-files-api.d.ts.map +1 -0
- package/package.json +57 -0
- package/src/index.ts +6 -0
- package/src/s3-file-handle.ts +491 -0
- package/src/s3-files-api.ts +455 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S3 implementation of IFilesApi
|
|
3
|
+
*
|
|
4
|
+
* Provides a filesystem-like interface over S3 object storage.
|
|
5
|
+
* Supports all IFilesApi methods including the optional move, copy, and mkdir.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { S3Client } from "@aws-sdk/client-s3";
|
|
9
|
+
import {
|
|
10
|
+
CopyObjectCommand,
|
|
11
|
+
DeleteObjectCommand,
|
|
12
|
+
HeadObjectCommand,
|
|
13
|
+
ListObjectsV2Command,
|
|
14
|
+
PutObjectCommand,
|
|
15
|
+
} from "@aws-sdk/client-s3";
|
|
16
|
+
import type {
|
|
17
|
+
CopyOptions,
|
|
18
|
+
FileHandle,
|
|
19
|
+
FileInfo,
|
|
20
|
+
FileRef,
|
|
21
|
+
IFilesApi,
|
|
22
|
+
ListOptions,
|
|
23
|
+
} from "@statewalker/webrun-files";
|
|
24
|
+
import { basename, resolveFileRef } from "@statewalker/webrun-files";
|
|
25
|
+
import { S3FileHandle } from "./s3-file-handle.js";
|
|
26
|
+
|
|
27
|
+
export interface S3FilesApiOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Pre-configured S3Client instance.
|
|
30
|
+
* Allows full control over credentials, region, endpoint.
|
|
31
|
+
*/
|
|
32
|
+
client: S3Client;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* S3 bucket name.
|
|
36
|
+
*/
|
|
37
|
+
bucket: string;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Optional key prefix (acts as root directory).
|
|
41
|
+
* All paths will be relative to this prefix.
|
|
42
|
+
* @example "projects/my-app/data"
|
|
43
|
+
*/
|
|
44
|
+
prefix?: string;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Minimum part size for multipart uploads (5MB minimum for S3).
|
|
48
|
+
* @default 5 * 1024 * 1024 (5MB)
|
|
49
|
+
*/
|
|
50
|
+
multipartPartSize?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class S3FilesApi implements IFilesApi {
|
|
54
|
+
private client: S3Client;
|
|
55
|
+
private bucket: string;
|
|
56
|
+
private prefix: string;
|
|
57
|
+
private multipartPartSize: number;
|
|
58
|
+
|
|
59
|
+
constructor(options: S3FilesApiOptions) {
|
|
60
|
+
this.client = options.client;
|
|
61
|
+
this.bucket = options.bucket;
|
|
62
|
+
// Normalize prefix: remove leading/trailing slashes
|
|
63
|
+
this.prefix = (options.prefix ?? "").replace(/^\/+|\/+$/g, "");
|
|
64
|
+
this.multipartPartSize = options.multipartPartSize ?? 5 * 1024 * 1024;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Converts a virtual path to an S3 key.
|
|
69
|
+
* @example resolveKey("/docs/file.txt") => "prefix/docs/file.txt"
|
|
70
|
+
*/
|
|
71
|
+
private resolveKey(file: FileRef): string {
|
|
72
|
+
const normalized = resolveFileRef(file);
|
|
73
|
+
// Remove leading slash and combine with prefix
|
|
74
|
+
const relativePath = normalized.startsWith("/")
|
|
75
|
+
? normalized.substring(1)
|
|
76
|
+
: normalized;
|
|
77
|
+
|
|
78
|
+
if (this.prefix) {
|
|
79
|
+
return relativePath ? `${this.prefix}/${relativePath}` : this.prefix;
|
|
80
|
+
}
|
|
81
|
+
return relativePath;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Converts an S3 key back to a virtual path.
|
|
86
|
+
*/
|
|
87
|
+
private keyToPath(key: string): string {
|
|
88
|
+
let relativePath = key;
|
|
89
|
+
if (this.prefix && key.startsWith(this.prefix)) {
|
|
90
|
+
relativePath = key.substring(this.prefix.length);
|
|
91
|
+
if (relativePath.startsWith("/")) {
|
|
92
|
+
relativePath = relativePath.substring(1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return "/" + relativePath;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Lists objects in a virtual directory.
|
|
100
|
+
*
|
|
101
|
+
* Uses S3's delimiter feature to simulate directory listing.
|
|
102
|
+
* CommonPrefixes represent subdirectories.
|
|
103
|
+
*/
|
|
104
|
+
async *list(file: FileRef, options: ListOptions = {}): AsyncGenerator<FileInfo> {
|
|
105
|
+
const { recursive = false } = options;
|
|
106
|
+
const prefix = this.resolveKey(file);
|
|
107
|
+
// For root path ("/"), resolveKey returns empty string or prefix
|
|
108
|
+
// We need to ensure proper prefix handling
|
|
109
|
+
const normalizedPrefix = prefix ? prefix + "/" : (this.prefix ? this.prefix + "/" : "");
|
|
110
|
+
|
|
111
|
+
let continuationToken: string | undefined;
|
|
112
|
+
|
|
113
|
+
do {
|
|
114
|
+
const command = new ListObjectsV2Command({
|
|
115
|
+
Bucket: this.bucket,
|
|
116
|
+
Prefix: normalizedPrefix,
|
|
117
|
+
Delimiter: recursive ? undefined : "/",
|
|
118
|
+
ContinuationToken: continuationToken,
|
|
119
|
+
MaxKeys: 1000,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const response = await this.client.send(command);
|
|
123
|
+
|
|
124
|
+
// Yield directories (CommonPrefixes)
|
|
125
|
+
if (!recursive && response.CommonPrefixes) {
|
|
126
|
+
for (const cpPrefix of response.CommonPrefixes) {
|
|
127
|
+
if (!cpPrefix.Prefix) continue;
|
|
128
|
+
|
|
129
|
+
// Remove trailing slash
|
|
130
|
+
const dirKey = cpPrefix.Prefix.endsWith("/")
|
|
131
|
+
? cpPrefix.Prefix.slice(0, -1)
|
|
132
|
+
: cpPrefix.Prefix;
|
|
133
|
+
|
|
134
|
+
const path = this.keyToPath(dirKey);
|
|
135
|
+
|
|
136
|
+
yield {
|
|
137
|
+
kind: "directory",
|
|
138
|
+
name: basename(path),
|
|
139
|
+
path,
|
|
140
|
+
lastModified: 0,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Yield files (Contents)
|
|
146
|
+
if (response.Contents) {
|
|
147
|
+
for (const obj of response.Contents) {
|
|
148
|
+
if (!obj.Key) continue;
|
|
149
|
+
|
|
150
|
+
// Skip if it's just the prefix itself (directory marker)
|
|
151
|
+
if (obj.Key === normalizedPrefix) continue;
|
|
152
|
+
if (obj.Key + "/" === normalizedPrefix) continue;
|
|
153
|
+
|
|
154
|
+
// Skip directory markers (keys ending with /)
|
|
155
|
+
if (obj.Key.endsWith("/")) continue;
|
|
156
|
+
|
|
157
|
+
const path = this.keyToPath(obj.Key);
|
|
158
|
+
|
|
159
|
+
yield {
|
|
160
|
+
kind: "file",
|
|
161
|
+
name: basename(path),
|
|
162
|
+
path,
|
|
163
|
+
size: obj.Size ?? 0,
|
|
164
|
+
lastModified: obj.LastModified?.getTime() ?? 0,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
continuationToken = response.NextContinuationToken;
|
|
170
|
+
} while (continuationToken);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Gets object metadata using HEAD request.
|
|
175
|
+
*/
|
|
176
|
+
async stats(file: FileRef): Promise<FileInfo | undefined> {
|
|
177
|
+
const normalized = resolveFileRef(file);
|
|
178
|
+
const key = this.resolveKey(file);
|
|
179
|
+
|
|
180
|
+
// Handle root directory
|
|
181
|
+
if (normalized === "/") {
|
|
182
|
+
return {
|
|
183
|
+
kind: "directory",
|
|
184
|
+
name: "",
|
|
185
|
+
path: "/",
|
|
186
|
+
lastModified: 0,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Try as file first
|
|
191
|
+
try {
|
|
192
|
+
const command = new HeadObjectCommand({
|
|
193
|
+
Bucket: this.bucket,
|
|
194
|
+
Key: key,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const response = await this.client.send(command);
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
kind: "file",
|
|
201
|
+
name: basename(normalized),
|
|
202
|
+
path: normalized,
|
|
203
|
+
size: response.ContentLength ?? 0,
|
|
204
|
+
lastModified: response.LastModified?.getTime() ?? 0,
|
|
205
|
+
type: response.ContentType,
|
|
206
|
+
};
|
|
207
|
+
} catch (error: unknown) {
|
|
208
|
+
const err = error as { name?: string; $metadata?: { httpStatusCode?: number } };
|
|
209
|
+
if (err.name !== "NotFound" && err.$metadata?.httpStatusCode !== 404) {
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Check if it's a virtual directory by listing with prefix
|
|
215
|
+
const prefix = key ? key + "/" : "";
|
|
216
|
+
const listCommand = new ListObjectsV2Command({
|
|
217
|
+
Bucket: this.bucket,
|
|
218
|
+
Prefix: prefix,
|
|
219
|
+
MaxKeys: 1,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const listResponse = await this.client.send(listCommand);
|
|
223
|
+
|
|
224
|
+
if (
|
|
225
|
+
(listResponse.Contents && listResponse.Contents.length > 0) ||
|
|
226
|
+
(listResponse.CommonPrefixes && listResponse.CommonPrefixes.length > 0)
|
|
227
|
+
) {
|
|
228
|
+
return {
|
|
229
|
+
kind: "directory",
|
|
230
|
+
name: basename(normalized),
|
|
231
|
+
path: normalized,
|
|
232
|
+
lastModified: 0,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Deletes an object or all objects under a prefix (directory).
|
|
241
|
+
*/
|
|
242
|
+
async remove(file: FileRef): Promise<boolean> {
|
|
243
|
+
const key = this.resolveKey(file);
|
|
244
|
+
|
|
245
|
+
// First try to delete as a single object
|
|
246
|
+
try {
|
|
247
|
+
// Check if object exists
|
|
248
|
+
const headCommand = new HeadObjectCommand({
|
|
249
|
+
Bucket: this.bucket,
|
|
250
|
+
Key: key,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await this.client.send(headCommand);
|
|
254
|
+
|
|
255
|
+
// Object exists, delete it
|
|
256
|
+
const deleteCommand = new DeleteObjectCommand({
|
|
257
|
+
Bucket: this.bucket,
|
|
258
|
+
Key: key,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
await this.client.send(deleteCommand);
|
|
262
|
+
return true;
|
|
263
|
+
} catch (error: unknown) {
|
|
264
|
+
const err = error as { name?: string; $metadata?: { httpStatusCode?: number } };
|
|
265
|
+
if (err.name !== "NotFound" && err.$metadata?.httpStatusCode !== 404) {
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
// Object doesn't exist, continue to check if it's a directory
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Delete all objects under this prefix (directory)
|
|
272
|
+
const prefix = key ? key + "/" : "";
|
|
273
|
+
const keysToDelete: string[] = [];
|
|
274
|
+
|
|
275
|
+
let continuationToken: string | undefined;
|
|
276
|
+
|
|
277
|
+
do {
|
|
278
|
+
const listCommand = new ListObjectsV2Command({
|
|
279
|
+
Bucket: this.bucket,
|
|
280
|
+
Prefix: prefix,
|
|
281
|
+
ContinuationToken: continuationToken,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const response = await this.client.send(listCommand);
|
|
285
|
+
|
|
286
|
+
if (response.Contents) {
|
|
287
|
+
for (const obj of response.Contents) {
|
|
288
|
+
if (obj.Key) {
|
|
289
|
+
keysToDelete.push(obj.Key);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
continuationToken = response.NextContinuationToken;
|
|
295
|
+
} while (continuationToken);
|
|
296
|
+
|
|
297
|
+
if (keysToDelete.length === 0) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Delete objects one by one (more compatible with MinIO and other S3-compatible stores)
|
|
302
|
+
for (const keyToDelete of keysToDelete) {
|
|
303
|
+
const deleteCommand = new DeleteObjectCommand({
|
|
304
|
+
Bucket: this.bucket,
|
|
305
|
+
Key: keyToDelete,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
await this.client.send(deleteCommand);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Opens an S3 object for random access.
|
|
316
|
+
* Creates the object if it doesn't exist.
|
|
317
|
+
*/
|
|
318
|
+
async open(file: FileRef): Promise<FileHandle> {
|
|
319
|
+
const key = this.resolveKey(file);
|
|
320
|
+
|
|
321
|
+
let size = 0;
|
|
322
|
+
|
|
323
|
+
// Get current size if object exists
|
|
324
|
+
try {
|
|
325
|
+
const command = new HeadObjectCommand({
|
|
326
|
+
Bucket: this.bucket,
|
|
327
|
+
Key: key,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const response = await this.client.send(command);
|
|
331
|
+
size = response.ContentLength ?? 0;
|
|
332
|
+
} catch (error: unknown) {
|
|
333
|
+
const err = error as { name?: string; $metadata?: { httpStatusCode?: number } };
|
|
334
|
+
if (err.name !== "NotFound" && err.$metadata?.httpStatusCode !== 404) {
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
// Object doesn't exist, will be created on write
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return new S3FileHandle({
|
|
341
|
+
client: this.client,
|
|
342
|
+
bucket: this.bucket,
|
|
343
|
+
key,
|
|
344
|
+
size,
|
|
345
|
+
multipartPartSize: this.multipartPartSize,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Moves an object using Copy + Delete.
|
|
351
|
+
*/
|
|
352
|
+
async move(source: FileRef, target: FileRef): Promise<boolean> {
|
|
353
|
+
const copied = await this.copy(source, target);
|
|
354
|
+
if (!copied) return false;
|
|
355
|
+
|
|
356
|
+
return this.remove(source);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Copies an object or directory using S3 CopyObject.
|
|
361
|
+
*/
|
|
362
|
+
async copy(
|
|
363
|
+
source: FileRef,
|
|
364
|
+
target: FileRef,
|
|
365
|
+
options: CopyOptions = {},
|
|
366
|
+
): Promise<boolean> {
|
|
367
|
+
const sourceKey = this.resolveKey(source);
|
|
368
|
+
const targetKey = this.resolveKey(target);
|
|
369
|
+
const { recursive = true } = options;
|
|
370
|
+
|
|
371
|
+
// Check if source is a file
|
|
372
|
+
const sourceInfo = await this.stats(source);
|
|
373
|
+
if (!sourceInfo) return false;
|
|
374
|
+
|
|
375
|
+
if (sourceInfo.kind === "file") {
|
|
376
|
+
// Copy single object
|
|
377
|
+
const command = new CopyObjectCommand({
|
|
378
|
+
Bucket: this.bucket,
|
|
379
|
+
Key: targetKey,
|
|
380
|
+
CopySource: encodeURIComponent(`${this.bucket}/${sourceKey}`),
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
await this.client.send(command);
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Copy directory (all objects under prefix)
|
|
388
|
+
if (!recursive) {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const sourcePrefix = sourceKey + "/";
|
|
393
|
+
const targetPrefix = targetKey + "/";
|
|
394
|
+
|
|
395
|
+
let continuationToken: string | undefined;
|
|
396
|
+
let copied = false;
|
|
397
|
+
|
|
398
|
+
do {
|
|
399
|
+
const listCommand = new ListObjectsV2Command({
|
|
400
|
+
Bucket: this.bucket,
|
|
401
|
+
Prefix: sourcePrefix,
|
|
402
|
+
ContinuationToken: continuationToken,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const response = await this.client.send(listCommand);
|
|
406
|
+
|
|
407
|
+
if (response.Contents) {
|
|
408
|
+
for (const obj of response.Contents) {
|
|
409
|
+
if (!obj.Key) continue;
|
|
410
|
+
|
|
411
|
+
const relativePath = obj.Key.substring(sourcePrefix.length);
|
|
412
|
+
const newKey = targetPrefix + relativePath;
|
|
413
|
+
|
|
414
|
+
const copyCommand = new CopyObjectCommand({
|
|
415
|
+
Bucket: this.bucket,
|
|
416
|
+
Key: newKey,
|
|
417
|
+
CopySource: encodeURIComponent(`${this.bucket}/${obj.Key}`),
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
await this.client.send(copyCommand);
|
|
421
|
+
copied = true;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
continuationToken = response.NextContinuationToken;
|
|
426
|
+
} while (continuationToken);
|
|
427
|
+
|
|
428
|
+
return copied;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Creates a directory marker in S3.
|
|
433
|
+
*
|
|
434
|
+
* S3 doesn't have real directories, but some tools expect
|
|
435
|
+
* a zero-byte object with trailing slash as a directory marker.
|
|
436
|
+
* However, for our purposes we don't actually need to create markers
|
|
437
|
+
* since directories are implied by files within them.
|
|
438
|
+
*/
|
|
439
|
+
async mkdir(file: FileRef): Promise<void> {
|
|
440
|
+
// For S3, directories don't really need to be created explicitly
|
|
441
|
+
// They exist implicitly when files exist inside them
|
|
442
|
+
// But we create an empty object as a marker for compatibility
|
|
443
|
+
const key = this.resolveKey(file);
|
|
444
|
+
const dirKey = key.endsWith("/") ? key : key + "/";
|
|
445
|
+
|
|
446
|
+
const command = new PutObjectCommand({
|
|
447
|
+
Bucket: this.bucket,
|
|
448
|
+
Key: dirKey,
|
|
449
|
+
Body: new Uint8Array(0),
|
|
450
|
+
ContentLength: 0,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
await this.client.send(command);
|
|
454
|
+
}
|
|
455
|
+
}
|