eip-cloud-services 1.5.0 → 1.6.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 CHANGED
@@ -7,6 +7,8 @@ All notable changes to this project will be documented in this file.
7
7
 
8
8
  ### Added
9
9
  - 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
+ - Added bounded S3 object streams and resumable Azure block-blob uploads for large server-to-server media transfers without temporary disk.
11
+ - Added callback-scoped loopback S3 range reads for seekable media inspection and server-side multipart copy for objects above 5 GiB.
10
12
 
11
13
  ## [1.2.5] - 2026-03-02
12
14
 
package/README.md CHANGED
@@ -128,6 +128,20 @@ await ondemand.uploadAzureBlob ( {
128
128
  await ingest.startIngest ( assetId, { url: cleanBlobUrl } );
129
129
  ```
130
130
 
131
+ For large one-shot sources such as an S3 `GetObject` body, use the block uploader. It buffers only one block at a time, retries replayable blocks independently, and commits the blob only after the exact expected byte length has been received:
132
+
133
+ ```javascript
134
+ const source = await s3.getStream ( objectKey, bucket );
135
+
136
+ await ondemand.uploadAzureBlockBlob ( {
137
+ blobUrl: cleanBlobUrl,
138
+ sasToken: process.env.ONDEMAND_AZURE_SAS_TOKEN,
139
+ body: source.body,
140
+ contentLength: source.contentLength,
141
+ contentType: 'video/mp4'
142
+ } );
143
+ ```
144
+
131
145
  # AWS Secrets Manager Module
132
146
 
133
147
  ## Overview
@@ -415,6 +429,35 @@ await s3.del('myObjectKey');
415
429
  await s3.copy('sourceObjectKey', 'destinationObjectKey');
416
430
  ```
417
431
 
432
+ For objects that may exceed S3's 5 GiB atomic-copy limit, use the large-object
433
+ copy helper. It automatically switches to multipart server-side copy and does
434
+ not download the object through the caller:
435
+
436
+ ```javascript
437
+ await s3.copyLargeObject(
438
+ 'temporary/video.mp4',
439
+ 'permanent/video.mp4',
440
+ 'source-bucket',
441
+ 'destination-bucket',
442
+ { contentType: 'video/mp4' }
443
+ );
444
+ ```
445
+
446
+ ### Seekable Local Access to a Private Large Object
447
+
448
+ Media tools such as FFmpeg often require byte-range seeking. `withLocalReadUrl`
449
+ provides a callback-scoped loopback URL and translates HTTP range requests into
450
+ authenticated S3 reads. The source is never written to local disk:
451
+
452
+ ```javascript
453
+ const metadata = await s3.withLocalReadUrl('temporary/video.mp4', 'source-bucket', async url => {
454
+ return inspectVideo(url);
455
+ });
456
+ ```
457
+
458
+ The URL is bound to `127.0.0.1`, contains an unguessable path, and is closed as
459
+ soon as the callback settles.
460
+
418
461
  ### Move an Object
419
462
 
420
463
  ```javascript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eip-cloud-services",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Houses a collection of helpers for connecting with Cloud services.",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/src/ondemand.js CHANGED
@@ -284,6 +284,46 @@ const buildAzureUploadUrl = ( blobUrl, sasToken ) => {
284
284
  return url.toString ();
285
285
  };
286
286
 
287
+ const buildAzureBlockUrl = ( blobUrl, sasToken, parameters ) => {
288
+ const url = new URL ( buildAzureUploadUrl ( blobUrl, sasToken ) );
289
+ Object.entries ( parameters ).forEach ( ( [ key, value ] ) => url.searchParams.set ( key, value ) );
290
+ return url.toString ();
291
+ };
292
+
293
+ const azureBlockId = index => Buffer.from ( String ( index ).padStart ( 12, '0' ) ).toString ( 'base64' );
294
+
295
+ const fixedSizeBlocks = async function * ( body, blockSize ) {
296
+ let pending = Buffer.alloc ( 0 );
297
+
298
+ for await ( const sourceChunk of body ) {
299
+ const chunk = Buffer.isBuffer ( sourceChunk ) ? sourceChunk : Buffer.from ( sourceChunk );
300
+ pending = pending.length ? Buffer.concat ( [ pending, chunk ] ) : chunk;
301
+
302
+ while ( pending.length >= blockSize ) {
303
+ yield pending.subarray ( 0, blockSize );
304
+ pending = pending.subarray ( blockSize );
305
+ }
306
+ }
307
+
308
+ if ( pending.length ) yield pending;
309
+ };
310
+
311
+ const executeReplayableRequest = async ( request, { maxRetries, retryDelayMs } ) => {
312
+ let attempt = 0;
313
+
314
+ while ( true ) {
315
+ try {
316
+ return await executeRequest ( request );
317
+ }
318
+ catch ( error ) {
319
+ const retryableStatus = error?.status === 408 || error?.status === 429 || Number ( error?.status ) >= 500;
320
+ if ( attempt >= maxRetries || ( error?.status && !retryableStatus ) ) throw error;
321
+ attempt += 1;
322
+ await new Promise ( resolve => setTimeout ( resolve, retryDelayMs * attempt ) );
323
+ }
324
+ }
325
+ };
326
+
287
327
  /**
288
328
  * Stream a source video to an Azure block blob using a SAS token. The returned
289
329
  * result never includes the signed request URL.
@@ -336,4 +376,102 @@ exports.uploadAzureBlob = async ( {
336
376
  };
337
377
  };
338
378
 
379
+ /**
380
+ * Stream a source video into fixed-size Azure blocks and commit the block list.
381
+ * Only one block is buffered at a time, allowing callers to pipe an S3 GetObject
382
+ * stream without using local disk. Deterministic block ids make whole-operation
383
+ * retries safe because an uploaded block is overwritten before the list is
384
+ * committed.
385
+ *
386
+ * @param {object} options
387
+ * @param {string} options.blobUrl Clean Azure blob URL without a query string.
388
+ * @param {string} options.sasToken Azure SAS query string.
389
+ * @param {AsyncIterable<Buffer|Uint8Array|string>} options.body One-shot source stream.
390
+ * @param {number} options.contentLength Exact expected byte length.
391
+ * @param {string} [options.contentType='video/mp4'] Final blob MIME type.
392
+ * @param {number} [options.blockSize=8388608] Bytes buffered per Azure block.
393
+ * @param {number} [options.maxRetries=2] Per-block retry count.
394
+ * @param {number} [options.retryDelayMs=250] Linear retry delay base.
395
+ * @param {Function} [options.fetch] Injectable fetch implementation.
396
+ * @param {number} [options.timeoutMs=300000] Per-request timeout.
397
+ * @returns {Promise<{status: number, etag: string|null, blocks: number, bytes: number}>}
398
+ */
399
+ exports.uploadAzureBlockBlob = async ( {
400
+ blobUrl,
401
+ sasToken,
402
+ body,
403
+ contentLength,
404
+ contentType = 'video/mp4',
405
+ blockSize = 8 * 1024 * 1024,
406
+ maxRetries = 2,
407
+ retryDelayMs = 250,
408
+ fetch: fetchOption,
409
+ timeoutMs = 300000
410
+ } ) => {
411
+ if ( !body || typeof body[ Symbol.asyncIterator ] !== 'function' ) {
412
+ throw new Error ( 'OnDemand Azure block upload body must be an async iterable' );
413
+ }
414
+
415
+ const expectedBytes = Number ( contentLength );
416
+ if ( !Number.isFinite ( expectedBytes ) || expectedBytes < 0 ) {
417
+ throw new Error ( 'OnDemand Azure upload content length is required' );
418
+ }
419
+ if ( !Number.isInteger ( blockSize ) || blockSize < 1 || blockSize > 100 * 1024 * 1024 ) {
420
+ throw new Error ( 'OnDemand Azure block size must be between 1 byte and 100 MiB' );
421
+ }
422
+
423
+ const fetchImpl = resolveFetch ( fetchOption );
424
+ const blockIds = [];
425
+ let uploadedBytes = 0;
426
+
427
+ for await ( const block of fixedSizeBlocks ( body, blockSize ) ) {
428
+ const blockId = azureBlockId ( blockIds.length );
429
+ await executeReplayableRequest ( {
430
+ fetchImpl,
431
+ url: buildAzureBlockUrl ( blobUrl, sasToken, { comp: 'block', blockid: blockId } ),
432
+ timeoutMs,
433
+ options: {
434
+ method: 'PUT',
435
+ headers: {
436
+ 'Content-Type': 'application/octet-stream',
437
+ 'Content-Length': String ( block.length ),
438
+ 'x-ms-version': '2018-11-09'
439
+ },
440
+ body: block
441
+ }
442
+ }, { maxRetries, retryDelayMs } );
443
+ blockIds.push ( blockId );
444
+ uploadedBytes += block.length;
445
+ }
446
+
447
+ if ( uploadedBytes !== expectedBytes ) {
448
+ throw new Error ( `OnDemand Azure upload length mismatch: expected ${expectedBytes}, received ${uploadedBytes}` );
449
+ }
450
+
451
+ const blockList = `<?xml version="1.0" encoding="utf-8"?><BlockList>${blockIds.map ( id => `<Latest>${id}</Latest>` ).join ( '' )}</BlockList>`;
452
+ const { response } = await executeReplayableRequest ( {
453
+ fetchImpl,
454
+ url: buildAzureBlockUrl ( blobUrl, sasToken, { comp: 'blocklist' } ),
455
+ timeoutMs,
456
+ includeResponse: true,
457
+ options: {
458
+ method: 'PUT',
459
+ headers: {
460
+ 'Content-Type': 'application/xml',
461
+ 'Content-Length': String ( Buffer.byteLength ( blockList ) ),
462
+ 'x-ms-blob-content-type': contentType,
463
+ 'x-ms-version': '2018-11-09'
464
+ },
465
+ body: blockList
466
+ }
467
+ }, { maxRetries, retryDelayMs } );
468
+
469
+ return {
470
+ status: response.status,
471
+ etag: response.headers?.get?.( 'etag' ) || null,
472
+ blocks: blockIds.length,
473
+ bytes: uploadedBytes
474
+ };
475
+ };
476
+
339
477
  exports.OnDemandRequestError = OnDemandRequestError;
package/src/s3.js CHANGED
@@ -39,6 +39,8 @@
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' );
42
44
  const fs = require ( 'fs' );
43
45
  const path = require ( 'path' );
44
46
  let config = {};
@@ -183,6 +185,59 @@ exports.get = async ( key, bucket = config?.s3?.Bucket, options = {} ) => {
183
185
  }
184
186
  };
185
187
 
188
+ /**
189
+ * Get an S3 object as a one-shot readable stream without buffering it or writing
190
+ * it to local disk.
191
+ *
192
+ * @param {string} key - The object key.
193
+ * @param {string} [bucket=config?.s3?.Bucket] - The bucket name.
194
+ * @returns {Promise<{body: import('stream').Readable, contentLength: number, contentType: string|null, etag: string|null}>}
195
+ * A stream and the metadata required by downstream streaming uploads.
196
+ */
197
+ exports.getStream = async ( key, bucket = config?.s3?.Bucket ) => {
198
+ const response = await S3.send ( new GetObjectCommand ( {
199
+ Bucket: bucket,
200
+ Key: key
201
+ } ) );
202
+
203
+ if ( !response.Body || typeof response.Body[ Symbol.asyncIterator ] !== 'function' ) {
204
+ throw new Error ( `S3 object ${bucket}/${key} did not return a readable stream` );
205
+ }
206
+
207
+ const contentLength = Number ( response.ContentLength );
208
+ if ( !Number.isFinite ( contentLength ) || contentLength < 0 ) {
209
+ response.Body.destroy?.();
210
+ throw new Error ( `S3 object ${bucket}/${key} did not return a valid content length` );
211
+ }
212
+
213
+ if ( config?.s3?.logs === 'verbose' ) {
214
+ log ( `S3 [GET STREAM]: Streaming ${bucket}/${key}.` );
215
+ }
216
+
217
+ return {
218
+ body: response.Body,
219
+ contentLength,
220
+ contentType: response.ContentType || null,
221
+ etag: response.ETag || null
222
+ };
223
+ };
224
+
225
+ /**
226
+ * Expose a private S3 object through a short-lived loopback HTTP URL. Range
227
+ * requests are served from S3 so local media tools can seek without writing
228
+ * the full object to disk or requiring public S3 network access.
229
+ *
230
+ * @param {string} key - The source object key.
231
+ * @param {string} [bucket=config?.s3?.Bucket] - The source bucket.
232
+ * @param {(url:string)=>Promise<*>} callback - Work performed while the URL is available.
233
+ * @returns {Promise<*>} The callback result.
234
+ */
235
+ exports.withLocalReadUrl = async ( key, bucket = config?.s3?.Bucket, callback ) => withLocalReadUrl ( {
236
+ client: S3,
237
+ key,
238
+ bucket
239
+ }, callback );
240
+
186
241
  /**
187
242
  * Set an object in S3.
188
243
  *
@@ -324,6 +379,32 @@ exports.copy = async ( sourceKey, destinationKey, sourceBucket = config?.s3?.Buc
324
379
  }
325
380
  };
326
381
 
382
+ /**
383
+ * Copy an S3 object without downloading it. Objects above S3's 5 GiB atomic
384
+ * copy limit are copied with multipart upload-part-copy requests.
385
+ *
386
+ * @param {string} sourceKey - The source object key.
387
+ * @param {string} destinationKey - The destination object key.
388
+ * @param {string} [sourceBucket=config?.s3?.Bucket] - The source bucket.
389
+ * @param {string} [destinationBucket=config?.s3?.Bucket] - The destination bucket.
390
+ * @param {object} [options] - Destination and multipart options.
391
+ * @returns {Promise<{contentLength:number,multipart:boolean,parts:number}>} Copy summary.
392
+ */
393
+ exports.copyLargeObject = async (
394
+ sourceKey,
395
+ destinationKey,
396
+ sourceBucket = config?.s3?.Bucket,
397
+ destinationBucket = config?.s3?.Bucket,
398
+ options = {}
399
+ ) => copyLargeObject ( {
400
+ client: S3,
401
+ sourceKey,
402
+ destinationKey,
403
+ sourceBucket,
404
+ destinationBucket,
405
+ ...options
406
+ } );
407
+
327
408
  /**
328
409
  * Move an object within S3 to a different location. (This deletes the original like a CUT / PASTE operation)
329
410
  *
@@ -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
+ };