@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,579 @@
|
|
|
1
|
+
let __aws_sdk_client_s3 = require("@aws-sdk/client-s3");
|
|
2
|
+
let __statewalker_webrun_files = require("@statewalker/webrun-files");
|
|
3
|
+
|
|
4
|
+
//#region src/s3-file-handle.ts
|
|
5
|
+
/**
|
|
6
|
+
* Helper to stream S3 response body as async iterable of Uint8Array chunks.
|
|
7
|
+
* Handles both Node.js Readable streams and browser ReadableStreams.
|
|
8
|
+
* Yields chunks as they arrive without buffering the entire response.
|
|
9
|
+
*/
|
|
10
|
+
async function* streamBody(body) {
|
|
11
|
+
if (!body) return;
|
|
12
|
+
if (Symbol.asyncIterator in body) {
|
|
13
|
+
for await (const chunk of body) yield chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (body instanceof Uint8Array) {
|
|
17
|
+
yield body;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (body instanceof ArrayBuffer) {
|
|
21
|
+
yield new Uint8Array(body);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
throw new Error("Unsupported body type");
|
|
25
|
+
}
|
|
26
|
+
var S3FileHandle = class {
|
|
27
|
+
client;
|
|
28
|
+
bucket;
|
|
29
|
+
key;
|
|
30
|
+
_size;
|
|
31
|
+
multipartPartSize;
|
|
32
|
+
closed = false;
|
|
33
|
+
constructor(options) {
|
|
34
|
+
this.client = options.client;
|
|
35
|
+
this.bucket = options.bucket;
|
|
36
|
+
this.key = options.key;
|
|
37
|
+
this._size = options.size;
|
|
38
|
+
this.multipartPartSize = options.multipartPartSize;
|
|
39
|
+
}
|
|
40
|
+
get size() {
|
|
41
|
+
return this._size;
|
|
42
|
+
}
|
|
43
|
+
async close() {
|
|
44
|
+
this.closed = true;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Reads a range of bytes from the S3 object.
|
|
48
|
+
* Uses HTTP Range header for efficient partial reads.
|
|
49
|
+
* Streams data directly from S3 without buffering the entire response.
|
|
50
|
+
*/
|
|
51
|
+
async *createReadStream(options = {}) {
|
|
52
|
+
if (this.closed) throw new Error("FileHandle is closed");
|
|
53
|
+
const { start = 0, end = Infinity, signal } = options;
|
|
54
|
+
const actualEnd = Math.min(end, this._size);
|
|
55
|
+
if (start >= actualEnd || this._size === 0) return;
|
|
56
|
+
const command = new __aws_sdk_client_s3.GetObjectCommand({
|
|
57
|
+
Bucket: this.bucket,
|
|
58
|
+
Key: this.key,
|
|
59
|
+
Range: `bytes=${start}-${actualEnd - 1}`
|
|
60
|
+
});
|
|
61
|
+
const response = await this.client.send(command, { abortSignal: signal });
|
|
62
|
+
if (!response.Body) return;
|
|
63
|
+
for await (const chunk of streamBody(response.Body)) {
|
|
64
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
65
|
+
yield chunk;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Writes content to the S3 object using streaming.
|
|
70
|
+
*
|
|
71
|
+
* S3 objects are immutable, so this operation replaces the entire object.
|
|
72
|
+
* Uses streaming multipart upload to avoid buffering the entire file in memory.
|
|
73
|
+
* Only buffers one part at a time (5MB by default).
|
|
74
|
+
*
|
|
75
|
+
* Note: start position > 0 uses UploadPartCopy to preserve existing content
|
|
76
|
+
* without downloading it.
|
|
77
|
+
*/
|
|
78
|
+
async createWriteStream(data, options = {}) {
|
|
79
|
+
if (this.closed) throw new Error("FileHandle is closed");
|
|
80
|
+
const { start = 0, signal } = options;
|
|
81
|
+
return this.streamingMultipartUpload(data, start, signal);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Appends content to the end of the S3 object using streaming.
|
|
85
|
+
*
|
|
86
|
+
* Uses UploadPartCopy to preserve existing content without downloading it,
|
|
87
|
+
* then streams new data as additional parts.
|
|
88
|
+
*/
|
|
89
|
+
async appendFile(data, options = {}) {
|
|
90
|
+
if (this.closed) throw new Error("FileHandle is closed");
|
|
91
|
+
const { signal } = options;
|
|
92
|
+
return this.streamingMultipartUpload(data, this._size, signal);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Streaming multipart upload that buffers only one part at a time.
|
|
96
|
+
* Uses UploadPartCopy to preserve existing content without downloading it
|
|
97
|
+
* for large files. For small existing content (< part size), downloads and
|
|
98
|
+
* combines with new data to meet S3's minimum part size requirement.
|
|
99
|
+
*/
|
|
100
|
+
async streamingMultipartUpload(data, start, signal) {
|
|
101
|
+
const createCommand = new __aws_sdk_client_s3.CreateMultipartUploadCommand({
|
|
102
|
+
Bucket: this.bucket,
|
|
103
|
+
Key: this.key
|
|
104
|
+
});
|
|
105
|
+
const { UploadId } = await this.client.send(createCommand, { abortSignal: signal });
|
|
106
|
+
if (!UploadId) throw new Error("Failed to initiate multipart upload");
|
|
107
|
+
const parts = [];
|
|
108
|
+
let partNumber = 1;
|
|
109
|
+
let bytesWritten = 0;
|
|
110
|
+
let totalSize = 0;
|
|
111
|
+
let buffer = new Uint8Array(this.multipartPartSize);
|
|
112
|
+
let bufferOffset = 0;
|
|
113
|
+
try {
|
|
114
|
+
if (start > 0 && this._size > 0) {
|
|
115
|
+
const preserveEnd = Math.min(start, this._size);
|
|
116
|
+
const fullPartsEnd = Math.floor(preserveEnd / this.multipartPartSize) * this.multipartPartSize;
|
|
117
|
+
let copyOffset = 0;
|
|
118
|
+
while (copyOffset < fullPartsEnd) {
|
|
119
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
120
|
+
const copyEnd = copyOffset + this.multipartPartSize;
|
|
121
|
+
const copyCommand = new __aws_sdk_client_s3.UploadPartCopyCommand({
|
|
122
|
+
Bucket: this.bucket,
|
|
123
|
+
Key: this.key,
|
|
124
|
+
UploadId,
|
|
125
|
+
PartNumber: partNumber,
|
|
126
|
+
CopySource: `${this.bucket}/${this.key}`,
|
|
127
|
+
CopySourceRange: `bytes=${copyOffset}-${copyEnd - 1}`
|
|
128
|
+
});
|
|
129
|
+
const { CopyPartResult } = await this.client.send(copyCommand, { abortSignal: signal });
|
|
130
|
+
if (CopyPartResult?.ETag) {
|
|
131
|
+
parts.push({
|
|
132
|
+
ETag: CopyPartResult.ETag,
|
|
133
|
+
PartNumber: partNumber
|
|
134
|
+
});
|
|
135
|
+
partNumber++;
|
|
136
|
+
totalSize += this.multipartPartSize;
|
|
137
|
+
}
|
|
138
|
+
copyOffset = copyEnd;
|
|
139
|
+
}
|
|
140
|
+
if (fullPartsEnd < preserveEnd) {
|
|
141
|
+
const remainingBytes = await this.downloadRange(fullPartsEnd, preserveEnd, signal);
|
|
142
|
+
buffer.set(remainingBytes, bufferOffset);
|
|
143
|
+
bufferOffset += remainingBytes.length;
|
|
144
|
+
totalSize += remainingBytes.length;
|
|
145
|
+
}
|
|
146
|
+
if (start > this._size) {
|
|
147
|
+
const paddingSize = start - this._size;
|
|
148
|
+
let paddingOffset = 0;
|
|
149
|
+
while (paddingOffset < paddingSize) {
|
|
150
|
+
const spaceInBuffer = this.multipartPartSize - bufferOffset;
|
|
151
|
+
const bytesToAdd = Math.min(spaceInBuffer, paddingSize - paddingOffset);
|
|
152
|
+
bufferOffset += bytesToAdd;
|
|
153
|
+
paddingOffset += bytesToAdd;
|
|
154
|
+
totalSize += bytesToAdd;
|
|
155
|
+
if (bufferOffset >= this.multipartPartSize) {
|
|
156
|
+
await this.uploadPart(UploadId, partNumber, buffer, parts, signal);
|
|
157
|
+
partNumber++;
|
|
158
|
+
buffer = new Uint8Array(this.multipartPartSize);
|
|
159
|
+
bufferOffset = 0;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} else if (start > 0) {
|
|
164
|
+
let paddingOffset = 0;
|
|
165
|
+
while (paddingOffset < start) {
|
|
166
|
+
const spaceInBuffer = this.multipartPartSize - bufferOffset;
|
|
167
|
+
const bytesToAdd = Math.min(spaceInBuffer, start - paddingOffset);
|
|
168
|
+
bufferOffset += bytesToAdd;
|
|
169
|
+
paddingOffset += bytesToAdd;
|
|
170
|
+
totalSize += bytesToAdd;
|
|
171
|
+
if (bufferOffset >= this.multipartPartSize) {
|
|
172
|
+
await this.uploadPart(UploadId, partNumber, buffer, parts, signal);
|
|
173
|
+
partNumber++;
|
|
174
|
+
buffer = new Uint8Array(this.multipartPartSize);
|
|
175
|
+
bufferOffset = 0;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for await (const chunk of (0, __statewalker_webrun_files.toBinaryAsyncIterable)(data)) {
|
|
180
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
181
|
+
let chunkOffset = 0;
|
|
182
|
+
while (chunkOffset < chunk.length) {
|
|
183
|
+
const spaceInBuffer = this.multipartPartSize - bufferOffset;
|
|
184
|
+
const bytesToCopy = Math.min(spaceInBuffer, chunk.length - chunkOffset);
|
|
185
|
+
buffer.set(chunk.subarray(chunkOffset, chunkOffset + bytesToCopy), bufferOffset);
|
|
186
|
+
bufferOffset += bytesToCopy;
|
|
187
|
+
chunkOffset += bytesToCopy;
|
|
188
|
+
bytesWritten += bytesToCopy;
|
|
189
|
+
totalSize += bytesToCopy;
|
|
190
|
+
if (bufferOffset >= this.multipartPartSize) {
|
|
191
|
+
await this.uploadPart(UploadId, partNumber, buffer, parts, signal);
|
|
192
|
+
partNumber++;
|
|
193
|
+
buffer = new Uint8Array(this.multipartPartSize);
|
|
194
|
+
bufferOffset = 0;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (bufferOffset > 0) {
|
|
199
|
+
const lastPart = buffer.subarray(0, bufferOffset);
|
|
200
|
+
await this.uploadPart(UploadId, partNumber, lastPart, parts, signal);
|
|
201
|
+
}
|
|
202
|
+
if (parts.length === 0) await this.uploadPart(UploadId, 1, new Uint8Array(0), parts, signal);
|
|
203
|
+
const completeCommand = new __aws_sdk_client_s3.CompleteMultipartUploadCommand({
|
|
204
|
+
Bucket: this.bucket,
|
|
205
|
+
Key: this.key,
|
|
206
|
+
UploadId,
|
|
207
|
+
MultipartUpload: { Parts: parts }
|
|
208
|
+
});
|
|
209
|
+
await this.client.send(completeCommand, { abortSignal: signal });
|
|
210
|
+
this._size = totalSize;
|
|
211
|
+
return bytesWritten;
|
|
212
|
+
} catch (error) {
|
|
213
|
+
await this.abortMultipartUpload(UploadId);
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Download a range of bytes from the S3 object.
|
|
219
|
+
*/
|
|
220
|
+
async downloadRange(start, end, signal) {
|
|
221
|
+
if (end <= start) return new Uint8Array(0);
|
|
222
|
+
const command = new __aws_sdk_client_s3.GetObjectCommand({
|
|
223
|
+
Bucket: this.bucket,
|
|
224
|
+
Key: this.key,
|
|
225
|
+
Range: `bytes=${start}-${end - 1}`
|
|
226
|
+
});
|
|
227
|
+
const response = await this.client.send(command, { abortSignal: signal });
|
|
228
|
+
if (!response.Body) return new Uint8Array(0);
|
|
229
|
+
const chunks = [];
|
|
230
|
+
for await (const chunk of streamBody(response.Body)) chunks.push(chunk);
|
|
231
|
+
if (chunks.length === 0) return new Uint8Array(0);
|
|
232
|
+
if (chunks.length === 1) return chunks[0];
|
|
233
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
234
|
+
const result = new Uint8Array(totalLength);
|
|
235
|
+
let offset = 0;
|
|
236
|
+
for (const chunk of chunks) {
|
|
237
|
+
result.set(chunk, offset);
|
|
238
|
+
offset += chunk.length;
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Upload a single part and record it in the parts array.
|
|
244
|
+
*/
|
|
245
|
+
async uploadPart(uploadId, partNumber, data, parts, signal) {
|
|
246
|
+
const command = new __aws_sdk_client_s3.UploadPartCommand({
|
|
247
|
+
Bucket: this.bucket,
|
|
248
|
+
Key: this.key,
|
|
249
|
+
UploadId: uploadId,
|
|
250
|
+
PartNumber: partNumber,
|
|
251
|
+
Body: data,
|
|
252
|
+
ContentLength: data.length
|
|
253
|
+
});
|
|
254
|
+
const { ETag } = await this.client.send(command, { abortSignal: signal });
|
|
255
|
+
if (ETag) parts.push({
|
|
256
|
+
ETag,
|
|
257
|
+
PartNumber: partNumber
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Abort a multipart upload, ignoring errors.
|
|
262
|
+
*/
|
|
263
|
+
async abortMultipartUpload(uploadId) {
|
|
264
|
+
const command = new __aws_sdk_client_s3.AbortMultipartUploadCommand({
|
|
265
|
+
Bucket: this.bucket,
|
|
266
|
+
Key: this.key,
|
|
267
|
+
UploadId: uploadId
|
|
268
|
+
});
|
|
269
|
+
await this.client.send(command).catch(() => {});
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Random access read from S3 object using HTTP Range header.
|
|
273
|
+
*/
|
|
274
|
+
async read(buffer, offset, length, position) {
|
|
275
|
+
if (this.closed) throw new Error("FileHandle is closed");
|
|
276
|
+
let len = Math.min(length, buffer.length - offset);
|
|
277
|
+
len = Math.min(len, this._size - position);
|
|
278
|
+
len = Math.max(0, len);
|
|
279
|
+
if (len === 0) return 0;
|
|
280
|
+
const command = new __aws_sdk_client_s3.GetObjectCommand({
|
|
281
|
+
Bucket: this.bucket,
|
|
282
|
+
Key: this.key,
|
|
283
|
+
Range: `bytes=${position}-${position + len - 1}`
|
|
284
|
+
});
|
|
285
|
+
const response = await this.client.send(command);
|
|
286
|
+
if (!response.Body) return 0;
|
|
287
|
+
const chunks = [];
|
|
288
|
+
for await (const chunk of streamBody(response.Body)) chunks.push(chunk);
|
|
289
|
+
if (chunks.length === 0) return 0;
|
|
290
|
+
let data;
|
|
291
|
+
if (chunks.length === 1) data = chunks[0];
|
|
292
|
+
else {
|
|
293
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
294
|
+
data = new Uint8Array(totalLength);
|
|
295
|
+
let pos = 0;
|
|
296
|
+
for (const chunk of chunks) {
|
|
297
|
+
data.set(chunk, pos);
|
|
298
|
+
pos += chunk.length;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const bytesToCopy = Math.min(data.length, len);
|
|
302
|
+
buffer.set(data.subarray(0, bytesToCopy), offset);
|
|
303
|
+
return bytesToCopy;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
//#endregion
|
|
308
|
+
//#region src/s3-files-api.ts
|
|
309
|
+
var S3FilesApi = class {
|
|
310
|
+
client;
|
|
311
|
+
bucket;
|
|
312
|
+
prefix;
|
|
313
|
+
multipartPartSize;
|
|
314
|
+
constructor(options) {
|
|
315
|
+
this.client = options.client;
|
|
316
|
+
this.bucket = options.bucket;
|
|
317
|
+
this.prefix = (options.prefix ?? "").replace(/^\/+|\/+$/g, "");
|
|
318
|
+
this.multipartPartSize = options.multipartPartSize ?? 5 * 1024 * 1024;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Converts a virtual path to an S3 key.
|
|
322
|
+
* @example resolveKey("/docs/file.txt") => "prefix/docs/file.txt"
|
|
323
|
+
*/
|
|
324
|
+
resolveKey(file) {
|
|
325
|
+
const normalized = (0, __statewalker_webrun_files.resolveFileRef)(file);
|
|
326
|
+
const relativePath = normalized.startsWith("/") ? normalized.substring(1) : normalized;
|
|
327
|
+
if (this.prefix) return relativePath ? `${this.prefix}/${relativePath}` : this.prefix;
|
|
328
|
+
return relativePath;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Converts an S3 key back to a virtual path.
|
|
332
|
+
*/
|
|
333
|
+
keyToPath(key) {
|
|
334
|
+
let relativePath = key;
|
|
335
|
+
if (this.prefix && key.startsWith(this.prefix)) {
|
|
336
|
+
relativePath = key.substring(this.prefix.length);
|
|
337
|
+
if (relativePath.startsWith("/")) relativePath = relativePath.substring(1);
|
|
338
|
+
}
|
|
339
|
+
return "/" + relativePath;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Lists objects in a virtual directory.
|
|
343
|
+
*
|
|
344
|
+
* Uses S3's delimiter feature to simulate directory listing.
|
|
345
|
+
* CommonPrefixes represent subdirectories.
|
|
346
|
+
*/
|
|
347
|
+
async *list(file, options = {}) {
|
|
348
|
+
const { recursive = false } = options;
|
|
349
|
+
const prefix = this.resolveKey(file);
|
|
350
|
+
const normalizedPrefix = prefix ? prefix + "/" : this.prefix ? this.prefix + "/" : "";
|
|
351
|
+
let continuationToken;
|
|
352
|
+
do {
|
|
353
|
+
const command = new __aws_sdk_client_s3.ListObjectsV2Command({
|
|
354
|
+
Bucket: this.bucket,
|
|
355
|
+
Prefix: normalizedPrefix,
|
|
356
|
+
Delimiter: recursive ? void 0 : "/",
|
|
357
|
+
ContinuationToken: continuationToken,
|
|
358
|
+
MaxKeys: 1e3
|
|
359
|
+
});
|
|
360
|
+
const response = await this.client.send(command);
|
|
361
|
+
if (!recursive && response.CommonPrefixes) for (const cpPrefix of response.CommonPrefixes) {
|
|
362
|
+
if (!cpPrefix.Prefix) continue;
|
|
363
|
+
const dirKey = cpPrefix.Prefix.endsWith("/") ? cpPrefix.Prefix.slice(0, -1) : cpPrefix.Prefix;
|
|
364
|
+
const path = this.keyToPath(dirKey);
|
|
365
|
+
yield {
|
|
366
|
+
kind: "directory",
|
|
367
|
+
name: (0, __statewalker_webrun_files.basename)(path),
|
|
368
|
+
path,
|
|
369
|
+
lastModified: 0
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
if (response.Contents) for (const obj of response.Contents) {
|
|
373
|
+
if (!obj.Key) continue;
|
|
374
|
+
if (obj.Key === normalizedPrefix) continue;
|
|
375
|
+
if (obj.Key + "/" === normalizedPrefix) continue;
|
|
376
|
+
if (obj.Key.endsWith("/")) continue;
|
|
377
|
+
const path = this.keyToPath(obj.Key);
|
|
378
|
+
yield {
|
|
379
|
+
kind: "file",
|
|
380
|
+
name: (0, __statewalker_webrun_files.basename)(path),
|
|
381
|
+
path,
|
|
382
|
+
size: obj.Size ?? 0,
|
|
383
|
+
lastModified: obj.LastModified?.getTime() ?? 0
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
continuationToken = response.NextContinuationToken;
|
|
387
|
+
} while (continuationToken);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Gets object metadata using HEAD request.
|
|
391
|
+
*/
|
|
392
|
+
async stats(file) {
|
|
393
|
+
const normalized = (0, __statewalker_webrun_files.resolveFileRef)(file);
|
|
394
|
+
const key = this.resolveKey(file);
|
|
395
|
+
if (normalized === "/") return {
|
|
396
|
+
kind: "directory",
|
|
397
|
+
name: "",
|
|
398
|
+
path: "/",
|
|
399
|
+
lastModified: 0
|
|
400
|
+
};
|
|
401
|
+
try {
|
|
402
|
+
const command = new __aws_sdk_client_s3.HeadObjectCommand({
|
|
403
|
+
Bucket: this.bucket,
|
|
404
|
+
Key: key
|
|
405
|
+
});
|
|
406
|
+
const response = await this.client.send(command);
|
|
407
|
+
return {
|
|
408
|
+
kind: "file",
|
|
409
|
+
name: (0, __statewalker_webrun_files.basename)(normalized),
|
|
410
|
+
path: normalized,
|
|
411
|
+
size: response.ContentLength ?? 0,
|
|
412
|
+
lastModified: response.LastModified?.getTime() ?? 0,
|
|
413
|
+
type: response.ContentType
|
|
414
|
+
};
|
|
415
|
+
} catch (error) {
|
|
416
|
+
const err = error;
|
|
417
|
+
if (err.name !== "NotFound" && err.$metadata?.httpStatusCode !== 404) throw error;
|
|
418
|
+
}
|
|
419
|
+
const prefix = key ? key + "/" : "";
|
|
420
|
+
const listCommand = new __aws_sdk_client_s3.ListObjectsV2Command({
|
|
421
|
+
Bucket: this.bucket,
|
|
422
|
+
Prefix: prefix,
|
|
423
|
+
MaxKeys: 1
|
|
424
|
+
});
|
|
425
|
+
const listResponse = await this.client.send(listCommand);
|
|
426
|
+
if (listResponse.Contents && listResponse.Contents.length > 0 || listResponse.CommonPrefixes && listResponse.CommonPrefixes.length > 0) return {
|
|
427
|
+
kind: "directory",
|
|
428
|
+
name: (0, __statewalker_webrun_files.basename)(normalized),
|
|
429
|
+
path: normalized,
|
|
430
|
+
lastModified: 0
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Deletes an object or all objects under a prefix (directory).
|
|
435
|
+
*/
|
|
436
|
+
async remove(file) {
|
|
437
|
+
const key = this.resolveKey(file);
|
|
438
|
+
try {
|
|
439
|
+
const headCommand = new __aws_sdk_client_s3.HeadObjectCommand({
|
|
440
|
+
Bucket: this.bucket,
|
|
441
|
+
Key: key
|
|
442
|
+
});
|
|
443
|
+
await this.client.send(headCommand);
|
|
444
|
+
const deleteCommand = new __aws_sdk_client_s3.DeleteObjectCommand({
|
|
445
|
+
Bucket: this.bucket,
|
|
446
|
+
Key: key
|
|
447
|
+
});
|
|
448
|
+
await this.client.send(deleteCommand);
|
|
449
|
+
return true;
|
|
450
|
+
} catch (error) {
|
|
451
|
+
const err = error;
|
|
452
|
+
if (err.name !== "NotFound" && err.$metadata?.httpStatusCode !== 404) throw error;
|
|
453
|
+
}
|
|
454
|
+
const prefix = key ? key + "/" : "";
|
|
455
|
+
const keysToDelete = [];
|
|
456
|
+
let continuationToken;
|
|
457
|
+
do {
|
|
458
|
+
const listCommand = new __aws_sdk_client_s3.ListObjectsV2Command({
|
|
459
|
+
Bucket: this.bucket,
|
|
460
|
+
Prefix: prefix,
|
|
461
|
+
ContinuationToken: continuationToken
|
|
462
|
+
});
|
|
463
|
+
const response = await this.client.send(listCommand);
|
|
464
|
+
if (response.Contents) {
|
|
465
|
+
for (const obj of response.Contents) if (obj.Key) keysToDelete.push(obj.Key);
|
|
466
|
+
}
|
|
467
|
+
continuationToken = response.NextContinuationToken;
|
|
468
|
+
} while (continuationToken);
|
|
469
|
+
if (keysToDelete.length === 0) return false;
|
|
470
|
+
for (const keyToDelete of keysToDelete) {
|
|
471
|
+
const deleteCommand = new __aws_sdk_client_s3.DeleteObjectCommand({
|
|
472
|
+
Bucket: this.bucket,
|
|
473
|
+
Key: keyToDelete
|
|
474
|
+
});
|
|
475
|
+
await this.client.send(deleteCommand);
|
|
476
|
+
}
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Opens an S3 object for random access.
|
|
481
|
+
* Creates the object if it doesn't exist.
|
|
482
|
+
*/
|
|
483
|
+
async open(file) {
|
|
484
|
+
const key = this.resolveKey(file);
|
|
485
|
+
let size = 0;
|
|
486
|
+
try {
|
|
487
|
+
const command = new __aws_sdk_client_s3.HeadObjectCommand({
|
|
488
|
+
Bucket: this.bucket,
|
|
489
|
+
Key: key
|
|
490
|
+
});
|
|
491
|
+
size = (await this.client.send(command)).ContentLength ?? 0;
|
|
492
|
+
} catch (error) {
|
|
493
|
+
const err = error;
|
|
494
|
+
if (err.name !== "NotFound" && err.$metadata?.httpStatusCode !== 404) throw error;
|
|
495
|
+
}
|
|
496
|
+
return new S3FileHandle({
|
|
497
|
+
client: this.client,
|
|
498
|
+
bucket: this.bucket,
|
|
499
|
+
key,
|
|
500
|
+
size,
|
|
501
|
+
multipartPartSize: this.multipartPartSize
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Moves an object using Copy + Delete.
|
|
506
|
+
*/
|
|
507
|
+
async move(source, target) {
|
|
508
|
+
if (!await this.copy(source, target)) return false;
|
|
509
|
+
return this.remove(source);
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Copies an object or directory using S3 CopyObject.
|
|
513
|
+
*/
|
|
514
|
+
async copy(source, target, options = {}) {
|
|
515
|
+
const sourceKey = this.resolveKey(source);
|
|
516
|
+
const targetKey = this.resolveKey(target);
|
|
517
|
+
const { recursive = true } = options;
|
|
518
|
+
const sourceInfo = await this.stats(source);
|
|
519
|
+
if (!sourceInfo) return false;
|
|
520
|
+
if (sourceInfo.kind === "file") {
|
|
521
|
+
const command = new __aws_sdk_client_s3.CopyObjectCommand({
|
|
522
|
+
Bucket: this.bucket,
|
|
523
|
+
Key: targetKey,
|
|
524
|
+
CopySource: encodeURIComponent(`${this.bucket}/${sourceKey}`)
|
|
525
|
+
});
|
|
526
|
+
await this.client.send(command);
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
if (!recursive) return false;
|
|
530
|
+
const sourcePrefix = sourceKey + "/";
|
|
531
|
+
const targetPrefix = targetKey + "/";
|
|
532
|
+
let continuationToken;
|
|
533
|
+
let copied = false;
|
|
534
|
+
do {
|
|
535
|
+
const listCommand = new __aws_sdk_client_s3.ListObjectsV2Command({
|
|
536
|
+
Bucket: this.bucket,
|
|
537
|
+
Prefix: sourcePrefix,
|
|
538
|
+
ContinuationToken: continuationToken
|
|
539
|
+
});
|
|
540
|
+
const response = await this.client.send(listCommand);
|
|
541
|
+
if (response.Contents) for (const obj of response.Contents) {
|
|
542
|
+
if (!obj.Key) continue;
|
|
543
|
+
const newKey = targetPrefix + obj.Key.substring(sourcePrefix.length);
|
|
544
|
+
const copyCommand = new __aws_sdk_client_s3.CopyObjectCommand({
|
|
545
|
+
Bucket: this.bucket,
|
|
546
|
+
Key: newKey,
|
|
547
|
+
CopySource: encodeURIComponent(`${this.bucket}/${obj.Key}`)
|
|
548
|
+
});
|
|
549
|
+
await this.client.send(copyCommand);
|
|
550
|
+
copied = true;
|
|
551
|
+
}
|
|
552
|
+
continuationToken = response.NextContinuationToken;
|
|
553
|
+
} while (continuationToken);
|
|
554
|
+
return copied;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Creates a directory marker in S3.
|
|
558
|
+
*
|
|
559
|
+
* S3 doesn't have real directories, but some tools expect
|
|
560
|
+
* a zero-byte object with trailing slash as a directory marker.
|
|
561
|
+
* However, for our purposes we don't actually need to create markers
|
|
562
|
+
* since directories are implied by files within them.
|
|
563
|
+
*/
|
|
564
|
+
async mkdir(file) {
|
|
565
|
+
const key = this.resolveKey(file);
|
|
566
|
+
const dirKey = key.endsWith("/") ? key : key + "/";
|
|
567
|
+
const command = new __aws_sdk_client_s3.PutObjectCommand({
|
|
568
|
+
Bucket: this.bucket,
|
|
569
|
+
Key: dirKey,
|
|
570
|
+
Body: new Uint8Array(0),
|
|
571
|
+
ContentLength: 0
|
|
572
|
+
});
|
|
573
|
+
await this.client.send(command);
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
//#endregion
|
|
578
|
+
exports.S3FileHandle = S3FileHandle;
|
|
579
|
+
exports.S3FilesApi = S3FilesApi;
|