s3mini 0.1.1 → 0.3.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/README.md +22 -8
- package/dist/s3mini.d.ts +235 -7
- package/dist/s3mini.js +425 -62
- package/dist/s3mini.js.map +1 -1
- package/dist/s3mini.min.js +1 -1
- package/dist/s3mini.min.js.map +1 -1
- package/package.json +4 -1
- package/src/S3.ts +413 -63
- package/src/utils.ts +48 -24
package/dist/s3mini.js
CHANGED
|
@@ -42,6 +42,9 @@ const _createHash = crypto.createHash || (await import('node:crypto')).createHas
|
|
|
42
42
|
const hash = (content) => {
|
|
43
43
|
return _createHash('sha256').update(content).digest('hex');
|
|
44
44
|
};
|
|
45
|
+
const md5base64 = (data) => {
|
|
46
|
+
return _createHash('md5').update(data).digest('base64');
|
|
47
|
+
};
|
|
45
48
|
/**
|
|
46
49
|
* Compute HMAC-SHA-256 of arbitrary data and return a hex string.
|
|
47
50
|
* @param {string|Buffer} key – secret key
|
|
@@ -75,6 +78,19 @@ const entityMap = {
|
|
|
75
78
|
'>': '>',
|
|
76
79
|
'&': '&',
|
|
77
80
|
};
|
|
81
|
+
/**
|
|
82
|
+
* Escape special characters for XML
|
|
83
|
+
* @param value String to escape
|
|
84
|
+
* @returns XML-escaped string
|
|
85
|
+
*/
|
|
86
|
+
const escapeXml = (value) => {
|
|
87
|
+
return value
|
|
88
|
+
.replace(/&/g, '&')
|
|
89
|
+
.replace(/</g, '<')
|
|
90
|
+
.replace(/>/g, '>')
|
|
91
|
+
.replace(/"/g, '"')
|
|
92
|
+
.replace(/'/g, ''');
|
|
93
|
+
};
|
|
78
94
|
const unescapeXml = (value) => value.replace(/&(quot|apos|lt|gt|amp);/g, m => entityMap[m] ?? m);
|
|
79
95
|
/**
|
|
80
96
|
* Parse a very small subset of XML into a JS structure.
|
|
@@ -83,29 +99,33 @@ const unescapeXml = (value) => value.replace(/&(quot|apos|lt|gt|amp);/g, m => en
|
|
|
83
99
|
* @returns string for leaf nodes, otherwise a map of children
|
|
84
100
|
*/
|
|
85
101
|
const parseXml = (input) => {
|
|
86
|
-
const
|
|
102
|
+
const xmlContent = input.replace(/<\?xml[^?]*\?>\s*/, '');
|
|
103
|
+
const RE_TAG = /<([A-Za-z_][\w\-.]*)[^>]*>([\s\S]*?)<\/\1>/gm;
|
|
87
104
|
const result = {}; // strong type, no `any`
|
|
88
105
|
let match;
|
|
89
|
-
while ((match = RE_TAG.exec(
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
const node =
|
|
93
|
-
|
|
106
|
+
while ((match = RE_TAG.exec(xmlContent)) !== null) {
|
|
107
|
+
const tagName = match[1];
|
|
108
|
+
const innerContent = match[2];
|
|
109
|
+
const node = innerContent ? parseXml(innerContent) : unescapeXml(innerContent?.trim() || '');
|
|
110
|
+
if (!tagName) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const current = result[tagName];
|
|
94
114
|
if (current === undefined) {
|
|
95
|
-
//
|
|
96
|
-
result[
|
|
115
|
+
// First occurrence
|
|
116
|
+
result[tagName] = node;
|
|
97
117
|
}
|
|
98
118
|
else if (Array.isArray(current)) {
|
|
99
|
-
//
|
|
119
|
+
// Already an array
|
|
100
120
|
current.push(node);
|
|
101
121
|
}
|
|
102
122
|
else {
|
|
103
|
-
//
|
|
104
|
-
result[
|
|
123
|
+
// Promote to array on the second occurrence
|
|
124
|
+
result[tagName] = [current, node];
|
|
105
125
|
}
|
|
106
126
|
}
|
|
107
127
|
// No child tags? — return the text, after entity decode
|
|
108
|
-
return Object.keys(result).length > 0 ? result : unescapeXml(
|
|
128
|
+
return Object.keys(result).length > 0 ? result : unescapeXml(xmlContent.trim());
|
|
109
129
|
};
|
|
110
130
|
/**
|
|
111
131
|
* Encode a character as a URI percent-encoded hex value
|
|
@@ -168,7 +188,7 @@ class S3ServiceError extends S3Error {
|
|
|
168
188
|
* @param {Iterable<() => Promise<unknonw>>} tasks – functions returning Promises
|
|
169
189
|
* @param {number} [batchSize=30] – max concurrent requests
|
|
170
190
|
* @param {number} [minIntervalMs=0] – ≥0; 0 means “no pacing”
|
|
171
|
-
* @returns {Promise<Array<PromiseSettledResult<
|
|
191
|
+
* @returns {Promise<Array<PromiseSettledResult<T>>>}
|
|
172
192
|
*/
|
|
173
193
|
const runInBatches = async (tasks, batchSize = 30, minIntervalMs = 0) => {
|
|
174
194
|
const allResults = [];
|
|
@@ -187,12 +207,12 @@ const runInBatches = async (tasks, batchSize = 30, minIntervalMs = 0) => {
|
|
|
187
207
|
// ───────── helpers ──────────
|
|
188
208
|
async function executeBatch(batchFns) {
|
|
189
209
|
const start = Date.now();
|
|
190
|
-
const settled = await Promise.allSettled(batchFns.map(fn => fn()));
|
|
210
|
+
const settled = await Promise.allSettled(batchFns.map((fn) => fn()));
|
|
191
211
|
allResults.push(...settled);
|
|
192
212
|
if (minIntervalMs > 0) {
|
|
193
213
|
const wait = minIntervalMs - (Date.now() - start);
|
|
194
214
|
if (wait > 0) {
|
|
195
|
-
await new Promise(
|
|
215
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
196
216
|
}
|
|
197
217
|
}
|
|
198
218
|
}
|
|
@@ -243,10 +263,8 @@ class s3mini {
|
|
|
243
263
|
requestSizeInBytes;
|
|
244
264
|
requestAbortTimeout;
|
|
245
265
|
logger;
|
|
246
|
-
|
|
247
|
-
shortDatetime;
|
|
266
|
+
signingKeyDate;
|
|
248
267
|
signingKey;
|
|
249
|
-
credentialScope;
|
|
250
268
|
constructor({ accessKeyId, secretAccessKey, endpoint, region = 'auto', requestSizeInBytes = DEFAULT_REQUEST_SIZE_IN_BYTES, requestAbortTimeout = undefined, logger = undefined, }) {
|
|
251
269
|
this._validateConstructorParams(accessKeyId, secretAccessKey, endpoint);
|
|
252
270
|
this.accessKeyId = accessKeyId;
|
|
@@ -256,10 +274,6 @@ class s3mini {
|
|
|
256
274
|
this.requestSizeInBytes = requestSizeInBytes;
|
|
257
275
|
this.requestAbortTimeout = requestAbortTimeout;
|
|
258
276
|
this.logger = logger;
|
|
259
|
-
this.fullDatetime = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
260
|
-
this.shortDatetime = this.fullDatetime.slice(0, 8);
|
|
261
|
-
this.signingKey = this._getSignatureKey(this.shortDatetime);
|
|
262
|
-
this.credentialScope = [this.shortDatetime, this.region, S3_SERVICE, AWS_REQUEST_TYPE].join('/');
|
|
263
277
|
}
|
|
264
278
|
_sanitize(obj) {
|
|
265
279
|
if (typeof obj !== 'object' || obj === null) {
|
|
@@ -314,11 +328,15 @@ class s3mini {
|
|
|
314
328
|
}
|
|
315
329
|
}
|
|
316
330
|
_ensureValidUrl(raw) {
|
|
317
|
-
// prepend https:// if user forgot a scheme
|
|
318
331
|
const candidate = /^(https?:)?\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
319
332
|
try {
|
|
320
333
|
new URL(candidate);
|
|
321
|
-
|
|
334
|
+
// Find the last non-slash character
|
|
335
|
+
let endIndex = candidate.length;
|
|
336
|
+
while (endIndex > 0 && candidate[endIndex - 1] === '/') {
|
|
337
|
+
endIndex--;
|
|
338
|
+
}
|
|
339
|
+
return endIndex === candidate.length ? candidate : candidate.substring(0, endIndex);
|
|
322
340
|
}
|
|
323
341
|
catch {
|
|
324
342
|
const msg = `${ERROR_ENDPOINT_FORMAT} But provided: "${raw}"`;
|
|
@@ -329,7 +347,7 @@ class s3mini {
|
|
|
329
347
|
_validateMethodIsGetOrHead(method) {
|
|
330
348
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
331
349
|
this._log('error', `${ERROR_PREFIX}method must be either GET or HEAD`);
|
|
332
|
-
throw new Error(
|
|
350
|
+
throw new Error(`${ERROR_PREFIX}method must be either GET or HEAD`);
|
|
333
351
|
}
|
|
334
352
|
}
|
|
335
353
|
_checkKey(key) {
|
|
@@ -401,8 +419,11 @@ class s3mini {
|
|
|
401
419
|
url.pathname =
|
|
402
420
|
url.pathname === '/' ? `/${keyPath.replace(/^\/+/, '')}` : `${url.pathname}/${keyPath.replace(/^\/+/, '')}`;
|
|
403
421
|
}
|
|
422
|
+
const fullDatetime = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
423
|
+
const shortDatetime = fullDatetime.slice(0, 8);
|
|
424
|
+
const credentialScope = this._buildCredentialScope(shortDatetime);
|
|
404
425
|
headers[HEADER_AMZ_CONTENT_SHA256] = UNSIGNED_PAYLOAD; // body ? U.hash(body) : C.UNSIGNED_PAYLOAD;
|
|
405
|
-
headers[HEADER_AMZ_DATE] =
|
|
426
|
+
headers[HEADER_AMZ_DATE] = fullDatetime;
|
|
406
427
|
headers[HEADER_HOST] = url.host;
|
|
407
428
|
const canonicalHeaders = this._buildCanonicalHeaders(headers);
|
|
408
429
|
const signedHeaders = Object.keys(headers)
|
|
@@ -410,9 +431,9 @@ class s3mini {
|
|
|
410
431
|
.sort()
|
|
411
432
|
.join(';');
|
|
412
433
|
const canonicalRequest = this._buildCanonicalRequest(method, url, query, canonicalHeaders, signedHeaders);
|
|
413
|
-
const stringToSign = this._buildStringToSign(canonicalRequest);
|
|
414
|
-
const signature = this._calculateSignature(stringToSign);
|
|
415
|
-
const authorizationHeader = this._buildAuthorizationHeader(signedHeaders, signature);
|
|
434
|
+
const stringToSign = this._buildStringToSign(fullDatetime, credentialScope, canonicalRequest);
|
|
435
|
+
const signature = this._calculateSignature(shortDatetime, stringToSign);
|
|
436
|
+
const authorizationHeader = this._buildAuthorizationHeader(credentialScope, signedHeaders, signature);
|
|
416
437
|
headers[HEADER_AUTHORIZATION] = authorizationHeader;
|
|
417
438
|
return { url: url.toString(), headers };
|
|
418
439
|
}
|
|
@@ -432,15 +453,22 @@ class s3mini {
|
|
|
432
453
|
UNSIGNED_PAYLOAD,
|
|
433
454
|
].join('\n');
|
|
434
455
|
}
|
|
435
|
-
|
|
436
|
-
return [
|
|
456
|
+
_buildCredentialScope(shortDatetime) {
|
|
457
|
+
return [shortDatetime, this.region, S3_SERVICE, AWS_REQUEST_TYPE].join('/');
|
|
437
458
|
}
|
|
438
|
-
|
|
459
|
+
_buildStringToSign(fullDatetime, credentialScope, canonicalRequest) {
|
|
460
|
+
return [AWS_ALGORITHM, fullDatetime, credentialScope, hash(canonicalRequest)].join('\n');
|
|
461
|
+
}
|
|
462
|
+
_calculateSignature(shortDatetime, stringToSign) {
|
|
463
|
+
if (shortDatetime !== this.signingKeyDate) {
|
|
464
|
+
this.signingKeyDate = shortDatetime;
|
|
465
|
+
this.signingKey = this._getSignatureKey(shortDatetime);
|
|
466
|
+
}
|
|
439
467
|
return hmac(this.signingKey, stringToSign, 'hex');
|
|
440
468
|
}
|
|
441
|
-
_buildAuthorizationHeader(signedHeaders, signature) {
|
|
469
|
+
_buildAuthorizationHeader(credentialScope, signedHeaders, signature) {
|
|
442
470
|
return [
|
|
443
|
-
`${AWS_ALGORITHM} Credential=${this.accessKeyId}/${
|
|
471
|
+
`${AWS_ALGORITHM} Credential=${this.accessKeyId}/${credentialScope}`,
|
|
444
472
|
`SignedHeaders=${signedHeaders}`,
|
|
445
473
|
`Signature=${signature}`,
|
|
446
474
|
].join(', ');
|
|
@@ -479,6 +507,13 @@ class s3mini {
|
|
|
479
507
|
const signedHeadersString = Object.fromEntries(Object.entries(signedHeaders).map(([k, v]) => [k, String(v)]));
|
|
480
508
|
return this._sendRequest(finalUrl, method, signedHeadersString, body, tolerated);
|
|
481
509
|
}
|
|
510
|
+
/**
|
|
511
|
+
* Gets the current configuration properties of the S3 instance.
|
|
512
|
+
* @returns {IT.S3Config} The current S3 configuration object containing all settings.
|
|
513
|
+
* @example
|
|
514
|
+
* const config = s3.getProps();
|
|
515
|
+
* console.log(config.endpoint); // 'https://s3.amazonaws.com/my-bucket'
|
|
516
|
+
*/
|
|
482
517
|
getProps() {
|
|
483
518
|
return {
|
|
484
519
|
accessKeyId: this.accessKeyId,
|
|
@@ -490,6 +525,25 @@ class s3mini {
|
|
|
490
525
|
logger: this.logger,
|
|
491
526
|
};
|
|
492
527
|
}
|
|
528
|
+
/**
|
|
529
|
+
* Updates the configuration properties of the S3 instance.
|
|
530
|
+
* @param {IT.S3Config} props - The new configuration object.
|
|
531
|
+
* @param {string} props.accessKeyId - The access key ID for authentication.
|
|
532
|
+
* @param {string} props.secretAccessKey - The secret access key for authentication.
|
|
533
|
+
* @param {string} props.endpoint - The endpoint URL of the S3-compatible service.
|
|
534
|
+
* @param {string} [props.region='auto'] - The region of the S3 service.
|
|
535
|
+
* @param {number} [props.requestSizeInBytes=8388608] - The request size of a single request in bytes.
|
|
536
|
+
* @param {number} [props.requestAbortTimeout] - The timeout in milliseconds after which a request should be aborted.
|
|
537
|
+
* @param {IT.Logger} [props.logger] - A logger object with methods like info, warn, error.
|
|
538
|
+
* @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type.
|
|
539
|
+
* @example
|
|
540
|
+
* s3.setProps({
|
|
541
|
+
* accessKeyId: 'new-access-key',
|
|
542
|
+
* secretAccessKey: 'new-secret-key',
|
|
543
|
+
* endpoint: 'https://new-endpoint.com/my-bucket',
|
|
544
|
+
* region: 'us-west-2' // by default is auto
|
|
545
|
+
* });
|
|
546
|
+
*/
|
|
493
547
|
setProps(props) {
|
|
494
548
|
this._validateConstructorParams(props.accessKeyId, props.secretAccessKey, props.endpoint);
|
|
495
549
|
this.accessKeyId = props.accessKeyId;
|
|
@@ -500,10 +554,22 @@ class s3mini {
|
|
|
500
554
|
this.requestAbortTimeout = props.requestAbortTimeout;
|
|
501
555
|
this.logger = props.logger;
|
|
502
556
|
}
|
|
557
|
+
/**
|
|
558
|
+
* Sanitizes an ETag value by removing surrounding quotes and whitespace.
|
|
559
|
+
* Still returns RFC compliant ETag. https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3
|
|
560
|
+
* @param {string} etag - The ETag value to sanitize.
|
|
561
|
+
* @returns {string} The sanitized ETag value.
|
|
562
|
+
* @example
|
|
563
|
+
* const cleanEtag = s3.sanitizeETag('"abc123"'); // Returns: 'abc123'
|
|
564
|
+
*/
|
|
503
565
|
sanitizeETag(etag) {
|
|
504
566
|
return sanitizeETag(etag);
|
|
505
567
|
}
|
|
506
|
-
|
|
568
|
+
/**
|
|
569
|
+
* Creates a new bucket.
|
|
570
|
+
* This method sends a request to create a new bucket in the specified in endpoint.
|
|
571
|
+
* @returns A promise that resolves to true if the bucket was created successfully, false otherwise.
|
|
572
|
+
*/
|
|
507
573
|
async createBucket() {
|
|
508
574
|
const xmlBody = `
|
|
509
575
|
<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
@@ -521,10 +587,30 @@ class s3mini {
|
|
|
521
587
|
});
|
|
522
588
|
return res.status === 200;
|
|
523
589
|
}
|
|
590
|
+
/**
|
|
591
|
+
* Checks if a bucket exists.
|
|
592
|
+
* This method sends a request to check if the specified bucket exists in the S3-compatible service.
|
|
593
|
+
* @returns A promise that resolves to true if the bucket exists, false otherwise.
|
|
594
|
+
*/
|
|
524
595
|
async bucketExists() {
|
|
525
596
|
const res = await this._signedRequest('HEAD', '', { tolerated: [200, 404, 403] });
|
|
526
597
|
return res.status === 200;
|
|
527
598
|
}
|
|
599
|
+
/**
|
|
600
|
+
* Lists objects in the bucket with optional filtering and no pagination.
|
|
601
|
+
* This method retrieves all objects matching the criteria (not paginated like listObjectsV2).
|
|
602
|
+
* @param {string} [delimiter='/'] - The delimiter to use for grouping objects.
|
|
603
|
+
* @param {string} [prefix=''] - The prefix to filter objects by.
|
|
604
|
+
* @param {number} [maxKeys] - The maximum number of keys to return. If not provided, all keys will be returned.
|
|
605
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
606
|
+
* @returns {Promise<object[] | null>} A promise that resolves to an array of objects or null if the bucket is empty.
|
|
607
|
+
* @example
|
|
608
|
+
* // List all objects
|
|
609
|
+
* const objects = await s3.listObjects();
|
|
610
|
+
*
|
|
611
|
+
* // List objects with prefix
|
|
612
|
+
* const photos = await s3.listObjects('/', 'photos/', 100);
|
|
613
|
+
*/
|
|
528
614
|
async listObjects(delimiter = '/', prefix = '', maxKeys,
|
|
529
615
|
// method: IT.HttpMethod = 'GET', // 'GET' or 'HEAD'
|
|
530
616
|
opts = {}) {
|
|
@@ -565,9 +651,9 @@ class s3mini {
|
|
|
565
651
|
this._log('error', `${ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`);
|
|
566
652
|
throw new Error(`${ERROR_PREFIX}Unexpected listObjects response shape`);
|
|
567
653
|
}
|
|
568
|
-
const out = (
|
|
654
|
+
const out = (raw.ListBucketResult || raw.listBucketResult || raw);
|
|
569
655
|
/* accumulate Contents */
|
|
570
|
-
const contents = out.contents;
|
|
656
|
+
const contents = out.Contents || out.contents; // S3 v2 vs v1
|
|
571
657
|
if (contents) {
|
|
572
658
|
const batch = Array.isArray(contents) ? contents : [contents];
|
|
573
659
|
all.push(...batch);
|
|
@@ -575,13 +661,22 @@ class s3mini {
|
|
|
575
661
|
remaining -= batch.length;
|
|
576
662
|
}
|
|
577
663
|
}
|
|
578
|
-
const truncated = out.
|
|
664
|
+
const truncated = out.IsTruncated === 'true' || out.isTruncated === 'true' || false;
|
|
579
665
|
token = truncated
|
|
580
|
-
? (out.
|
|
666
|
+
? (out.NextContinuationToken || out.nextContinuationToken || out.NextMarker || out.nextMarker)
|
|
581
667
|
: undefined;
|
|
582
668
|
} while (token && remaining > 0);
|
|
583
669
|
return all;
|
|
584
670
|
}
|
|
671
|
+
/**
|
|
672
|
+
* Lists multipart uploads in the bucket.
|
|
673
|
+
* This method sends a request to list multipart uploads in the specified bucket.
|
|
674
|
+
* @param {string} [delimiter='/'] - The delimiter to use for grouping uploads.
|
|
675
|
+
* @param {string} [prefix=''] - The prefix to filter uploads by.
|
|
676
|
+
* @param {IT.HttpMethod} [method='GET'] - The HTTP method to use for the request (GET or HEAD).
|
|
677
|
+
* @param {Record<string, string | number | boolean | undefined>} [opts={}] - Additional options for the request.
|
|
678
|
+
* @returns A promise that resolves to a list of multipart uploads or an error.
|
|
679
|
+
*/
|
|
585
680
|
async listMultipartUploads(delimiter = '/', prefix = '', method = 'GET', opts = {}) {
|
|
586
681
|
this._checkDelimiter(delimiter);
|
|
587
682
|
this._checkPrefix(prefix);
|
|
@@ -610,6 +705,13 @@ class s3mini {
|
|
|
610
705
|
}
|
|
611
706
|
return raw;
|
|
612
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* Get an object from the S3-compatible service.
|
|
710
|
+
* This method sends a request to retrieve the specified object from the S3-compatible service.
|
|
711
|
+
* @param {string} key - The key of the object to retrieve.
|
|
712
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
713
|
+
* @returns A promise that resolves to the object data (string) or null if not found.
|
|
714
|
+
*/
|
|
613
715
|
async getObject(key, opts = {}) {
|
|
614
716
|
const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
|
|
615
717
|
if ([404, 412, 304].includes(res.status)) {
|
|
@@ -617,6 +719,13 @@ class s3mini {
|
|
|
617
719
|
}
|
|
618
720
|
return res.text();
|
|
619
721
|
}
|
|
722
|
+
/**
|
|
723
|
+
* Get an object response from the S3-compatible service.
|
|
724
|
+
* This method sends a request to retrieve the specified object and returns the full response.
|
|
725
|
+
* @param {string} key - The key of the object to retrieve.
|
|
726
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
727
|
+
* @returns A promise that resolves to the Response object or null if not found.
|
|
728
|
+
*/
|
|
620
729
|
async getObjectResponse(key, opts = {}) {
|
|
621
730
|
const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
|
|
622
731
|
if ([404, 412, 304].includes(res.status)) {
|
|
@@ -624,6 +733,13 @@ class s3mini {
|
|
|
624
733
|
}
|
|
625
734
|
return res;
|
|
626
735
|
}
|
|
736
|
+
/**
|
|
737
|
+
* Get an object as an ArrayBuffer from the S3-compatible service.
|
|
738
|
+
* This method sends a request to retrieve the specified object and returns it as an ArrayBuffer.
|
|
739
|
+
* @param {string} key - The key of the object to retrieve.
|
|
740
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
741
|
+
* @returns A promise that resolves to the object data as an ArrayBuffer or null if not found.
|
|
742
|
+
*/
|
|
627
743
|
async getObjectArrayBuffer(key, opts = {}) {
|
|
628
744
|
const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
|
|
629
745
|
if ([404, 412, 304].includes(res.status)) {
|
|
@@ -631,6 +747,13 @@ class s3mini {
|
|
|
631
747
|
}
|
|
632
748
|
return res.arrayBuffer();
|
|
633
749
|
}
|
|
750
|
+
/**
|
|
751
|
+
* Get an object as JSON from the S3-compatible service.
|
|
752
|
+
* This method sends a request to retrieve the specified object and returns it as JSON.
|
|
753
|
+
* @param {string} key - The key of the object to retrieve.
|
|
754
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
755
|
+
* @returns A promise that resolves to the object data as JSON or null if not found.
|
|
756
|
+
*/
|
|
634
757
|
async getObjectJSON(key, opts = {}) {
|
|
635
758
|
const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
|
|
636
759
|
if ([404, 412, 304].includes(res.status)) {
|
|
@@ -638,6 +761,13 @@ class s3mini {
|
|
|
638
761
|
}
|
|
639
762
|
return res.json();
|
|
640
763
|
}
|
|
764
|
+
/**
|
|
765
|
+
* Get an object with its ETag from the S3-compatible service.
|
|
766
|
+
* This method sends a request to retrieve the specified object and its ETag.
|
|
767
|
+
* @param {string} key - The key of the object to retrieve.
|
|
768
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
769
|
+
* @returns A promise that resolves to an object containing the ETag and the object data as an ArrayBuffer or null if not found.
|
|
770
|
+
*/
|
|
641
771
|
async getObjectWithETag(key, opts = {}) {
|
|
642
772
|
try {
|
|
643
773
|
const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
|
|
@@ -646,7 +776,7 @@ class s3mini {
|
|
|
646
776
|
}
|
|
647
777
|
const etag = res.headers.get(HEADER_ETAG);
|
|
648
778
|
if (!etag) {
|
|
649
|
-
throw new Error(
|
|
779
|
+
throw new Error(`${ERROR_PREFIX}ETag not found in response headers`);
|
|
650
780
|
}
|
|
651
781
|
return { etag: sanitizeETag(etag), data: await res.arrayBuffer() };
|
|
652
782
|
}
|
|
@@ -655,6 +785,16 @@ class s3mini {
|
|
|
655
785
|
throw err;
|
|
656
786
|
}
|
|
657
787
|
}
|
|
788
|
+
/**
|
|
789
|
+
* Get an object as a raw response from the S3-compatible service.
|
|
790
|
+
* This method sends a request to retrieve the specified object and returns the raw response.
|
|
791
|
+
* @param {string} key - The key of the object to retrieve.
|
|
792
|
+
* @param {boolean} [wholeFile=true] - Whether to retrieve the whole file or a range.
|
|
793
|
+
* @param {number} [rangeFrom=0] - The starting byte for the range (if not whole file).
|
|
794
|
+
* @param {number} [rangeTo=this.requestSizeInBytes] - The ending byte for the range (if not whole file).
|
|
795
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
796
|
+
* @returns A promise that resolves to the Response object.
|
|
797
|
+
*/
|
|
658
798
|
async getObjectRaw(key, wholeFile = true, rangeFrom = 0, rangeTo = this.requestSizeInBytes, opts = {}) {
|
|
659
799
|
const rangeHdr = wholeFile ? {} : { range: `bytes=${rangeFrom}-${rangeTo - 1}` };
|
|
660
800
|
return this._signedRequest('GET', key, {
|
|
@@ -663,11 +803,31 @@ class s3mini {
|
|
|
663
803
|
withQuery: true, // keep ?query=string behaviour
|
|
664
804
|
});
|
|
665
805
|
}
|
|
806
|
+
/**
|
|
807
|
+
* Get the content length of an object.
|
|
808
|
+
* This method sends a HEAD request to retrieve the content length of the specified object.
|
|
809
|
+
* @param {string} key - The key of the object to retrieve the content length for.
|
|
810
|
+
* @returns A promise that resolves to the content length of the object in bytes, or 0 if not found.
|
|
811
|
+
* @throws {Error} If the content length header is not found in the response.
|
|
812
|
+
*/
|
|
666
813
|
async getContentLength(key) {
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
814
|
+
try {
|
|
815
|
+
const res = await this._signedRequest('HEAD', key);
|
|
816
|
+
const len = res.headers.get(HEADER_CONTENT_LENGTH);
|
|
817
|
+
return len ? +len : 0;
|
|
818
|
+
}
|
|
819
|
+
catch (err) {
|
|
820
|
+
this._log('error', `Error getting content length for object ${key}: ${String(err)}`);
|
|
821
|
+
throw new Error(`${ERROR_PREFIX}Error getting content length for object ${key}: ${String(err)}`);
|
|
822
|
+
}
|
|
670
823
|
}
|
|
824
|
+
/**
|
|
825
|
+
* Checks if an object exists in the S3-compatible service.
|
|
826
|
+
* This method sends a HEAD request to check if the specified object exists.
|
|
827
|
+
* @param {string} key - The key of the object to check.
|
|
828
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
829
|
+
* @returns A promise that resolves to true if the object exists, false if not found, or null if ETag mismatch.
|
|
830
|
+
*/
|
|
671
831
|
async objectExists(key, opts = {}) {
|
|
672
832
|
const res = await this._signedRequest('HEAD', key, {
|
|
673
833
|
query: opts,
|
|
@@ -681,6 +841,18 @@ class s3mini {
|
|
|
681
841
|
}
|
|
682
842
|
return true; // found (200)
|
|
683
843
|
}
|
|
844
|
+
/**
|
|
845
|
+
* Retrieves the ETag of an object without downloading its content.
|
|
846
|
+
* @param {string} key - The key of the object to retrieve the ETag for.
|
|
847
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
848
|
+
* @returns {Promise<string | null>} A promise that resolves to the ETag value or null if the object is not found.
|
|
849
|
+
* @throws {Error} If the ETag header is not found in the response.
|
|
850
|
+
* @example
|
|
851
|
+
* const etag = await s3.getEtag('path/to/file.txt');
|
|
852
|
+
* if (etag) {
|
|
853
|
+
* console.log(`File ETag: ${etag}`);
|
|
854
|
+
* }
|
|
855
|
+
*/
|
|
684
856
|
async getEtag(key, opts = {}) {
|
|
685
857
|
const res = await this._signedRequest('HEAD', key, {
|
|
686
858
|
query: opts,
|
|
@@ -691,20 +863,49 @@ class s3mini {
|
|
|
691
863
|
}
|
|
692
864
|
const etag = res.headers.get(HEADER_ETAG);
|
|
693
865
|
if (!etag) {
|
|
694
|
-
throw new Error(
|
|
866
|
+
throw new Error(`${ERROR_PREFIX}ETag not found in response headers`);
|
|
695
867
|
}
|
|
696
868
|
return sanitizeETag(etag);
|
|
697
869
|
}
|
|
698
|
-
|
|
870
|
+
/**
|
|
871
|
+
* Uploads an object to the S3-compatible service.
|
|
872
|
+
* @param {string} key - The key/path where the object will be stored.
|
|
873
|
+
* @param {string | Buffer} data - The data to upload (string or Buffer).
|
|
874
|
+
* @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
|
|
875
|
+
* @returns {Promise<Response>} A promise that resolves to the Response object from the upload request.
|
|
876
|
+
* @throws {TypeError} If data is not a string or Buffer.
|
|
877
|
+
* @example
|
|
878
|
+
* // Upload text file
|
|
879
|
+
* await s3.putObject('hello.txt', 'Hello, World!', 'text/plain');
|
|
880
|
+
*
|
|
881
|
+
* // Upload binary data
|
|
882
|
+
* const buffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
|
883
|
+
* await s3.putObject('image.png', buffer, 'image/png');
|
|
884
|
+
*/
|
|
885
|
+
async putObject(key, data, fileType = DEFAULT_STREAM_CONTENT_TYPE) {
|
|
699
886
|
if (!(data instanceof Buffer || typeof data === 'string')) {
|
|
700
887
|
throw new TypeError(ERROR_DATA_BUFFER_REQUIRED);
|
|
701
888
|
}
|
|
702
889
|
return this._signedRequest('PUT', key, {
|
|
703
890
|
body: data,
|
|
704
|
-
headers: {
|
|
891
|
+
headers: {
|
|
892
|
+
[HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length,
|
|
893
|
+
[HEADER_CONTENT_TYPE]: fileType,
|
|
894
|
+
},
|
|
705
895
|
tolerated: [200],
|
|
706
896
|
});
|
|
707
897
|
}
|
|
898
|
+
/**
|
|
899
|
+
* Initiates a multipart upload and returns the upload ID.
|
|
900
|
+
* @param {string} key - The key/path where the object will be stored.
|
|
901
|
+
* @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
|
|
902
|
+
* @returns {Promise<string>} A promise that resolves to the upload ID for the multipart upload.
|
|
903
|
+
* @throws {TypeError} If key is invalid or fileType is not a string.
|
|
904
|
+
* @throws {Error} If the multipart upload fails to initialize.
|
|
905
|
+
* @example
|
|
906
|
+
* const uploadId = await s3.getMultipartUploadId('large-file.zip', 'application/zip');
|
|
907
|
+
* console.log(`Started multipart upload: ${uploadId}`);
|
|
908
|
+
*/
|
|
708
909
|
async getMultipartUploadId(key, fileType = DEFAULT_STREAM_CONTENT_TYPE) {
|
|
709
910
|
this._checkKey(key);
|
|
710
911
|
if (typeof fileType !== 'string') {
|
|
@@ -718,15 +919,47 @@ class s3mini {
|
|
|
718
919
|
withQuery: true,
|
|
719
920
|
});
|
|
720
921
|
const parsed = parseXml(await res.text());
|
|
721
|
-
if (
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
922
|
+
// if (
|
|
923
|
+
// parsed &&
|
|
924
|
+
// typeof parsed === 'object' &&
|
|
925
|
+
// 'initiateMultipartUploadResult' in parsed &&
|
|
926
|
+
// parsed.initiateMultipartUploadResult &&
|
|
927
|
+
// 'uploadId' in (parsed.initiateMultipartUploadResult as { uploadId: string })
|
|
928
|
+
// ) {
|
|
929
|
+
// return (parsed.initiateMultipartUploadResult as { uploadId: string }).uploadId;
|
|
930
|
+
// }
|
|
931
|
+
if (parsed && typeof parsed === 'object') {
|
|
932
|
+
// Check for both cases of InitiateMultipartUploadResult
|
|
933
|
+
const uploadResult = parsed.initiateMultipartUploadResult ||
|
|
934
|
+
parsed.InitiateMultipartUploadResult;
|
|
935
|
+
if (uploadResult && typeof uploadResult === 'object') {
|
|
936
|
+
// Check for both cases of uploadId
|
|
937
|
+
const uploadId = uploadResult.uploadId || uploadResult.UploadId;
|
|
938
|
+
if (uploadId && typeof uploadId === 'string') {
|
|
939
|
+
return uploadId;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
727
942
|
}
|
|
728
943
|
throw new Error(`${ERROR_PREFIX}Failed to create multipart upload: ${JSON.stringify(parsed)}`);
|
|
729
944
|
}
|
|
945
|
+
/**
|
|
946
|
+
* Uploads a part in a multipart upload.
|
|
947
|
+
* @param {string} key - The key of the object being uploaded.
|
|
948
|
+
* @param {string} uploadId - The upload ID from getMultipartUploadId.
|
|
949
|
+
* @param {Buffer | string} data - The data for this part.
|
|
950
|
+
* @param {number} partNumber - The part number (must be between 1 and 10,000).
|
|
951
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
952
|
+
* @returns {Promise<IT.UploadPart>} A promise that resolves to an object containing the partNumber and etag.
|
|
953
|
+
* @throws {TypeError} If any parameter is invalid.
|
|
954
|
+
* @example
|
|
955
|
+
* const part = await s3.uploadPart(
|
|
956
|
+
* 'large-file.zip',
|
|
957
|
+
* uploadId,
|
|
958
|
+
* partData,
|
|
959
|
+
* 1
|
|
960
|
+
* );
|
|
961
|
+
* console.log(`Part ${part.partNumber} uploaded with ETag: ${part.etag}`);
|
|
962
|
+
*/
|
|
730
963
|
async uploadPart(key, uploadId, data, partNumber, opts = {}) {
|
|
731
964
|
this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
|
|
732
965
|
const query = { uploadId, partNumber, ...opts };
|
|
@@ -737,8 +970,25 @@ class s3mini {
|
|
|
737
970
|
});
|
|
738
971
|
return { partNumber, etag: sanitizeETag(res.headers.get('etag') || '') };
|
|
739
972
|
}
|
|
973
|
+
/**
|
|
974
|
+
* Completes a multipart upload by combining all uploaded parts.
|
|
975
|
+
* @param {string} key - The key of the object being uploaded.
|
|
976
|
+
* @param {string} uploadId - The upload ID from getMultipartUploadId.
|
|
977
|
+
* @param {Array<IT.UploadPart>} parts - Array of uploaded parts with partNumber and etag.
|
|
978
|
+
* @returns {Promise<IT.CompleteMultipartUploadResult>} A promise that resolves to the completion result containing the final ETag.
|
|
979
|
+
* @throws {Error} If the multipart upload fails to complete.
|
|
980
|
+
* @example
|
|
981
|
+
* const result = await s3.completeMultipartUpload(
|
|
982
|
+
* 'large-file.zip',
|
|
983
|
+
* uploadId,
|
|
984
|
+
* [
|
|
985
|
+
* { partNumber: 1, etag: 'abc123' },
|
|
986
|
+
* { partNumber: 2, etag: 'def456' }
|
|
987
|
+
* ]
|
|
988
|
+
* );
|
|
989
|
+
* console.log(`Upload completed with ETag: ${result.etag}`);
|
|
990
|
+
*/
|
|
740
991
|
async completeMultipartUpload(key, uploadId, parts) {
|
|
741
|
-
// …existing validation left untouched …
|
|
742
992
|
const query = { uploadId };
|
|
743
993
|
const xmlBody = this._buildCompleteMultipartUploadXml(parts);
|
|
744
994
|
const headers = {
|
|
@@ -752,17 +1002,39 @@ class s3mini {
|
|
|
752
1002
|
withQuery: true,
|
|
753
1003
|
});
|
|
754
1004
|
const parsed = parseXml(await res.text());
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1005
|
+
if (parsed && typeof parsed === 'object') {
|
|
1006
|
+
// Check for both cases
|
|
1007
|
+
const result = parsed.completeMultipartUploadResult || parsed.CompleteMultipartUploadResult || parsed;
|
|
1008
|
+
if (result && typeof result === 'object') {
|
|
1009
|
+
const resultObj = result;
|
|
1010
|
+
// Handle ETag in all its variations
|
|
1011
|
+
const etag = resultObj.ETag || resultObj.eTag || resultObj.etag;
|
|
1012
|
+
if (etag && typeof etag === 'string') {
|
|
1013
|
+
return {
|
|
1014
|
+
...resultObj,
|
|
1015
|
+
etag: this.sanitizeETag(etag),
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
return result;
|
|
1019
|
+
}
|
|
763
1020
|
}
|
|
764
|
-
|
|
1021
|
+
throw new Error(`${ERROR_PREFIX}Failed to complete multipart upload: ${JSON.stringify(parsed)}`);
|
|
765
1022
|
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Aborts a multipart upload and removes all uploaded parts.
|
|
1025
|
+
* @param {string} key - The key of the object being uploaded.
|
|
1026
|
+
* @param {string} uploadId - The upload ID to abort.
|
|
1027
|
+
* @returns {Promise<object>} A promise that resolves to an object containing the abort status and details.
|
|
1028
|
+
* @throws {TypeError} If key or uploadId is invalid.
|
|
1029
|
+
* @throws {Error} If the abort operation fails.
|
|
1030
|
+
* @example
|
|
1031
|
+
* try {
|
|
1032
|
+
* const result = await s3.abortMultipartUpload('large-file.zip', uploadId);
|
|
1033
|
+
* console.log('Upload aborted:', result.status);
|
|
1034
|
+
* } catch (error) {
|
|
1035
|
+
* console.error('Failed to abort upload:', error);
|
|
1036
|
+
* }
|
|
1037
|
+
*/
|
|
766
1038
|
async abortMultipartUpload(key, uploadId) {
|
|
767
1039
|
this._checkKey(key);
|
|
768
1040
|
if (!uploadId) {
|
|
@@ -800,10 +1072,101 @@ class s3mini {
|
|
|
800
1072
|
</CompleteMultipartUpload>
|
|
801
1073
|
`;
|
|
802
1074
|
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Deletes an object from the bucket.
|
|
1077
|
+
* This method sends a request to delete the specified object from the bucket.
|
|
1078
|
+
* @param {string} key - The key of the object to delete.
|
|
1079
|
+
* @returns A promise that resolves to true if the object was deleted successfully, false otherwise.
|
|
1080
|
+
*/
|
|
803
1081
|
async deleteObject(key) {
|
|
804
1082
|
const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] });
|
|
805
1083
|
return res.status === 200 || res.status === 204;
|
|
806
1084
|
}
|
|
1085
|
+
async _deleteObjectsProcess(keys) {
|
|
1086
|
+
const xmlBody = `<Delete>${keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('')}</Delete>`;
|
|
1087
|
+
const query = { delete: '' };
|
|
1088
|
+
const md5Base64 = md5base64(xmlBody);
|
|
1089
|
+
const headers = {
|
|
1090
|
+
[HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
|
|
1091
|
+
[HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(),
|
|
1092
|
+
'Content-MD5': md5Base64,
|
|
1093
|
+
};
|
|
1094
|
+
const res = await this._signedRequest('POST', '', {
|
|
1095
|
+
query,
|
|
1096
|
+
body: xmlBody,
|
|
1097
|
+
headers,
|
|
1098
|
+
withQuery: true,
|
|
1099
|
+
});
|
|
1100
|
+
const parsed = parseXml(await res.text());
|
|
1101
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
1102
|
+
throw new Error(`${ERROR_PREFIX}Failed to delete objects: ${JSON.stringify(parsed)}`);
|
|
1103
|
+
}
|
|
1104
|
+
const out = (parsed.DeleteResult || parsed.deleteResult || parsed);
|
|
1105
|
+
const resultMap = new Map();
|
|
1106
|
+
keys.forEach(key => resultMap.set(key, false));
|
|
1107
|
+
const deleted = out.deleted || out.Deleted;
|
|
1108
|
+
if (deleted) {
|
|
1109
|
+
const deletedArray = Array.isArray(deleted) ? deleted : [deleted];
|
|
1110
|
+
deletedArray.forEach((item) => {
|
|
1111
|
+
if (item && typeof item === 'object') {
|
|
1112
|
+
const obj = item;
|
|
1113
|
+
// Check both key and Key
|
|
1114
|
+
const key = obj.key || obj.Key;
|
|
1115
|
+
if (key && typeof key === 'string') {
|
|
1116
|
+
resultMap.set(key, true);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
// Handle errors (check both cases)
|
|
1122
|
+
const errors = out.error || out.Error;
|
|
1123
|
+
if (errors) {
|
|
1124
|
+
const errorsArray = Array.isArray(errors) ? errors : [errors];
|
|
1125
|
+
errorsArray.forEach((item) => {
|
|
1126
|
+
if (item && typeof item === 'object') {
|
|
1127
|
+
const obj = item;
|
|
1128
|
+
// Check both cases for all properties
|
|
1129
|
+
const key = obj.key || obj.Key;
|
|
1130
|
+
const code = obj.code || obj.Code;
|
|
1131
|
+
const message = obj.message || obj.Message;
|
|
1132
|
+
if (key && typeof key === 'string') {
|
|
1133
|
+
resultMap.set(key, false);
|
|
1134
|
+
// Optionally log the error for debugging
|
|
1135
|
+
this._log('warn', `Failed to delete object: ${key}`, {
|
|
1136
|
+
code: code || 'Unknown',
|
|
1137
|
+
message: message || 'Unknown error',
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
// Return boolean array in the same order as input keys
|
|
1144
|
+
return keys.map(key => resultMap.get(key) || false);
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Deletes multiple objects from the bucket.
|
|
1148
|
+
* @param {string[]} keys - An array of object keys to delete.
|
|
1149
|
+
* @returns A promise that resolves to an array of booleans indicating success for each key in order.
|
|
1150
|
+
*/
|
|
1151
|
+
async deleteObjects(keys) {
|
|
1152
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
1153
|
+
return [];
|
|
1154
|
+
}
|
|
1155
|
+
const maxBatchSize = 1000; // S3 limit for delete batch size
|
|
1156
|
+
if (keys.length > maxBatchSize) {
|
|
1157
|
+
const allPromises = [];
|
|
1158
|
+
for (let i = 0; i < keys.length; i += maxBatchSize) {
|
|
1159
|
+
const batch = keys.slice(i, i + maxBatchSize);
|
|
1160
|
+
allPromises.push(this._deleteObjectsProcess(batch));
|
|
1161
|
+
}
|
|
1162
|
+
const results = await Promise.all(allPromises);
|
|
1163
|
+
// Flatten the results array
|
|
1164
|
+
return results.flat();
|
|
1165
|
+
}
|
|
1166
|
+
else {
|
|
1167
|
+
return await this._deleteObjectsProcess(keys);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
807
1170
|
async _sendRequest(url, method, headers, body, toleratedStatusCodes = []) {
|
|
808
1171
|
this._log('info', `Sending ${method} request to ${url}`, `headers: ${JSON.stringify(headers)}`);
|
|
809
1172
|
try {
|