@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.
@@ -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
+ }