eip-cloud-services 1.5.0 → 1.5.1
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 +1 -0
- package/README.md +14 -0
- package/package.json +1 -1
- package/src/ondemand.js +138 -0
- package/src/s3.js +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,7 @@ 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.
|
|
10
11
|
|
|
11
12
|
## [1.2.5] - 2026-03-02
|
|
12
13
|
|
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
|
package/package.json
CHANGED
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
|
@@ -183,6 +183,43 @@ exports.get = async ( key, bucket = config?.s3?.Bucket, options = {} ) => {
|
|
|
183
183
|
}
|
|
184
184
|
};
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Get an S3 object as a one-shot readable stream without buffering it or writing
|
|
188
|
+
* it to local disk.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} key - The object key.
|
|
191
|
+
* @param {string} [bucket=config?.s3?.Bucket] - The bucket name.
|
|
192
|
+
* @returns {Promise<{body: import('stream').Readable, contentLength: number, contentType: string|null, etag: string|null}>}
|
|
193
|
+
* A stream and the metadata required by downstream streaming uploads.
|
|
194
|
+
*/
|
|
195
|
+
exports.getStream = async ( key, bucket = config?.s3?.Bucket ) => {
|
|
196
|
+
const response = await S3.send ( new GetObjectCommand ( {
|
|
197
|
+
Bucket: bucket,
|
|
198
|
+
Key: key
|
|
199
|
+
} ) );
|
|
200
|
+
|
|
201
|
+
if ( !response.Body || typeof response.Body[ Symbol.asyncIterator ] !== 'function' ) {
|
|
202
|
+
throw new Error ( `S3 object ${bucket}/${key} did not return a readable stream` );
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const contentLength = Number ( response.ContentLength );
|
|
206
|
+
if ( !Number.isFinite ( contentLength ) || contentLength < 0 ) {
|
|
207
|
+
response.Body.destroy?.();
|
|
208
|
+
throw new Error ( `S3 object ${bucket}/${key} did not return a valid content length` );
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if ( config?.s3?.logs === 'verbose' ) {
|
|
212
|
+
log ( `S3 [GET STREAM]: Streaming ${bucket}/${key}.` );
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
body: response.Body,
|
|
217
|
+
contentLength,
|
|
218
|
+
contentType: response.ContentType || null,
|
|
219
|
+
etag: response.ETag || null
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
|
|
186
223
|
/**
|
|
187
224
|
* Set an object in S3.
|
|
188
225
|
*
|