@statewalker/webrun-files-s3 0.5.0 → 0.7.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 +70 -148
- package/dist/cjs/index.cjs +155 -408
- package/dist/esm/index.js +158 -410
- 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 +3 -3
- package/src/index.ts +1 -2
- package/src/s3-files-api.ts +243 -167
- 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/src/s3-files-api.ts
CHANGED
|
@@ -1,33 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* S3 implementation of
|
|
2
|
+
* S3 implementation of FilesApi
|
|
3
3
|
*
|
|
4
4
|
* Provides a filesystem-like interface over S3 object storage.
|
|
5
|
-
* Supports all IFilesApi methods including the optional move, copy, and mkdir.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import type { S3Client } from "@aws-sdk/client-s3";
|
|
9
8
|
import {
|
|
9
|
+
AbortMultipartUploadCommand,
|
|
10
|
+
CompleteMultipartUploadCommand,
|
|
10
11
|
CopyObjectCommand,
|
|
12
|
+
CreateMultipartUploadCommand,
|
|
11
13
|
DeleteObjectCommand,
|
|
14
|
+
GetObjectCommand,
|
|
12
15
|
HeadObjectCommand,
|
|
13
16
|
ListObjectsV2Command,
|
|
14
17
|
PutObjectCommand,
|
|
18
|
+
UploadPartCommand,
|
|
15
19
|
} from "@aws-sdk/client-s3";
|
|
16
20
|
import type {
|
|
17
|
-
CopyOptions,
|
|
18
|
-
FileHandle,
|
|
19
21
|
FileInfo,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
FileStats,
|
|
23
|
+
FilesApi,
|
|
22
24
|
ListOptions,
|
|
25
|
+
ReadOptions,
|
|
23
26
|
} from "@statewalker/webrun-files";
|
|
24
|
-
import { basename,
|
|
25
|
-
|
|
27
|
+
import { basename, normalizePath } from "@statewalker/webrun-files";
|
|
28
|
+
|
|
29
|
+
/** Default part size for multipart uploads: 5MB (S3 minimum) */
|
|
30
|
+
const DEFAULT_PART_SIZE = 5 * 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
/** Threshold for using multipart upload: 5MB */
|
|
33
|
+
const MULTIPART_THRESHOLD = 5 * 1024 * 1024;
|
|
26
34
|
|
|
27
35
|
export interface S3FilesApiOptions {
|
|
28
36
|
/**
|
|
29
37
|
* Pre-configured S3Client instance.
|
|
30
|
-
* Allows full control over credentials, region, endpoint.
|
|
31
38
|
*/
|
|
32
39
|
client: S3Client;
|
|
33
40
|
|
|
@@ -38,42 +45,40 @@ export interface S3FilesApiOptions {
|
|
|
38
45
|
|
|
39
46
|
/**
|
|
40
47
|
* Optional key prefix (acts as root directory).
|
|
41
|
-
* All paths will be relative to this prefix.
|
|
42
48
|
* @example "projects/my-app/data"
|
|
43
49
|
*/
|
|
44
50
|
prefix?: string;
|
|
45
51
|
|
|
46
52
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
53
|
+
* Part size for multipart uploads in bytes.
|
|
54
|
+
* S3 requires minimum 5MB for all parts except the last.
|
|
55
|
+
* @default 5242880 (5MB)
|
|
49
56
|
*/
|
|
50
57
|
multipartPartSize?: number;
|
|
51
58
|
}
|
|
52
59
|
|
|
53
|
-
|
|
60
|
+
/**
|
|
61
|
+
* S3 filesystem implementation of FilesApi.
|
|
62
|
+
*
|
|
63
|
+
* Provides a filesystem-like interface over S3 object storage.
|
|
64
|
+
* Works with AWS S3 and S3-compatible services.
|
|
65
|
+
*/
|
|
66
|
+
export class S3FilesApi implements FilesApi {
|
|
54
67
|
private client: S3Client;
|
|
55
68
|
private bucket: string;
|
|
56
69
|
private prefix: string;
|
|
57
|
-
private
|
|
70
|
+
private partSize: number;
|
|
58
71
|
|
|
59
72
|
constructor(options: S3FilesApiOptions) {
|
|
60
73
|
this.client = options.client;
|
|
61
74
|
this.bucket = options.bucket;
|
|
62
|
-
// Normalize prefix: remove leading/trailing slashes
|
|
63
75
|
this.prefix = (options.prefix ?? "").replace(/^\/+|\/+$/g, "");
|
|
64
|
-
this.
|
|
76
|
+
this.partSize = options.multipartPartSize ?? DEFAULT_PART_SIZE;
|
|
65
77
|
}
|
|
66
78
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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;
|
|
79
|
+
private resolveKey(path: string): string {
|
|
80
|
+
const normalized = normalizePath(path);
|
|
81
|
+
const relativePath = normalized.startsWith("/") ? normalized.substring(1) : normalized;
|
|
77
82
|
|
|
78
83
|
if (this.prefix) {
|
|
79
84
|
return relativePath ? `${this.prefix}/${relativePath}` : this.prefix;
|
|
@@ -81,9 +86,6 @@ export class S3FilesApi implements IFilesApi {
|
|
|
81
86
|
return relativePath;
|
|
82
87
|
}
|
|
83
88
|
|
|
84
|
-
/**
|
|
85
|
-
* Converts an S3 key back to a virtual path.
|
|
86
|
-
*/
|
|
87
89
|
private keyToPath(key: string): string {
|
|
88
90
|
let relativePath = key;
|
|
89
91
|
if (this.prefix && key.startsWith(this.prefix)) {
|
|
@@ -92,21 +94,193 @@ export class S3FilesApi implements IFilesApi {
|
|
|
92
94
|
relativePath = relativePath.substring(1);
|
|
93
95
|
}
|
|
94
96
|
}
|
|
95
|
-
return
|
|
97
|
+
return `/${relativePath}`;
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
100
|
+
async *read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array> {
|
|
101
|
+
const key = this.resolveKey(path);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const start = options?.start ?? 0;
|
|
105
|
+
const length = options?.length;
|
|
106
|
+
|
|
107
|
+
// Return empty if length is explicitly 0
|
|
108
|
+
if (length === 0) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let range: string | undefined;
|
|
113
|
+
if (start > 0 || length !== undefined) {
|
|
114
|
+
const end = length !== undefined ? start + length - 1 : "";
|
|
115
|
+
range = `bytes=${start}-${end}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const command = new GetObjectCommand({
|
|
119
|
+
Bucket: this.bucket,
|
|
120
|
+
Key: key,
|
|
121
|
+
Range: range,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const response = await this.client.send(command);
|
|
125
|
+
|
|
126
|
+
if (response.Body) {
|
|
127
|
+
const stream = response.Body as AsyncIterable<Uint8Array>;
|
|
128
|
+
for await (const chunk of stream) {
|
|
129
|
+
yield chunk;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch (error: unknown) {
|
|
133
|
+
const err = error as { name?: string; $metadata?: { httpStatusCode?: number } };
|
|
134
|
+
// Handle NotFound (404) and InvalidRange (416) - return empty
|
|
135
|
+
if (
|
|
136
|
+
err.name === "NotFound" ||
|
|
137
|
+
err.name === "InvalidRange" ||
|
|
138
|
+
err.$metadata?.httpStatusCode === 404 ||
|
|
139
|
+
err.$metadata?.httpStatusCode === 416
|
|
140
|
+
) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async write(
|
|
148
|
+
path: string,
|
|
149
|
+
content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
150
|
+
): Promise<void> {
|
|
151
|
+
const key = this.resolveKey(path);
|
|
152
|
+
|
|
153
|
+
// Buffer for accumulating small writes
|
|
154
|
+
let buffer: Uint8Array[] = [];
|
|
155
|
+
let bufferSize = 0;
|
|
156
|
+
let uploadId: string | undefined;
|
|
157
|
+
const parts: { ETag: string; PartNumber: number }[] = [];
|
|
158
|
+
let partNumber = 1;
|
|
159
|
+
|
|
160
|
+
const flushPart = async (isLast: boolean) => {
|
|
161
|
+
if (buffer.length === 0) return;
|
|
162
|
+
|
|
163
|
+
const partLength = buffer.reduce((sum, c) => sum + c.length, 0);
|
|
164
|
+
const partBuffer = new Uint8Array(partLength);
|
|
165
|
+
let offset = 0;
|
|
166
|
+
for (const chunk of buffer) {
|
|
167
|
+
partBuffer.set(chunk, offset);
|
|
168
|
+
offset += chunk.length;
|
|
169
|
+
}
|
|
170
|
+
buffer = [];
|
|
171
|
+
bufferSize = 0;
|
|
172
|
+
|
|
173
|
+
// For small files (under threshold) and this is the only/last part, use simple PutObject
|
|
174
|
+
if (!uploadId && isLast && partLength < MULTIPART_THRESHOLD) {
|
|
175
|
+
const command = new PutObjectCommand({
|
|
176
|
+
Bucket: this.bucket,
|
|
177
|
+
Key: key,
|
|
178
|
+
Body: partBuffer,
|
|
179
|
+
ContentLength: partLength,
|
|
180
|
+
});
|
|
181
|
+
await this.client.send(command);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Start multipart upload if not already started
|
|
186
|
+
if (!uploadId) {
|
|
187
|
+
const createCommand = new CreateMultipartUploadCommand({
|
|
188
|
+
Bucket: this.bucket,
|
|
189
|
+
Key: key,
|
|
190
|
+
});
|
|
191
|
+
const { UploadId } = await this.client.send(createCommand);
|
|
192
|
+
if (!UploadId) {
|
|
193
|
+
throw new Error("Failed to create multipart upload");
|
|
194
|
+
}
|
|
195
|
+
uploadId = UploadId;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Upload the part
|
|
199
|
+
const uploadCommand = new UploadPartCommand({
|
|
200
|
+
Bucket: this.bucket,
|
|
201
|
+
Key: key,
|
|
202
|
+
UploadId: uploadId,
|
|
203
|
+
PartNumber: partNumber,
|
|
204
|
+
Body: partBuffer,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const response = await this.client.send(uploadCommand);
|
|
208
|
+
if (!response.ETag) {
|
|
209
|
+
throw new Error(`Failed to upload part ${partNumber}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
parts.push({
|
|
213
|
+
ETag: response.ETag,
|
|
214
|
+
PartNumber: partNumber,
|
|
215
|
+
});
|
|
216
|
+
partNumber++;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
// Process chunks as they arrive
|
|
221
|
+
for await (const chunk of content) {
|
|
222
|
+
buffer.push(chunk);
|
|
223
|
+
bufferSize += chunk.length;
|
|
224
|
+
|
|
225
|
+
// Flush when buffer reaches part size
|
|
226
|
+
if (bufferSize >= this.partSize) {
|
|
227
|
+
await flushPart(false);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Flush remaining data
|
|
232
|
+
await flushPart(true);
|
|
233
|
+
|
|
234
|
+
// Complete multipart upload if one was started
|
|
235
|
+
if (uploadId) {
|
|
236
|
+
const completeCommand = new CompleteMultipartUploadCommand({
|
|
237
|
+
Bucket: this.bucket,
|
|
238
|
+
Key: key,
|
|
239
|
+
UploadId: uploadId,
|
|
240
|
+
MultipartUpload: {
|
|
241
|
+
Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber),
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
await this.client.send(completeCommand);
|
|
245
|
+
}
|
|
246
|
+
} catch (error) {
|
|
247
|
+
// Abort multipart upload on error
|
|
248
|
+
if (uploadId) {
|
|
249
|
+
const abortCommand = new AbortMultipartUploadCommand({
|
|
250
|
+
Bucket: this.bucket,
|
|
251
|
+
Key: key,
|
|
252
|
+
UploadId: uploadId,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
await this.client.send(abortCommand);
|
|
257
|
+
} catch {
|
|
258
|
+
// Ignore abort errors
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async mkdir(path: string): Promise<void> {
|
|
267
|
+
const key = this.resolveKey(path);
|
|
268
|
+
const dirKey = key.endsWith("/") ? key : `${key}/`;
|
|
269
|
+
|
|
270
|
+
const command = new PutObjectCommand({
|
|
271
|
+
Bucket: this.bucket,
|
|
272
|
+
Key: dirKey,
|
|
273
|
+
Body: new Uint8Array(0),
|
|
274
|
+
ContentLength: 0,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await this.client.send(command);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async *list(path: string, options?: ListOptions): AsyncIterable<FileInfo> {
|
|
281
|
+
const recursive = options?.recursive ?? false;
|
|
282
|
+
const prefix = this.resolveKey(path);
|
|
283
|
+
const normalizedPrefix = prefix ? `${prefix}/` : this.prefix ? `${this.prefix}/` : "";
|
|
110
284
|
|
|
111
285
|
let continuationToken: string | undefined;
|
|
112
286
|
|
|
@@ -121,45 +295,38 @@ export class S3FilesApi implements IFilesApi {
|
|
|
121
295
|
|
|
122
296
|
const response = await this.client.send(command);
|
|
123
297
|
|
|
124
|
-
// Yield directories (CommonPrefixes)
|
|
125
298
|
if (!recursive && response.CommonPrefixes) {
|
|
126
299
|
for (const cpPrefix of response.CommonPrefixes) {
|
|
127
300
|
if (!cpPrefix.Prefix) continue;
|
|
128
301
|
|
|
129
|
-
// Remove trailing slash
|
|
130
302
|
const dirKey = cpPrefix.Prefix.endsWith("/")
|
|
131
303
|
? cpPrefix.Prefix.slice(0, -1)
|
|
132
304
|
: cpPrefix.Prefix;
|
|
133
305
|
|
|
134
|
-
const
|
|
306
|
+
const dirPath = this.keyToPath(dirKey);
|
|
135
307
|
|
|
136
308
|
yield {
|
|
137
309
|
kind: "directory",
|
|
138
|
-
name: basename(
|
|
139
|
-
path,
|
|
310
|
+
name: basename(dirPath),
|
|
311
|
+
path: dirPath,
|
|
140
312
|
lastModified: 0,
|
|
141
313
|
};
|
|
142
314
|
}
|
|
143
315
|
}
|
|
144
316
|
|
|
145
|
-
// Yield files (Contents)
|
|
146
317
|
if (response.Contents) {
|
|
147
318
|
for (const obj of response.Contents) {
|
|
148
319
|
if (!obj.Key) continue;
|
|
149
|
-
|
|
150
|
-
// Skip if it's just the prefix itself (directory marker)
|
|
151
320
|
if (obj.Key === normalizedPrefix) continue;
|
|
152
|
-
if (obj.Key
|
|
153
|
-
|
|
154
|
-
// Skip directory markers (keys ending with /)
|
|
321
|
+
if (`${obj.Key}/` === normalizedPrefix) continue;
|
|
155
322
|
if (obj.Key.endsWith("/")) continue;
|
|
156
323
|
|
|
157
|
-
const
|
|
324
|
+
const filePath = this.keyToPath(obj.Key);
|
|
158
325
|
|
|
159
326
|
yield {
|
|
160
327
|
kind: "file",
|
|
161
|
-
name: basename(
|
|
162
|
-
path,
|
|
328
|
+
name: basename(filePath),
|
|
329
|
+
path: filePath,
|
|
163
330
|
size: obj.Size ?? 0,
|
|
164
331
|
lastModified: obj.LastModified?.getTime() ?? 0,
|
|
165
332
|
};
|
|
@@ -170,19 +337,13 @@ export class S3FilesApi implements IFilesApi {
|
|
|
170
337
|
} while (continuationToken);
|
|
171
338
|
}
|
|
172
339
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
async stats(file: FileRef): Promise<FileInfo | undefined> {
|
|
177
|
-
const normalized = resolveFileRef(file);
|
|
178
|
-
const key = this.resolveKey(file);
|
|
340
|
+
async stats(path: string): Promise<FileStats | undefined> {
|
|
341
|
+
const normalized = normalizePath(path);
|
|
342
|
+
const key = this.resolveKey(path);
|
|
179
343
|
|
|
180
|
-
// Handle root directory
|
|
181
344
|
if (normalized === "/") {
|
|
182
345
|
return {
|
|
183
346
|
kind: "directory",
|
|
184
|
-
name: "",
|
|
185
|
-
path: "/",
|
|
186
347
|
lastModified: 0,
|
|
187
348
|
};
|
|
188
349
|
}
|
|
@@ -198,11 +359,8 @@ export class S3FilesApi implements IFilesApi {
|
|
|
198
359
|
|
|
199
360
|
return {
|
|
200
361
|
kind: "file",
|
|
201
|
-
name: basename(normalized),
|
|
202
|
-
path: normalized,
|
|
203
362
|
size: response.ContentLength ?? 0,
|
|
204
363
|
lastModified: response.LastModified?.getTime() ?? 0,
|
|
205
|
-
type: response.ContentType,
|
|
206
364
|
};
|
|
207
365
|
} catch (error: unknown) {
|
|
208
366
|
const err = error as { name?: string; $metadata?: { httpStatusCode?: number } };
|
|
@@ -211,8 +369,8 @@ export class S3FilesApi implements IFilesApi {
|
|
|
211
369
|
}
|
|
212
370
|
}
|
|
213
371
|
|
|
214
|
-
// Check if it's a virtual directory
|
|
215
|
-
const prefix = key ? key
|
|
372
|
+
// Check if it's a virtual directory
|
|
373
|
+
const prefix = key ? `${key}/` : "";
|
|
216
374
|
const listCommand = new ListObjectsV2Command({
|
|
217
375
|
Bucket: this.bucket,
|
|
218
376
|
Prefix: prefix,
|
|
@@ -227,8 +385,6 @@ export class S3FilesApi implements IFilesApi {
|
|
|
227
385
|
) {
|
|
228
386
|
return {
|
|
229
387
|
kind: "directory",
|
|
230
|
-
name: basename(normalized),
|
|
231
|
-
path: normalized,
|
|
232
388
|
lastModified: 0,
|
|
233
389
|
};
|
|
234
390
|
}
|
|
@@ -236,15 +392,16 @@ export class S3FilesApi implements IFilesApi {
|
|
|
236
392
|
return undefined;
|
|
237
393
|
}
|
|
238
394
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
395
|
+
async exists(path: string): Promise<boolean> {
|
|
396
|
+
const stats = await this.stats(path);
|
|
397
|
+
return stats !== undefined;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async remove(path: string): Promise<boolean> {
|
|
401
|
+
const key = this.resolveKey(path);
|
|
244
402
|
|
|
245
403
|
// First try to delete as a single object
|
|
246
404
|
try {
|
|
247
|
-
// Check if object exists
|
|
248
405
|
const headCommand = new HeadObjectCommand({
|
|
249
406
|
Bucket: this.bucket,
|
|
250
407
|
Key: key,
|
|
@@ -252,7 +409,6 @@ export class S3FilesApi implements IFilesApi {
|
|
|
252
409
|
|
|
253
410
|
await this.client.send(headCommand);
|
|
254
411
|
|
|
255
|
-
// Object exists, delete it
|
|
256
412
|
const deleteCommand = new DeleteObjectCommand({
|
|
257
413
|
Bucket: this.bucket,
|
|
258
414
|
Key: key,
|
|
@@ -265,11 +421,10 @@ export class S3FilesApi implements IFilesApi {
|
|
|
265
421
|
if (err.name !== "NotFound" && err.$metadata?.httpStatusCode !== 404) {
|
|
266
422
|
throw error;
|
|
267
423
|
}
|
|
268
|
-
// Object doesn't exist, continue to check if it's a directory
|
|
269
424
|
}
|
|
270
425
|
|
|
271
426
|
// Delete all objects under this prefix (directory)
|
|
272
|
-
const prefix = key ? key
|
|
427
|
+
const prefix = key ? `${key}/` : "";
|
|
273
428
|
const keysToDelete: string[] = [];
|
|
274
429
|
|
|
275
430
|
let continuationToken: string | undefined;
|
|
@@ -298,7 +453,6 @@ export class S3FilesApi implements IFilesApi {
|
|
|
298
453
|
return false;
|
|
299
454
|
}
|
|
300
455
|
|
|
301
|
-
// Delete objects one by one (more compatible with MinIO and other S3-compatible stores)
|
|
302
456
|
for (const keyToDelete of keysToDelete) {
|
|
303
457
|
const deleteCommand = new DeleteObjectCommand({
|
|
304
458
|
Bucket: this.bucket,
|
|
@@ -311,69 +465,20 @@ export class S3FilesApi implements IFilesApi {
|
|
|
311
465
|
return true;
|
|
312
466
|
}
|
|
313
467
|
|
|
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> {
|
|
468
|
+
async move(source: string, target: string): Promise<boolean> {
|
|
353
469
|
const copied = await this.copy(source, target);
|
|
354
470
|
if (!copied) return false;
|
|
355
|
-
|
|
356
471
|
return this.remove(source);
|
|
357
472
|
}
|
|
358
473
|
|
|
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> {
|
|
474
|
+
async copy(source: string, target: string): Promise<boolean> {
|
|
367
475
|
const sourceKey = this.resolveKey(source);
|
|
368
476
|
const targetKey = this.resolveKey(target);
|
|
369
|
-
const { recursive = true } = options;
|
|
370
477
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
if (!sourceInfo) return false;
|
|
478
|
+
const sourceStats = await this.stats(source);
|
|
479
|
+
if (!sourceStats) return false;
|
|
374
480
|
|
|
375
|
-
if (
|
|
376
|
-
// Copy single object
|
|
481
|
+
if (sourceStats.kind === "file") {
|
|
377
482
|
const command = new CopyObjectCommand({
|
|
378
483
|
Bucket: this.bucket,
|
|
379
484
|
Key: targetKey,
|
|
@@ -385,12 +490,8 @@ export class S3FilesApi implements IFilesApi {
|
|
|
385
490
|
}
|
|
386
491
|
|
|
387
492
|
// Copy directory (all objects under prefix)
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
const sourcePrefix = sourceKey + "/";
|
|
393
|
-
const targetPrefix = targetKey + "/";
|
|
493
|
+
const sourcePrefix = `${sourceKey}/`;
|
|
494
|
+
const targetPrefix = `${targetKey}/`;
|
|
394
495
|
|
|
395
496
|
let continuationToken: string | undefined;
|
|
396
497
|
let copied = false;
|
|
@@ -427,29 +528,4 @@ export class S3FilesApi implements IFilesApi {
|
|
|
427
528
|
|
|
428
529
|
return copied;
|
|
429
530
|
}
|
|
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
531
|
}
|
package/dist/s3-file-handle.d.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* S3 FileHandle implementation
|
|
3
|
-
*
|
|
4
|
-
* Provides random access read/write operations for S3 objects.
|
|
5
|
-
* Uses HTTP Range headers for efficient partial reads.
|
|
6
|
-
* Since S3 objects are immutable, writes require re-uploading the entire object.
|
|
7
|
-
*/
|
|
8
|
-
import type { S3Client } from "@aws-sdk/client-s3";
|
|
9
|
-
import type { AppendOptions, BinaryStream, FileHandle, ReadStreamOptions, WriteStreamOptions } from "@statewalker/webrun-files";
|
|
10
|
-
export interface S3FileHandleOptions {
|
|
11
|
-
client: S3Client;
|
|
12
|
-
bucket: string;
|
|
13
|
-
key: string;
|
|
14
|
-
size: number;
|
|
15
|
-
multipartPartSize: number;
|
|
16
|
-
}
|
|
17
|
-
export declare class S3FileHandle implements FileHandle {
|
|
18
|
-
private client;
|
|
19
|
-
private bucket;
|
|
20
|
-
private key;
|
|
21
|
-
private _size;
|
|
22
|
-
private multipartPartSize;
|
|
23
|
-
private closed;
|
|
24
|
-
constructor(options: S3FileHandleOptions);
|
|
25
|
-
get size(): number;
|
|
26
|
-
close(): Promise<void>;
|
|
27
|
-
/**
|
|
28
|
-
* Reads a range of bytes from the S3 object.
|
|
29
|
-
* Uses HTTP Range header for efficient partial reads.
|
|
30
|
-
* Streams data directly from S3 without buffering the entire response.
|
|
31
|
-
*/
|
|
32
|
-
createReadStream(options?: ReadStreamOptions): AsyncGenerator<Uint8Array>;
|
|
33
|
-
/**
|
|
34
|
-
* Writes content to the S3 object using streaming.
|
|
35
|
-
*
|
|
36
|
-
* S3 objects are immutable, so this operation replaces the entire object.
|
|
37
|
-
* Uses streaming multipart upload to avoid buffering the entire file in memory.
|
|
38
|
-
* Only buffers one part at a time (5MB by default).
|
|
39
|
-
*
|
|
40
|
-
* Note: start position > 0 uses UploadPartCopy to preserve existing content
|
|
41
|
-
* without downloading it.
|
|
42
|
-
*/
|
|
43
|
-
createWriteStream(data: BinaryStream, options?: WriteStreamOptions): Promise<number>;
|
|
44
|
-
/**
|
|
45
|
-
* Appends content to the end of the S3 object using streaming.
|
|
46
|
-
*
|
|
47
|
-
* Uses UploadPartCopy to preserve existing content without downloading it,
|
|
48
|
-
* then streams new data as additional parts.
|
|
49
|
-
*/
|
|
50
|
-
appendFile(data: BinaryStream, options?: AppendOptions): Promise<number>;
|
|
51
|
-
/**
|
|
52
|
-
* Streaming multipart upload that buffers only one part at a time.
|
|
53
|
-
* Uses UploadPartCopy to preserve existing content without downloading it
|
|
54
|
-
* for large files. For small existing content (< part size), downloads and
|
|
55
|
-
* combines with new data to meet S3's minimum part size requirement.
|
|
56
|
-
*/
|
|
57
|
-
private streamingMultipartUpload;
|
|
58
|
-
/**
|
|
59
|
-
* Download a range of bytes from the S3 object.
|
|
60
|
-
*/
|
|
61
|
-
private downloadRange;
|
|
62
|
-
/**
|
|
63
|
-
* Upload a single part and record it in the parts array.
|
|
64
|
-
*/
|
|
65
|
-
private uploadPart;
|
|
66
|
-
/**
|
|
67
|
-
* Abort a multipart upload, ignoring errors.
|
|
68
|
-
*/
|
|
69
|
-
private abortMultipartUpload;
|
|
70
|
-
/**
|
|
71
|
-
* Random access read from S3 object using HTTP Range header.
|
|
72
|
-
*/
|
|
73
|
-
read(buffer: Uint8Array, offset: number, length: number, position: number): Promise<number>;
|
|
74
|
-
}
|
|
75
|
-
//# sourceMappingURL=s3-file-handle.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"s3-file-handle.d.ts","sourceRoot":"","sources":["../src/s3-file-handle.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AASnD,OAAO,KAAK,EACV,aAAa,EACb,YAAY,EACZ,UAAU,EACV,iBAAiB,EACjB,kBAAkB,EACnB,MAAM,2BAA2B,CAAC;AAGnC,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,QAAQ,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AA+BD,qBAAa,YAAa,YAAW,UAAU;IAC7C,OAAO,CAAC,MAAM,CAAW;IACzB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,KAAK,CAAS;IACtB,OAAO,CAAC,iBAAiB,CAAS;IAClC,OAAO,CAAC,MAAM,CAAS;gBAEX,OAAO,EAAE,mBAAmB;IAQxC,IAAI,IAAI,IAAI,MAAM,CAEjB;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAI5B;;;;OAIG;IACI,gBAAgB,CACrB,OAAO,GAAE,iBAAsB,GAC9B,cAAc,CAAC,UAAU,CAAC;IA6B7B;;;;;;;;;OASG;IACG,iBAAiB,CACrB,IAAI,EAAE,YAAY,EAClB,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,MAAM,CAAC;IAWlB;;;;;OAKG;IACG,UAAU,CAAC,IAAI,EAAE,YAAY,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,MAAM,CAAC;IAgBlF;;;;;OAKG;YACW,wBAAwB;IAgLtC;;OAEG;YACW,aAAa;IAwC3B;;OAEG;YACW,UAAU;IAuBxB;;OAEG;YACW,oBAAoB;IAYlC;;OAEG;IACG,IAAI,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAoDlG"}
|