@directus/storage-driver-s3 10.0.23 → 10.1.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/dist/index.d.ts +22 -4
- package/dist/index.js +225 -10
- package/package.json +6 -4
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ObjectCannedACL, ServerSideEncryption } from '@aws-sdk/client-s3';
|
|
2
|
-
import {
|
|
2
|
+
import { TusDriver, Range, ChunkedUploadContext } from '@directus/storage';
|
|
3
3
|
import { Readable } from 'node:stream';
|
|
4
4
|
|
|
5
5
|
type DriverS3Config = {
|
|
@@ -12,11 +12,19 @@ type DriverS3Config = {
|
|
|
12
12
|
endpoint?: string;
|
|
13
13
|
region?: string;
|
|
14
14
|
forcePathStyle?: boolean;
|
|
15
|
+
tus?: {
|
|
16
|
+
chunkSize?: number;
|
|
17
|
+
};
|
|
15
18
|
};
|
|
16
|
-
declare class DriverS3 implements
|
|
19
|
+
declare class DriverS3 implements TusDriver {
|
|
17
20
|
private config;
|
|
18
|
-
private client;
|
|
19
|
-
private root;
|
|
21
|
+
private readonly client;
|
|
22
|
+
private readonly root;
|
|
23
|
+
private partUploadSemaphore;
|
|
24
|
+
private readonly preferredPartSize;
|
|
25
|
+
maxMultipartParts: 10000;
|
|
26
|
+
minPartSize: 5242880;
|
|
27
|
+
maxUploadSize: 5497558138880;
|
|
20
28
|
constructor(config: DriverS3Config);
|
|
21
29
|
private getClient;
|
|
22
30
|
private fullPath;
|
|
@@ -31,6 +39,16 @@ declare class DriverS3 implements Driver {
|
|
|
31
39
|
write(filepath: string, content: Readable, type?: string): Promise<void>;
|
|
32
40
|
delete(filepath: string): Promise<void>;
|
|
33
41
|
list(prefix?: string): AsyncGenerator<string, void, unknown>;
|
|
42
|
+
get tusExtensions(): string[];
|
|
43
|
+
createChunkedUpload(filepath: string, context: ChunkedUploadContext): Promise<ChunkedUploadContext>;
|
|
44
|
+
deleteChunkedUpload(filepath: string, context: ChunkedUploadContext): Promise<void>;
|
|
45
|
+
finishChunkedUpload(filepath: string, context: ChunkedUploadContext): Promise<void>;
|
|
46
|
+
writeChunk(filepath: string, content: Readable, offset: number, context: ChunkedUploadContext): Promise<number>;
|
|
47
|
+
private uploadPart;
|
|
48
|
+
private uploadParts;
|
|
49
|
+
private retrieveParts;
|
|
50
|
+
private finishMultipartUpload;
|
|
51
|
+
private calcOptimalPartSize;
|
|
34
52
|
}
|
|
35
53
|
|
|
36
54
|
export { DriverS3, type DriverS3Config, DriverS3 as default };
|
package/dist/index.js
CHANGED
|
@@ -1,27 +1,48 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import {
|
|
3
|
+
AbortMultipartUploadCommand,
|
|
4
|
+
CompleteMultipartUploadCommand,
|
|
3
5
|
CopyObjectCommand,
|
|
6
|
+
CreateMultipartUploadCommand,
|
|
4
7
|
DeleteObjectCommand,
|
|
8
|
+
DeleteObjectsCommand,
|
|
5
9
|
GetObjectCommand,
|
|
6
10
|
HeadObjectCommand,
|
|
7
11
|
ListObjectsV2Command,
|
|
8
|
-
|
|
12
|
+
ListPartsCommand,
|
|
13
|
+
S3Client,
|
|
14
|
+
UploadPartCommand
|
|
9
15
|
} from "@aws-sdk/client-s3";
|
|
10
16
|
import { Upload } from "@aws-sdk/lib-storage";
|
|
11
17
|
import { normalizePath } from "@directus/utils";
|
|
12
18
|
import { isReadableStream } from "@directus/utils/node";
|
|
19
|
+
import { Semaphore } from "@shopify/semaphore";
|
|
13
20
|
import { NodeHttpHandler } from "@smithy/node-http-handler";
|
|
21
|
+
import { ERRORS, StreamSplitter, TUS_RESUMABLE } from "@tus/utils";
|
|
22
|
+
import fs, { promises as fsProm } from "fs";
|
|
14
23
|
import { Agent as HttpAgent } from "http";
|
|
15
24
|
import { Agent as HttpsAgent } from "https";
|
|
25
|
+
import os from "os";
|
|
16
26
|
import { join } from "path";
|
|
27
|
+
import { promises as streamProm } from "stream";
|
|
17
28
|
var DriverS3 = class {
|
|
18
29
|
config;
|
|
19
30
|
client;
|
|
20
31
|
root;
|
|
32
|
+
// TUS specific members
|
|
33
|
+
partUploadSemaphore;
|
|
34
|
+
preferredPartSize;
|
|
35
|
+
maxMultipartParts = 1e4;
|
|
36
|
+
minPartSize = 5242880;
|
|
37
|
+
// 5MiB
|
|
38
|
+
maxUploadSize = 5497558138880;
|
|
39
|
+
// 5TiB
|
|
21
40
|
constructor(config) {
|
|
22
41
|
this.config = config;
|
|
23
42
|
this.client = this.getClient();
|
|
24
43
|
this.root = this.config.root ? normalizePath(this.config.root, { removeLeading: true }) : "";
|
|
44
|
+
this.preferredPartSize = config.tus?.chunkSize ?? this.minPartSize;
|
|
45
|
+
this.partUploadSemaphore = new Semaphore(60);
|
|
25
46
|
}
|
|
26
47
|
getClient() {
|
|
27
48
|
const connectionTimeout = 5e3;
|
|
@@ -73,11 +94,11 @@ var DriverS3 = class {
|
|
|
73
94
|
if (range) {
|
|
74
95
|
commandInput.Range = `bytes=${range.start ?? ""}-${range.end ?? ""}`;
|
|
75
96
|
}
|
|
76
|
-
const { Body:
|
|
77
|
-
if (!
|
|
97
|
+
const { Body: stream2 } = await this.client.send(new GetObjectCommand(commandInput));
|
|
98
|
+
if (!stream2 || !isReadableStream(stream2)) {
|
|
78
99
|
throw new Error(`No stream returned for file "${filepath}"`);
|
|
79
100
|
}
|
|
80
|
-
return
|
|
101
|
+
return stream2;
|
|
81
102
|
}
|
|
82
103
|
async stat(filepath) {
|
|
83
104
|
const { ContentLength, LastModified } = await this.client.send(
|
|
@@ -143,8 +164,7 @@ var DriverS3 = class {
|
|
|
143
164
|
}
|
|
144
165
|
async *list(prefix = "") {
|
|
145
166
|
let Prefix = this.fullPath(prefix);
|
|
146
|
-
if (Prefix === ".")
|
|
147
|
-
Prefix = "";
|
|
167
|
+
if (Prefix === ".") Prefix = "";
|
|
148
168
|
let continuationToken = void 0;
|
|
149
169
|
do {
|
|
150
170
|
const listObjectsV2CommandInput = {
|
|
@@ -159,16 +179,211 @@ var DriverS3 = class {
|
|
|
159
179
|
continuationToken = response.NextContinuationToken;
|
|
160
180
|
if (response.Contents) {
|
|
161
181
|
for (const object of response.Contents) {
|
|
162
|
-
if (!object.Key)
|
|
163
|
-
continue;
|
|
182
|
+
if (!object.Key) continue;
|
|
164
183
|
const isDir = object.Key.endsWith("/");
|
|
165
|
-
if (isDir)
|
|
166
|
-
continue;
|
|
184
|
+
if (isDir) continue;
|
|
167
185
|
yield object.Key.substring(this.root.length);
|
|
168
186
|
}
|
|
169
187
|
}
|
|
170
188
|
} while (continuationToken);
|
|
171
189
|
}
|
|
190
|
+
// TUS implementation based on https://github.com/tus/tus-node-server
|
|
191
|
+
get tusExtensions() {
|
|
192
|
+
return ["creation", "termination", "expiration"];
|
|
193
|
+
}
|
|
194
|
+
async createChunkedUpload(filepath, context) {
|
|
195
|
+
const command = new CreateMultipartUploadCommand({
|
|
196
|
+
Bucket: this.config.bucket,
|
|
197
|
+
Key: this.fullPath(filepath),
|
|
198
|
+
Metadata: { "tus-version": TUS_RESUMABLE },
|
|
199
|
+
...context.metadata?.["contentType"] ? {
|
|
200
|
+
ContentType: context.metadata["contentType"]
|
|
201
|
+
} : {},
|
|
202
|
+
...context.metadata?.["cacheControl"] ? {
|
|
203
|
+
CacheControl: context.metadata["cacheControl"]
|
|
204
|
+
} : {}
|
|
205
|
+
});
|
|
206
|
+
const res = await this.client.send(command);
|
|
207
|
+
context.metadata["upload-id"] = res.UploadId;
|
|
208
|
+
return context;
|
|
209
|
+
}
|
|
210
|
+
async deleteChunkedUpload(filepath, context) {
|
|
211
|
+
const key = this.fullPath(filepath);
|
|
212
|
+
try {
|
|
213
|
+
const { "upload-id": uploadId } = context.metadata;
|
|
214
|
+
if (uploadId) {
|
|
215
|
+
await this.client.send(
|
|
216
|
+
new AbortMultipartUploadCommand({
|
|
217
|
+
Bucket: this.config.bucket,
|
|
218
|
+
Key: key,
|
|
219
|
+
UploadId: uploadId
|
|
220
|
+
})
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error?.code && ["NotFound", "NoSuchKey", "NoSuchUpload"].includes(error.Code)) {
|
|
225
|
+
throw ERRORS.FILE_NOT_FOUND;
|
|
226
|
+
}
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
await this.client.send(
|
|
230
|
+
new DeleteObjectsCommand({
|
|
231
|
+
Bucket: this.config.bucket,
|
|
232
|
+
Delete: {
|
|
233
|
+
Objects: [{ Key: key }]
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
async finishChunkedUpload(filepath, context) {
|
|
239
|
+
const key = this.fullPath(filepath);
|
|
240
|
+
const uploadId = context.metadata["upload-id"];
|
|
241
|
+
const size = context.size;
|
|
242
|
+
const chunkSize = this.calcOptimalPartSize(size);
|
|
243
|
+
const expectedParts = Math.ceil(size / chunkSize);
|
|
244
|
+
let parts = await this.retrieveParts(key, uploadId);
|
|
245
|
+
let retries = 0;
|
|
246
|
+
while (parts.length !== expectedParts && retries < 3) {
|
|
247
|
+
++retries;
|
|
248
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * retries));
|
|
249
|
+
parts = await this.retrieveParts(key, uploadId);
|
|
250
|
+
}
|
|
251
|
+
if (parts.length !== expectedParts) {
|
|
252
|
+
throw {
|
|
253
|
+
status_code: 500,
|
|
254
|
+
body: "Failed to upload all parts to S3."
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
await this.finishMultipartUpload(key, uploadId, parts);
|
|
258
|
+
}
|
|
259
|
+
async writeChunk(filepath, content, offset, context) {
|
|
260
|
+
const key = this.fullPath(filepath);
|
|
261
|
+
const uploadId = context.metadata["upload-id"];
|
|
262
|
+
const size = context.size;
|
|
263
|
+
const parts = await this.retrieveParts(key, uploadId);
|
|
264
|
+
const partNumber = parts.length > 0 ? parts[parts.length - 1].PartNumber : 0;
|
|
265
|
+
const nextPartNumber = partNumber + 1;
|
|
266
|
+
const requestedOffset = offset;
|
|
267
|
+
const bytesUploaded = await this.uploadParts(key, uploadId, size, content, nextPartNumber, offset);
|
|
268
|
+
return requestedOffset + bytesUploaded;
|
|
269
|
+
}
|
|
270
|
+
async uploadPart(key, uploadId, readStream, partNumber) {
|
|
271
|
+
const data = await this.client.send(
|
|
272
|
+
new UploadPartCommand({
|
|
273
|
+
Bucket: this.config.bucket,
|
|
274
|
+
Key: key,
|
|
275
|
+
UploadId: uploadId,
|
|
276
|
+
PartNumber: partNumber,
|
|
277
|
+
Body: readStream
|
|
278
|
+
})
|
|
279
|
+
);
|
|
280
|
+
return data.ETag;
|
|
281
|
+
}
|
|
282
|
+
async uploadParts(key, uploadId, size, readStream, currentPartNumber, offset) {
|
|
283
|
+
const promises = [];
|
|
284
|
+
let pendingChunkFilepath = null;
|
|
285
|
+
let bytesUploaded = 0;
|
|
286
|
+
let permit = void 0;
|
|
287
|
+
const splitterStream = new StreamSplitter({
|
|
288
|
+
chunkSize: this.calcOptimalPartSize(size),
|
|
289
|
+
directory: os.tmpdir()
|
|
290
|
+
}).on("beforeChunkStarted", async () => {
|
|
291
|
+
permit = await this.partUploadSemaphore.acquire();
|
|
292
|
+
}).on("chunkStarted", (filepath) => {
|
|
293
|
+
pendingChunkFilepath = filepath;
|
|
294
|
+
}).on("chunkFinished", ({ path, size: partSize }) => {
|
|
295
|
+
pendingChunkFilepath = null;
|
|
296
|
+
const partNumber = currentPartNumber++;
|
|
297
|
+
const acquiredPermit = permit;
|
|
298
|
+
offset += partSize;
|
|
299
|
+
const isFinalPart = size === offset;
|
|
300
|
+
const deferred = new Promise(async (resolve, reject) => {
|
|
301
|
+
try {
|
|
302
|
+
const readable = fs.createReadStream(path);
|
|
303
|
+
readable.on("error", reject);
|
|
304
|
+
if (partSize >= this.minPartSize || isFinalPart) {
|
|
305
|
+
await this.uploadPart(key, uploadId, readable, partNumber);
|
|
306
|
+
bytesUploaded += partSize;
|
|
307
|
+
} else {
|
|
308
|
+
}
|
|
309
|
+
resolve();
|
|
310
|
+
} catch (error) {
|
|
311
|
+
reject(error);
|
|
312
|
+
} finally {
|
|
313
|
+
fsProm.rm(path).catch(() => {
|
|
314
|
+
});
|
|
315
|
+
acquiredPermit?.release();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
promises.push(deferred);
|
|
319
|
+
}).on("chunkError", () => {
|
|
320
|
+
permit?.release();
|
|
321
|
+
});
|
|
322
|
+
try {
|
|
323
|
+
await streamProm.pipeline(readStream, splitterStream);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (pendingChunkFilepath !== null) {
|
|
326
|
+
try {
|
|
327
|
+
await fsProm.rm(pendingChunkFilepath);
|
|
328
|
+
} catch {
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
promises.push(Promise.reject(error));
|
|
332
|
+
} finally {
|
|
333
|
+
await Promise.all(promises);
|
|
334
|
+
}
|
|
335
|
+
return bytesUploaded;
|
|
336
|
+
}
|
|
337
|
+
async retrieveParts(key, uploadId, partNumberMarker) {
|
|
338
|
+
const data = await this.client.send(
|
|
339
|
+
new ListPartsCommand({
|
|
340
|
+
Bucket: this.config.bucket,
|
|
341
|
+
Key: key,
|
|
342
|
+
UploadId: uploadId,
|
|
343
|
+
PartNumberMarker: partNumberMarker
|
|
344
|
+
})
|
|
345
|
+
);
|
|
346
|
+
let parts = data.Parts ?? [];
|
|
347
|
+
if (data.IsTruncated) {
|
|
348
|
+
const rest = await this.retrieveParts(key, uploadId, data.NextPartNumberMarker);
|
|
349
|
+
parts = [...parts, ...rest];
|
|
350
|
+
}
|
|
351
|
+
if (!partNumberMarker) {
|
|
352
|
+
parts.sort((a, b) => a.PartNumber - b.PartNumber);
|
|
353
|
+
}
|
|
354
|
+
return parts;
|
|
355
|
+
}
|
|
356
|
+
async finishMultipartUpload(key, uploadId, parts) {
|
|
357
|
+
const command = new CompleteMultipartUploadCommand({
|
|
358
|
+
Bucket: this.config.bucket,
|
|
359
|
+
Key: key,
|
|
360
|
+
UploadId: uploadId,
|
|
361
|
+
MultipartUpload: {
|
|
362
|
+
Parts: parts.map((part) => {
|
|
363
|
+
return {
|
|
364
|
+
ETag: part.ETag,
|
|
365
|
+
PartNumber: part.PartNumber
|
|
366
|
+
};
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
const response = await this.client.send(command);
|
|
371
|
+
return response.Location;
|
|
372
|
+
}
|
|
373
|
+
calcOptimalPartSize(size) {
|
|
374
|
+
if (size === void 0) {
|
|
375
|
+
size = this.maxUploadSize;
|
|
376
|
+
}
|
|
377
|
+
let optimalPartSize;
|
|
378
|
+
if (size <= this.preferredPartSize) {
|
|
379
|
+
optimalPartSize = size;
|
|
380
|
+
} else if (size <= this.preferredPartSize * this.maxMultipartParts) {
|
|
381
|
+
optimalPartSize = this.preferredPartSize;
|
|
382
|
+
} else {
|
|
383
|
+
optimalPartSize = Math.ceil(size / this.maxMultipartParts);
|
|
384
|
+
}
|
|
385
|
+
return optimalPartSize;
|
|
386
|
+
}
|
|
172
387
|
};
|
|
173
388
|
var src_default = DriverS3;
|
|
174
389
|
export {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@directus/storage-driver-s3",
|
|
3
|
-
"version": "10.0
|
|
3
|
+
"version": "10.1.0",
|
|
4
4
|
"description": "S3 file storage abstraction for `@directus/storage`",
|
|
5
5
|
"homepage": "https://directus.io",
|
|
6
6
|
"repository": {
|
|
@@ -23,14 +23,16 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@aws-sdk/client-s3": "3.569.0",
|
|
25
25
|
"@aws-sdk/lib-storage": "3.569.0",
|
|
26
|
+
"@shopify/semaphore": "3.1.0",
|
|
26
27
|
"@smithy/node-http-handler": "2.5.0",
|
|
27
|
-
"@
|
|
28
|
-
"@directus/
|
|
28
|
+
"@tus/utils": "0.2.0",
|
|
29
|
+
"@directus/storage": "10.1.0",
|
|
30
|
+
"@directus/utils": "11.0.10"
|
|
29
31
|
},
|
|
30
32
|
"devDependencies": {
|
|
31
33
|
"@ngneat/falso": "7.2.0",
|
|
32
34
|
"@vitest/coverage-v8": "1.5.3",
|
|
33
|
-
"tsup": "8.0
|
|
35
|
+
"tsup": "8.1.0",
|
|
34
36
|
"typescript": "5.4.5",
|
|
35
37
|
"vitest": "1.5.3",
|
|
36
38
|
"@directus/tsconfig": "1.0.1"
|