eip-cloud-services 1.5.1 → 1.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/CHANGELOG.md +2 -0
- package/README.md +31 -0
- package/package.json +2 -1
- package/src/s3.js +79 -0
- package/src/s3LargeObject.js +142 -0
- package/src/s3LocalReadServer.js +111 -0
- package/src/s3MultipartUpload.js +154 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,8 +6,10 @@ All notable changes to this project will be documented in this file.
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
8
|
### Added
|
|
9
|
+
- Added browser-safe S3 multipart upload initiation, part signing, completion, and abort helpers for files above the single-PUT limit.
|
|
9
10
|
- Added staging-safe OnDemand Content Ingest, Content Discovery, and Azure Blob upload clients with injectable fetch, timeouts, production-host guards, and signed-query redaction.
|
|
10
11
|
- Added bounded S3 object streams and resumable Azure block-blob uploads for large server-to-server media transfers without temporary disk.
|
|
12
|
+
- Added callback-scoped loopback S3 range reads for seekable media inspection and server-side multipart copy for objects above 5 GiB.
|
|
11
13
|
|
|
12
14
|
## [1.2.5] - 2026-03-02
|
|
13
15
|
|
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
The EIP Cloud Services Module is a comprehensive Node.js package that provides seamless integration with various cloud services, including Redis, AWS S3, CDN, AWS Lambda, AWS SQS, and MySQL. This module is designed to simplify the complexities of interacting with these cloud services, offering a range of functionalities from data caching and storage to content delivery and serverless computing.
|
|
6
6
|
|
|
7
|
+
The S3 service supports multipart browser upload orchestration through `createMultipartUpload`, `signMultipartUploadParts`, `completeMultipartUpload`, and `abortMultipartUpload`. Completion lists the uploaded parts from S3 so clients do not need access to response ETags.
|
|
8
|
+
|
|
7
9
|
## Installation and Import
|
|
8
10
|
|
|
9
11
|
To use this module, first install it in your Node.js project. Then, import the required services as follows:
|
|
@@ -429,6 +431,35 @@ await s3.del('myObjectKey');
|
|
|
429
431
|
await s3.copy('sourceObjectKey', 'destinationObjectKey');
|
|
430
432
|
```
|
|
431
433
|
|
|
434
|
+
For objects that may exceed S3's 5 GiB atomic-copy limit, use the large-object
|
|
435
|
+
copy helper. It automatically switches to multipart server-side copy and does
|
|
436
|
+
not download the object through the caller:
|
|
437
|
+
|
|
438
|
+
```javascript
|
|
439
|
+
await s3.copyLargeObject(
|
|
440
|
+
'temporary/video.mp4',
|
|
441
|
+
'permanent/video.mp4',
|
|
442
|
+
'source-bucket',
|
|
443
|
+
'destination-bucket',
|
|
444
|
+
{ contentType: 'video/mp4' }
|
|
445
|
+
);
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
### Seekable Local Access to a Private Large Object
|
|
449
|
+
|
|
450
|
+
Media tools such as FFmpeg often require byte-range seeking. `withLocalReadUrl`
|
|
451
|
+
provides a callback-scoped loopback URL and translates HTTP range requests into
|
|
452
|
+
authenticated S3 reads. The source is never written to local disk:
|
|
453
|
+
|
|
454
|
+
```javascript
|
|
455
|
+
const metadata = await s3.withLocalReadUrl('temporary/video.mp4', 'source-bucket', async url => {
|
|
456
|
+
return inspectVideo(url);
|
|
457
|
+
});
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
The URL is bound to `127.0.0.1`, contains an unguessable path, and is closed as
|
|
461
|
+
soon as the callback settles.
|
|
462
|
+
|
|
432
463
|
### Move an Object
|
|
433
464
|
|
|
434
465
|
```javascript
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eip-cloud-services",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Houses a collection of helpers for connecting with Cloud services.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"@aws-sdk/client-cloudfront": "^3.354.0",
|
|
19
19
|
"@aws-sdk/client-lambda": "^3.356.0",
|
|
20
20
|
"@aws-sdk/client-s3": "^3.354.0",
|
|
21
|
+
"@aws-sdk/s3-request-presigner": "^3.354.0",
|
|
21
22
|
"@aws-sdk/client-sqs": "^3.356.0",
|
|
22
23
|
"@aws-sdk/client-secrets-manager": "^3.354.0",
|
|
23
24
|
"config": "^3.3.9",
|
package/src/s3.js
CHANGED
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
const { S3Client, HeadObjectCommand, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, CopyObjectCommand, ListObjectsV2Command } = require ( '@aws-sdk/client-s3' );
|
|
42
|
+
const { copyLargeObject } = require ( './s3LargeObject' );
|
|
43
|
+
const { withLocalReadUrl } = require ( './s3LocalReadServer' );
|
|
44
|
+
const multipartUpload = require ( './s3MultipartUpload' );
|
|
42
45
|
const fs = require ( 'fs' );
|
|
43
46
|
const path = require ( 'path' );
|
|
44
47
|
let config = {};
|
|
@@ -220,6 +223,56 @@ exports.getStream = async ( key, bucket = config?.s3?.Bucket ) => {
|
|
|
220
223
|
};
|
|
221
224
|
};
|
|
222
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Expose a private S3 object through a short-lived loopback HTTP URL. Range
|
|
228
|
+
* requests are served from S3 so local media tools can seek without writing
|
|
229
|
+
* the full object to disk or requiring public S3 network access.
|
|
230
|
+
*
|
|
231
|
+
* @param {string} key - The source object key.
|
|
232
|
+
* @param {string} [bucket=config?.s3?.Bucket] - The source bucket.
|
|
233
|
+
* @param {(url:string)=>Promise<*>} callback - Work performed while the URL is available.
|
|
234
|
+
* @returns {Promise<*>} The callback result.
|
|
235
|
+
*/
|
|
236
|
+
exports.withLocalReadUrl = async ( key, bucket = config?.s3?.Bucket, callback ) => withLocalReadUrl ( {
|
|
237
|
+
client: S3,
|
|
238
|
+
key,
|
|
239
|
+
bucket
|
|
240
|
+
}, callback );
|
|
241
|
+
|
|
242
|
+
exports.createMultipartUpload = async ( key, bucket = config?.s3?.Bucket, options = {} ) =>
|
|
243
|
+
multipartUpload.createMultipartUpload ( {
|
|
244
|
+
client: S3,
|
|
245
|
+
key,
|
|
246
|
+
bucket,
|
|
247
|
+
...options
|
|
248
|
+
} );
|
|
249
|
+
|
|
250
|
+
exports.signMultipartUploadParts = async ( key, uploadId, partNumbers, bucket = config?.s3?.Bucket, options = {} ) =>
|
|
251
|
+
multipartUpload.signMultipartUploadParts ( {
|
|
252
|
+
client: S3,
|
|
253
|
+
key,
|
|
254
|
+
bucket,
|
|
255
|
+
uploadId,
|
|
256
|
+
partNumbers,
|
|
257
|
+
...options
|
|
258
|
+
} );
|
|
259
|
+
|
|
260
|
+
exports.completeMultipartUpload = async ( key, uploadId, bucket = config?.s3?.Bucket ) =>
|
|
261
|
+
multipartUpload.completeMultipartUpload ( {
|
|
262
|
+
client: S3,
|
|
263
|
+
key,
|
|
264
|
+
bucket,
|
|
265
|
+
uploadId
|
|
266
|
+
} );
|
|
267
|
+
|
|
268
|
+
exports.abortMultipartUpload = async ( key, uploadId, bucket = config?.s3?.Bucket ) =>
|
|
269
|
+
multipartUpload.abortMultipartUpload ( {
|
|
270
|
+
client: S3,
|
|
271
|
+
key,
|
|
272
|
+
bucket,
|
|
273
|
+
uploadId
|
|
274
|
+
} );
|
|
275
|
+
|
|
223
276
|
/**
|
|
224
277
|
* Set an object in S3.
|
|
225
278
|
*
|
|
@@ -361,6 +414,32 @@ exports.copy = async ( sourceKey, destinationKey, sourceBucket = config?.s3?.Buc
|
|
|
361
414
|
}
|
|
362
415
|
};
|
|
363
416
|
|
|
417
|
+
/**
|
|
418
|
+
* Copy an S3 object without downloading it. Objects above S3's 5 GiB atomic
|
|
419
|
+
* copy limit are copied with multipart upload-part-copy requests.
|
|
420
|
+
*
|
|
421
|
+
* @param {string} sourceKey - The source object key.
|
|
422
|
+
* @param {string} destinationKey - The destination object key.
|
|
423
|
+
* @param {string} [sourceBucket=config?.s3?.Bucket] - The source bucket.
|
|
424
|
+
* @param {string} [destinationBucket=config?.s3?.Bucket] - The destination bucket.
|
|
425
|
+
* @param {object} [options] - Destination and multipart options.
|
|
426
|
+
* @returns {Promise<{contentLength:number,multipart:boolean,parts:number}>} Copy summary.
|
|
427
|
+
*/
|
|
428
|
+
exports.copyLargeObject = async (
|
|
429
|
+
sourceKey,
|
|
430
|
+
destinationKey,
|
|
431
|
+
sourceBucket = config?.s3?.Bucket,
|
|
432
|
+
destinationBucket = config?.s3?.Bucket,
|
|
433
|
+
options = {}
|
|
434
|
+
) => copyLargeObject ( {
|
|
435
|
+
client: S3,
|
|
436
|
+
sourceKey,
|
|
437
|
+
destinationKey,
|
|
438
|
+
sourceBucket,
|
|
439
|
+
destinationBucket,
|
|
440
|
+
...options
|
|
441
|
+
} );
|
|
442
|
+
|
|
364
443
|
/**
|
|
365
444
|
* Move an object within S3 to a different location. (This deletes the original like a CUT / PASTE operation)
|
|
366
445
|
*
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const {
|
|
2
|
+
AbortMultipartUploadCommand,
|
|
3
|
+
CompleteMultipartUploadCommand,
|
|
4
|
+
CopyObjectCommand,
|
|
5
|
+
CreateMultipartUploadCommand,
|
|
6
|
+
HeadObjectCommand,
|
|
7
|
+
UploadPartCopyCommand
|
|
8
|
+
} = require ( '@aws-sdk/client-s3' );
|
|
9
|
+
|
|
10
|
+
const GIB = 1024 * 1024 * 1024;
|
|
11
|
+
const MIB = 1024 * 1024;
|
|
12
|
+
const SINGLE_COPY_LIMIT_BYTES = 5 * GIB;
|
|
13
|
+
const MAX_COPY_PART_BYTES = 5 * GIB;
|
|
14
|
+
const DEFAULT_COPY_PART_BYTES = 512 * MIB;
|
|
15
|
+
const MAX_MULTIPART_PARTS = 10000;
|
|
16
|
+
|
|
17
|
+
const encodeCopySource = ( bucket, key ) => encodeURIComponent ( `${bucket}/${key}` ).replace ( /%2F/g, '/' );
|
|
18
|
+
|
|
19
|
+
const resolvePartSize = ( contentLength, requestedPartSize = DEFAULT_COPY_PART_BYTES ) => {
|
|
20
|
+
const minimumForPartLimit = Math.ceil ( contentLength / MAX_MULTIPART_PARTS );
|
|
21
|
+
const selected = Math.max ( Number ( requestedPartSize ) || DEFAULT_COPY_PART_BYTES, minimumForPartLimit );
|
|
22
|
+
const rounded = Math.ceil ( selected / MIB ) * MIB;
|
|
23
|
+
|
|
24
|
+
if ( rounded > MAX_COPY_PART_BYTES ) {
|
|
25
|
+
throw new Error ( `S3 object is too large to copy in ${MAX_MULTIPART_PARTS} parts` );
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return rounded;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const buildCopyRanges = ( contentLength, requestedPartSize ) => {
|
|
32
|
+
if ( !Number.isSafeInteger ( contentLength ) || contentLength <= 0 ) {
|
|
33
|
+
throw new Error ( 'S3 copy source must have a positive, safe content length' );
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const partSize = resolvePartSize ( contentLength, requestedPartSize );
|
|
37
|
+
const ranges = [];
|
|
38
|
+
|
|
39
|
+
for ( let start = 0, partNumber = 1; start < contentLength; start += partSize, partNumber += 1 ) {
|
|
40
|
+
ranges.push ( {
|
|
41
|
+
partNumber,
|
|
42
|
+
start,
|
|
43
|
+
end: Math.min ( start + partSize, contentLength ) - 1
|
|
44
|
+
} );
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return ranges;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const copyLargeObject = async ( {
|
|
51
|
+
client,
|
|
52
|
+
sourceKey,
|
|
53
|
+
destinationKey,
|
|
54
|
+
sourceBucket,
|
|
55
|
+
destinationBucket,
|
|
56
|
+
acl = 'public-read',
|
|
57
|
+
contentType,
|
|
58
|
+
partSize
|
|
59
|
+
} ) => {
|
|
60
|
+
const source = encodeCopySource ( sourceBucket, sourceKey );
|
|
61
|
+
const head = await client.send ( new HeadObjectCommand ( {
|
|
62
|
+
Bucket: sourceBucket,
|
|
63
|
+
Key: sourceKey
|
|
64
|
+
} ) );
|
|
65
|
+
const contentLength = Number ( head.ContentLength );
|
|
66
|
+
|
|
67
|
+
if ( !Number.isSafeInteger ( contentLength ) || contentLength <= 0 ) {
|
|
68
|
+
throw new Error ( `S3 object ${sourceBucket}/${sourceKey} did not return a valid content length` );
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if ( contentLength <= SINGLE_COPY_LIMIT_BYTES ) {
|
|
72
|
+
await client.send ( new CopyObjectCommand ( {
|
|
73
|
+
CopySource: source,
|
|
74
|
+
Bucket: destinationBucket,
|
|
75
|
+
Key: destinationKey,
|
|
76
|
+
ACL: acl,
|
|
77
|
+
ContentType: contentType || head.ContentType,
|
|
78
|
+
MetadataDirective: contentType ? 'REPLACE' : 'COPY',
|
|
79
|
+
...( contentType ? { Metadata: head.Metadata || {} } : {} )
|
|
80
|
+
} ) );
|
|
81
|
+
|
|
82
|
+
return { contentLength, multipart: false, parts: 1 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const created = await client.send ( new CreateMultipartUploadCommand ( {
|
|
86
|
+
Bucket: destinationBucket,
|
|
87
|
+
Key: destinationKey,
|
|
88
|
+
ACL: acl,
|
|
89
|
+
ContentType: contentType || head.ContentType,
|
|
90
|
+
CacheControl: head.CacheControl,
|
|
91
|
+
ContentDisposition: head.ContentDisposition,
|
|
92
|
+
ContentEncoding: head.ContentEncoding,
|
|
93
|
+
ContentLanguage: head.ContentLanguage,
|
|
94
|
+
Metadata: head.Metadata || {}
|
|
95
|
+
} ) );
|
|
96
|
+
const uploadId = created.UploadId;
|
|
97
|
+
|
|
98
|
+
if ( !uploadId ) {
|
|
99
|
+
throw new Error ( 'S3 did not return an upload id for multipart copy' );
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const completedParts = [];
|
|
104
|
+
for ( const range of buildCopyRanges ( contentLength, partSize ) ) {
|
|
105
|
+
const copied = await client.send ( new UploadPartCopyCommand ( {
|
|
106
|
+
Bucket: destinationBucket,
|
|
107
|
+
Key: destinationKey,
|
|
108
|
+
CopySource: source,
|
|
109
|
+
CopySourceRange: `bytes=${range.start}-${range.end}`,
|
|
110
|
+
PartNumber: range.partNumber,
|
|
111
|
+
UploadId: uploadId
|
|
112
|
+
} ) );
|
|
113
|
+
const etag = copied.CopyPartResult?.ETag;
|
|
114
|
+
if ( !etag ) throw new Error ( `S3 multipart copy part ${range.partNumber} did not return an ETag` );
|
|
115
|
+
completedParts.push ( { ETag: etag, PartNumber: range.partNumber } );
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await client.send ( new CompleteMultipartUploadCommand ( {
|
|
119
|
+
Bucket: destinationBucket,
|
|
120
|
+
Key: destinationKey,
|
|
121
|
+
UploadId: uploadId,
|
|
122
|
+
MultipartUpload: { Parts: completedParts }
|
|
123
|
+
} ) );
|
|
124
|
+
|
|
125
|
+
return { contentLength, multipart: true, parts: completedParts.length };
|
|
126
|
+
}
|
|
127
|
+
catch ( error ) {
|
|
128
|
+
await client.send ( new AbortMultipartUploadCommand ( {
|
|
129
|
+
Bucket: destinationBucket,
|
|
130
|
+
Key: destinationKey,
|
|
131
|
+
UploadId: uploadId
|
|
132
|
+
} ) ).catch ( () => {} );
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
module.exports = {
|
|
138
|
+
DEFAULT_COPY_PART_BYTES,
|
|
139
|
+
SINGLE_COPY_LIMIT_BYTES,
|
|
140
|
+
buildCopyRanges,
|
|
141
|
+
copyLargeObject
|
|
142
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const crypto = require ( 'crypto' );
|
|
2
|
+
const http = require ( 'http' );
|
|
3
|
+
const { GetObjectCommand, HeadObjectCommand } = require ( '@aws-sdk/client-s3' );
|
|
4
|
+
|
|
5
|
+
const parseRange = ( value, contentLength ) => {
|
|
6
|
+
const match = /^bytes=(\d*)-(\d*)$/i.exec ( value || '' );
|
|
7
|
+
if ( !match ) return null;
|
|
8
|
+
|
|
9
|
+
let start;
|
|
10
|
+
let end;
|
|
11
|
+
if ( match[ 1 ] ) {
|
|
12
|
+
start = Number ( match[ 1 ] );
|
|
13
|
+
end = match[ 2 ] ? Number ( match[ 2 ] ) : contentLength - 1;
|
|
14
|
+
}
|
|
15
|
+
else if ( match[ 2 ] ) {
|
|
16
|
+
const suffixLength = Number ( match[ 2 ] );
|
|
17
|
+
start = Math.max ( contentLength - suffixLength, 0 );
|
|
18
|
+
end = contentLength - 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (
|
|
22
|
+
!Number.isSafeInteger ( start )
|
|
23
|
+
|| !Number.isSafeInteger ( end )
|
|
24
|
+
|| start < 0
|
|
25
|
+
|| end < start
|
|
26
|
+
|| start >= contentLength
|
|
27
|
+
) return null;
|
|
28
|
+
|
|
29
|
+
return { start, end: Math.min ( end, contentLength - 1 ) };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const listen = server => new Promise ( ( resolve, reject ) => {
|
|
33
|
+
server.once ( 'error', reject );
|
|
34
|
+
server.listen ( 0, '127.0.0.1', () => {
|
|
35
|
+
server.off ( 'error', reject );
|
|
36
|
+
resolve ();
|
|
37
|
+
} );
|
|
38
|
+
} );
|
|
39
|
+
|
|
40
|
+
const close = server => new Promise ( resolve => server.close ( resolve ) );
|
|
41
|
+
|
|
42
|
+
const withLocalReadUrl = async ( { client, key, bucket }, callback ) => {
|
|
43
|
+
const head = await client.send ( new HeadObjectCommand ( { Bucket: bucket, Key: key } ) );
|
|
44
|
+
const contentLength = Number ( head.ContentLength );
|
|
45
|
+
if ( !Number.isSafeInteger ( contentLength ) || contentLength <= 0 ) {
|
|
46
|
+
throw new Error ( `S3 object ${bucket}/${key} did not return a valid content length` );
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const pathname = `/${crypto.randomUUID ()}`;
|
|
50
|
+
const server = http.createServer ( async ( request, response ) => {
|
|
51
|
+
if ( request.url !== pathname || ![ 'GET', 'HEAD' ].includes ( request.method ) ) {
|
|
52
|
+
response.writeHead ( 404 ).end ();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const range = request.headers.range ? parseRange ( request.headers.range, contentLength ) : null;
|
|
57
|
+
if ( request.headers.range && !range ) {
|
|
58
|
+
response.writeHead ( 416, { 'Content-Range': `bytes */${contentLength}` } ).end ();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const responseLength = range ? range.end - range.start + 1 : contentLength;
|
|
63
|
+
const headers = {
|
|
64
|
+
'Accept-Ranges': 'bytes',
|
|
65
|
+
'Content-Length': responseLength,
|
|
66
|
+
'Content-Type': head.ContentType || 'application/octet-stream',
|
|
67
|
+
...( range ? { 'Content-Range': `bytes ${range.start}-${range.end}/${contentLength}` } : {} )
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if ( request.method === 'HEAD' ) {
|
|
71
|
+
response.writeHead ( range ? 206 : 200, headers ).end ();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const object = await client.send ( new GetObjectCommand ( {
|
|
77
|
+
Bucket: bucket,
|
|
78
|
+
Key: key,
|
|
79
|
+
...( range ? { Range: `bytes=${range.start}-${range.end}` } : {} )
|
|
80
|
+
} ) );
|
|
81
|
+
if ( !object.Body || typeof object.Body.pipe !== 'function' ) {
|
|
82
|
+
throw new Error ( 'S3 did not return a readable object body' );
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
response.writeHead ( range ? 206 : 200, headers );
|
|
86
|
+
response.once ( 'close', () => object.Body.destroy?. () );
|
|
87
|
+
object.Body.once ( 'error', () => response.destroy () );
|
|
88
|
+
object.Body.pipe ( response );
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
if ( !response.headersSent ) response.writeHead ( 502 ).end ();
|
|
92
|
+
else response.destroy ();
|
|
93
|
+
}
|
|
94
|
+
} );
|
|
95
|
+
|
|
96
|
+
await listen ( server );
|
|
97
|
+
const address = server.address ();
|
|
98
|
+
const url = `http://127.0.0.1:${address.port}${pathname}`;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
return await callback ( url );
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
await close ( server );
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
parseRange,
|
|
110
|
+
withLocalReadUrl
|
|
111
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
const {
|
|
2
|
+
AbortMultipartUploadCommand,
|
|
3
|
+
CompleteMultipartUploadCommand,
|
|
4
|
+
CreateMultipartUploadCommand,
|
|
5
|
+
ListPartsCommand,
|
|
6
|
+
UploadPartCommand
|
|
7
|
+
} = require ( '@aws-sdk/client-s3' );
|
|
8
|
+
const { getSignedUrl } = require ( '@aws-sdk/s3-request-presigner' );
|
|
9
|
+
|
|
10
|
+
const MIB = 1024 * 1024;
|
|
11
|
+
const DEFAULT_PART_BYTES = 128 * MIB;
|
|
12
|
+
const MAX_MULTIPART_PARTS = 10000;
|
|
13
|
+
const MAX_PART_BYTES = 5 * 1024 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
const resolveUploadPartSize = ( contentLength, requestedPartSize = DEFAULT_PART_BYTES ) => {
|
|
16
|
+
if ( !Number.isSafeInteger ( contentLength ) || contentLength <= 0 ) {
|
|
17
|
+
throw new Error ( 'S3 multipart upload requires a positive, safe content length' );
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const minimumForPartLimit = Math.ceil ( contentLength / MAX_MULTIPART_PARTS );
|
|
21
|
+
const selected = Math.max ( Number ( requestedPartSize ) || DEFAULT_PART_BYTES, minimumForPartLimit );
|
|
22
|
+
const rounded = Math.ceil ( selected / MIB ) * MIB;
|
|
23
|
+
|
|
24
|
+
if ( rounded > MAX_PART_BYTES ) {
|
|
25
|
+
throw new Error ( `S3 object is too large to upload in ${MAX_MULTIPART_PARTS} parts` );
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return rounded;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const createMultipartUpload = async ( {
|
|
32
|
+
client,
|
|
33
|
+
bucket,
|
|
34
|
+
key,
|
|
35
|
+
contentLength,
|
|
36
|
+
contentType,
|
|
37
|
+
acl = 'public-read',
|
|
38
|
+
metadata = {},
|
|
39
|
+
partSize
|
|
40
|
+
} ) => {
|
|
41
|
+
const resolvedPartSize = resolveUploadPartSize ( contentLength, partSize );
|
|
42
|
+
const created = await client.send ( new CreateMultipartUploadCommand ( {
|
|
43
|
+
Bucket: bucket,
|
|
44
|
+
Key: key,
|
|
45
|
+
ContentType: contentType,
|
|
46
|
+
ACL: acl,
|
|
47
|
+
Metadata: metadata
|
|
48
|
+
} ) );
|
|
49
|
+
|
|
50
|
+
if ( !created.UploadId ) {
|
|
51
|
+
throw new Error ( 'S3 did not return an upload id for multipart upload' );
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
uploadId: created.UploadId,
|
|
56
|
+
partSize: resolvedPartSize,
|
|
57
|
+
partCount: Math.ceil ( contentLength / resolvedPartSize )
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const signMultipartUploadParts = async ( {
|
|
62
|
+
client,
|
|
63
|
+
bucket,
|
|
64
|
+
key,
|
|
65
|
+
uploadId,
|
|
66
|
+
partNumbers,
|
|
67
|
+
expiresIn = 1800
|
|
68
|
+
} ) => {
|
|
69
|
+
if ( !Array.isArray ( partNumbers ) || partNumbers.length === 0 ) {
|
|
70
|
+
throw new Error ( 'At least one multipart upload part number is required' );
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return Promise.all ( partNumbers.map ( async partNumber => {
|
|
74
|
+
if ( !Number.isInteger ( partNumber ) || partNumber < 1 || partNumber > MAX_MULTIPART_PARTS ) {
|
|
75
|
+
throw new Error ( `Invalid multipart upload part number: ${partNumber}` );
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const signedRequest = await getSignedUrl ( client, new UploadPartCommand ( {
|
|
79
|
+
Bucket: bucket,
|
|
80
|
+
Key: key,
|
|
81
|
+
UploadId: uploadId,
|
|
82
|
+
PartNumber: partNumber
|
|
83
|
+
} ), { expiresIn } );
|
|
84
|
+
|
|
85
|
+
return { partNumber, signedRequest };
|
|
86
|
+
} ) );
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const listAllParts = async ( { client, bucket, key, uploadId } ) => {
|
|
90
|
+
const parts = [];
|
|
91
|
+
let partNumberMarker;
|
|
92
|
+
|
|
93
|
+
do {
|
|
94
|
+
const page = await client.send ( new ListPartsCommand ( {
|
|
95
|
+
Bucket: bucket,
|
|
96
|
+
Key: key,
|
|
97
|
+
UploadId: uploadId,
|
|
98
|
+
...( partNumberMarker ? { PartNumberMarker: partNumberMarker } : {} )
|
|
99
|
+
} ) );
|
|
100
|
+
|
|
101
|
+
for ( const part of page.Parts || [] ) {
|
|
102
|
+
if ( !part.ETag || !part.PartNumber ) {
|
|
103
|
+
throw new Error ( 'S3 returned an incomplete multipart upload part' );
|
|
104
|
+
}
|
|
105
|
+
parts.push ( { ETag: part.ETag, PartNumber: part.PartNumber } );
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
partNumberMarker = page.IsTruncated ? page.NextPartNumberMarker : undefined;
|
|
109
|
+
} while ( partNumberMarker );
|
|
110
|
+
|
|
111
|
+
if ( parts.length === 0 ) {
|
|
112
|
+
throw new Error ( 'S3 multipart upload contains no completed parts' );
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
parts.sort ( ( left, right ) => left.PartNumber - right.PartNumber );
|
|
116
|
+
parts.forEach ( ( part, index ) => {
|
|
117
|
+
if ( part.PartNumber !== index + 1 ) {
|
|
118
|
+
throw new Error ( `S3 multipart upload is missing part ${index + 1}` );
|
|
119
|
+
}
|
|
120
|
+
} );
|
|
121
|
+
|
|
122
|
+
return parts;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const completeMultipartUpload = async params => {
|
|
126
|
+
const parts = await listAllParts ( params );
|
|
127
|
+
await params.client.send ( new CompleteMultipartUploadCommand ( {
|
|
128
|
+
Bucket: params.bucket,
|
|
129
|
+
Key: params.key,
|
|
130
|
+
UploadId: params.uploadId,
|
|
131
|
+
MultipartUpload: { Parts: parts }
|
|
132
|
+
} ) );
|
|
133
|
+
|
|
134
|
+
return { parts: parts.length };
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const abortMultipartUpload = async ( { client, bucket, key, uploadId } ) => {
|
|
138
|
+
await client.send ( new AbortMultipartUploadCommand ( {
|
|
139
|
+
Bucket: bucket,
|
|
140
|
+
Key: key,
|
|
141
|
+
UploadId: uploadId
|
|
142
|
+
} ) );
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
module.exports = {
|
|
146
|
+
DEFAULT_PART_BYTES,
|
|
147
|
+
MAX_MULTIPART_PARTS,
|
|
148
|
+
abortMultipartUpload,
|
|
149
|
+
completeMultipartUpload,
|
|
150
|
+
createMultipartUpload,
|
|
151
|
+
listAllParts,
|
|
152
|
+
resolveUploadPartSize,
|
|
153
|
+
signMultipartUploadParts
|
|
154
|
+
};
|