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/src/S3.ts
CHANGED
|
@@ -49,10 +49,8 @@ class s3mini {
|
|
|
49
49
|
private requestSizeInBytes: number;
|
|
50
50
|
private requestAbortTimeout?: number;
|
|
51
51
|
private logger?: IT.Logger;
|
|
52
|
-
private
|
|
53
|
-
private
|
|
54
|
-
private signingKey: Buffer;
|
|
55
|
-
private credentialScope: string;
|
|
52
|
+
private signingKeyDate?: string;
|
|
53
|
+
private signingKey?: Buffer;
|
|
56
54
|
|
|
57
55
|
constructor({
|
|
58
56
|
accessKeyId,
|
|
@@ -71,10 +69,6 @@ class s3mini {
|
|
|
71
69
|
this.requestSizeInBytes = requestSizeInBytes;
|
|
72
70
|
this.requestAbortTimeout = requestAbortTimeout;
|
|
73
71
|
this.logger = logger;
|
|
74
|
-
this.fullDatetime = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
75
|
-
this.shortDatetime = this.fullDatetime.slice(0, 8);
|
|
76
|
-
this.signingKey = this._getSignatureKey(this.shortDatetime);
|
|
77
|
-
this.credentialScope = [this.shortDatetime, this.region, C.S3_SERVICE, C.AWS_REQUEST_TYPE].join('/');
|
|
78
72
|
}
|
|
79
73
|
|
|
80
74
|
private _sanitize(obj: unknown): unknown {
|
|
@@ -142,11 +136,16 @@ class s3mini {
|
|
|
142
136
|
}
|
|
143
137
|
|
|
144
138
|
private _ensureValidUrl(raw: string): string {
|
|
145
|
-
// prepend https:// if user forgot a scheme
|
|
146
139
|
const candidate = /^(https?:)?\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
147
140
|
try {
|
|
148
141
|
new URL(candidate);
|
|
149
|
-
|
|
142
|
+
|
|
143
|
+
// Find the last non-slash character
|
|
144
|
+
let endIndex = candidate.length;
|
|
145
|
+
while (endIndex > 0 && candidate[endIndex - 1] === '/') {
|
|
146
|
+
endIndex--;
|
|
147
|
+
}
|
|
148
|
+
return endIndex === candidate.length ? candidate : candidate.substring(0, endIndex);
|
|
150
149
|
} catch {
|
|
151
150
|
const msg = `${C.ERROR_ENDPOINT_FORMAT} But provided: "${raw}"`;
|
|
152
151
|
this._log('error', msg);
|
|
@@ -157,7 +156,7 @@ class s3mini {
|
|
|
157
156
|
private _validateMethodIsGetOrHead(method: string): void {
|
|
158
157
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
159
158
|
this._log('error', `${C.ERROR_PREFIX}method must be either GET or HEAD`);
|
|
160
|
-
throw new Error(
|
|
159
|
+
throw new Error(`${C.ERROR_PREFIX}method must be either GET or HEAD`);
|
|
161
160
|
}
|
|
162
161
|
}
|
|
163
162
|
|
|
@@ -254,8 +253,12 @@ class s3mini {
|
|
|
254
253
|
url.pathname === '/' ? `/${keyPath.replace(/^\/+/, '')}` : `${url.pathname}/${keyPath.replace(/^\/+/, '')}`;
|
|
255
254
|
}
|
|
256
255
|
|
|
256
|
+
const fullDatetime = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
257
|
+
const shortDatetime = fullDatetime.slice(0, 8);
|
|
258
|
+
const credentialScope = this._buildCredentialScope(shortDatetime);
|
|
259
|
+
|
|
257
260
|
headers[C.HEADER_AMZ_CONTENT_SHA256] = C.UNSIGNED_PAYLOAD; // body ? U.hash(body) : C.UNSIGNED_PAYLOAD;
|
|
258
|
-
headers[C.HEADER_AMZ_DATE] =
|
|
261
|
+
headers[C.HEADER_AMZ_DATE] = fullDatetime;
|
|
259
262
|
headers[C.HEADER_HOST] = url.host;
|
|
260
263
|
const canonicalHeaders = this._buildCanonicalHeaders(headers);
|
|
261
264
|
const signedHeaders = Object.keys(headers)
|
|
@@ -264,9 +267,9 @@ class s3mini {
|
|
|
264
267
|
.join(';');
|
|
265
268
|
|
|
266
269
|
const canonicalRequest = this._buildCanonicalRequest(method, url, query, canonicalHeaders, signedHeaders);
|
|
267
|
-
const stringToSign = this._buildStringToSign(canonicalRequest);
|
|
268
|
-
const signature = this._calculateSignature(stringToSign);
|
|
269
|
-
const authorizationHeader = this._buildAuthorizationHeader(signedHeaders, signature);
|
|
270
|
+
const stringToSign = this._buildStringToSign(fullDatetime, credentialScope, canonicalRequest);
|
|
271
|
+
const signature = this._calculateSignature(shortDatetime, stringToSign);
|
|
272
|
+
const authorizationHeader = this._buildAuthorizationHeader(credentialScope, signedHeaders, signature);
|
|
270
273
|
headers[C.HEADER_AUTHORIZATION] = authorizationHeader;
|
|
271
274
|
return { url: url.toString(), headers };
|
|
272
275
|
}
|
|
@@ -295,17 +298,25 @@ class s3mini {
|
|
|
295
298
|
].join('\n');
|
|
296
299
|
}
|
|
297
300
|
|
|
298
|
-
private
|
|
299
|
-
return [
|
|
301
|
+
private _buildCredentialScope(shortDatetime: string): string {
|
|
302
|
+
return [shortDatetime, this.region, C.S3_SERVICE, C.AWS_REQUEST_TYPE].join('/');
|
|
300
303
|
}
|
|
301
304
|
|
|
302
|
-
private
|
|
303
|
-
return
|
|
305
|
+
private _buildStringToSign(fullDatetime: string, credentialScope: string, canonicalRequest: string): string {
|
|
306
|
+
return [C.AWS_ALGORITHM, fullDatetime, credentialScope, U.hash(canonicalRequest)].join('\n');
|
|
304
307
|
}
|
|
305
308
|
|
|
306
|
-
private
|
|
309
|
+
private _calculateSignature(shortDatetime: string, stringToSign: string): string {
|
|
310
|
+
if (shortDatetime !== this.signingKeyDate) {
|
|
311
|
+
this.signingKeyDate = shortDatetime;
|
|
312
|
+
this.signingKey = this._getSignatureKey(shortDatetime);
|
|
313
|
+
}
|
|
314
|
+
return U.hmac(this.signingKey!, stringToSign, 'hex') as string;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private _buildAuthorizationHeader(credentialScope: string, signedHeaders: string, signature: string): string {
|
|
307
318
|
return [
|
|
308
|
-
`${C.AWS_ALGORITHM} Credential=${this.accessKeyId}/${
|
|
319
|
+
`${C.AWS_ALGORITHM} Credential=${this.accessKeyId}/${credentialScope}`,
|
|
309
320
|
`SignedHeaders=${signedHeaders}`,
|
|
310
321
|
`Signature=${signature}`,
|
|
311
322
|
].join(', ');
|
|
@@ -363,6 +374,13 @@ class s3mini {
|
|
|
363
374
|
return this._sendRequest(finalUrl, method, signedHeadersString, body, tolerated);
|
|
364
375
|
}
|
|
365
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Gets the current configuration properties of the S3 instance.
|
|
379
|
+
* @returns {IT.S3Config} The current S3 configuration object containing all settings.
|
|
380
|
+
* @example
|
|
381
|
+
* const config = s3.getProps();
|
|
382
|
+
* console.log(config.endpoint); // 'https://s3.amazonaws.com/my-bucket'
|
|
383
|
+
*/
|
|
366
384
|
public getProps(): IT.S3Config {
|
|
367
385
|
return {
|
|
368
386
|
accessKeyId: this.accessKeyId,
|
|
@@ -374,6 +392,26 @@ class s3mini {
|
|
|
374
392
|
logger: this.logger,
|
|
375
393
|
};
|
|
376
394
|
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Updates the configuration properties of the S3 instance.
|
|
398
|
+
* @param {IT.S3Config} props - The new configuration object.
|
|
399
|
+
* @param {string} props.accessKeyId - The access key ID for authentication.
|
|
400
|
+
* @param {string} props.secretAccessKey - The secret access key for authentication.
|
|
401
|
+
* @param {string} props.endpoint - The endpoint URL of the S3-compatible service.
|
|
402
|
+
* @param {string} [props.region='auto'] - The region of the S3 service.
|
|
403
|
+
* @param {number} [props.requestSizeInBytes=8388608] - The request size of a single request in bytes.
|
|
404
|
+
* @param {number} [props.requestAbortTimeout] - The timeout in milliseconds after which a request should be aborted.
|
|
405
|
+
* @param {IT.Logger} [props.logger] - A logger object with methods like info, warn, error.
|
|
406
|
+
* @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type.
|
|
407
|
+
* @example
|
|
408
|
+
* s3.setProps({
|
|
409
|
+
* accessKeyId: 'new-access-key',
|
|
410
|
+
* secretAccessKey: 'new-secret-key',
|
|
411
|
+
* endpoint: 'https://new-endpoint.com/my-bucket',
|
|
412
|
+
* region: 'us-west-2' // by default is auto
|
|
413
|
+
* });
|
|
414
|
+
*/
|
|
377
415
|
public setProps(props: IT.S3Config): void {
|
|
378
416
|
this._validateConstructorParams(props.accessKeyId, props.secretAccessKey, props.endpoint);
|
|
379
417
|
this.accessKeyId = props.accessKeyId;
|
|
@@ -385,11 +423,23 @@ class s3mini {
|
|
|
385
423
|
this.logger = props.logger;
|
|
386
424
|
}
|
|
387
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Sanitizes an ETag value by removing surrounding quotes and whitespace.
|
|
428
|
+
* Still returns RFC compliant ETag. https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3
|
|
429
|
+
* @param {string} etag - The ETag value to sanitize.
|
|
430
|
+
* @returns {string} The sanitized ETag value.
|
|
431
|
+
* @example
|
|
432
|
+
* const cleanEtag = s3.sanitizeETag('"abc123"'); // Returns: 'abc123'
|
|
433
|
+
*/
|
|
388
434
|
public sanitizeETag(etag: string): string {
|
|
389
435
|
return U.sanitizeETag(etag);
|
|
390
436
|
}
|
|
391
437
|
|
|
392
|
-
|
|
438
|
+
/**
|
|
439
|
+
* Creates a new bucket.
|
|
440
|
+
* This method sends a request to create a new bucket in the specified in endpoint.
|
|
441
|
+
* @returns A promise that resolves to true if the bucket was created successfully, false otherwise.
|
|
442
|
+
*/
|
|
393
443
|
public async createBucket(): Promise<boolean> {
|
|
394
444
|
const xmlBody = `
|
|
395
445
|
<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
@@ -408,11 +458,31 @@ class s3mini {
|
|
|
408
458
|
return res.status === 200;
|
|
409
459
|
}
|
|
410
460
|
|
|
461
|
+
/**
|
|
462
|
+
* Checks if a bucket exists.
|
|
463
|
+
* This method sends a request to check if the specified bucket exists in the S3-compatible service.
|
|
464
|
+
* @returns A promise that resolves to true if the bucket exists, false otherwise.
|
|
465
|
+
*/
|
|
411
466
|
public async bucketExists(): Promise<boolean> {
|
|
412
467
|
const res = await this._signedRequest('HEAD', '', { tolerated: [200, 404, 403] });
|
|
413
468
|
return res.status === 200;
|
|
414
469
|
}
|
|
415
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Lists objects in the bucket with optional filtering and no pagination.
|
|
473
|
+
* This method retrieves all objects matching the criteria (not paginated like listObjectsV2).
|
|
474
|
+
* @param {string} [delimiter='/'] - The delimiter to use for grouping objects.
|
|
475
|
+
* @param {string} [prefix=''] - The prefix to filter objects by.
|
|
476
|
+
* @param {number} [maxKeys] - The maximum number of keys to return. If not provided, all keys will be returned.
|
|
477
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
478
|
+
* @returns {Promise<object[] | null>} A promise that resolves to an array of objects or null if the bucket is empty.
|
|
479
|
+
* @example
|
|
480
|
+
* // List all objects
|
|
481
|
+
* const objects = await s3.listObjects();
|
|
482
|
+
*
|
|
483
|
+
* // List objects with prefix
|
|
484
|
+
* const photos = await s3.listObjects('/', 'photos/', 100);
|
|
485
|
+
*/
|
|
416
486
|
public async listObjects(
|
|
417
487
|
delimiter: string = '/',
|
|
418
488
|
prefix: string = '',
|
|
@@ -462,16 +532,14 @@ class s3mini {
|
|
|
462
532
|
`${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
|
|
463
533
|
);
|
|
464
534
|
}
|
|
465
|
-
|
|
466
535
|
const raw = U.parseXml(await res.text()) as Record<string, unknown>;
|
|
467
536
|
if (typeof raw !== 'object' || !raw || 'error' in raw) {
|
|
468
537
|
this._log('error', `${C.ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`);
|
|
469
538
|
throw new Error(`${C.ERROR_PREFIX}Unexpected listObjects response shape`);
|
|
470
539
|
}
|
|
471
|
-
const out = (
|
|
472
|
-
|
|
540
|
+
const out = (raw.ListBucketResult || raw.listBucketResult || raw) as Record<string, unknown>;
|
|
473
541
|
/* accumulate Contents */
|
|
474
|
-
const contents = out.contents;
|
|
542
|
+
const contents = out.Contents || out.contents; // S3 v2 vs v1
|
|
475
543
|
if (contents) {
|
|
476
544
|
const batch = Array.isArray(contents) ? contents : [contents];
|
|
477
545
|
all.push(...(batch as object[]));
|
|
@@ -479,9 +547,9 @@ class s3mini {
|
|
|
479
547
|
remaining -= batch.length;
|
|
480
548
|
}
|
|
481
549
|
}
|
|
482
|
-
const truncated = out.
|
|
550
|
+
const truncated = out.IsTruncated === 'true' || out.isTruncated === 'true' || false;
|
|
483
551
|
token = truncated
|
|
484
|
-
? ((out.
|
|
552
|
+
? ((out.NextContinuationToken || out.nextContinuationToken || out.NextMarker || out.nextMarker) as
|
|
485
553
|
| string
|
|
486
554
|
| undefined)
|
|
487
555
|
: undefined;
|
|
@@ -490,6 +558,15 @@ class s3mini {
|
|
|
490
558
|
return all;
|
|
491
559
|
}
|
|
492
560
|
|
|
561
|
+
/**
|
|
562
|
+
* Lists multipart uploads in the bucket.
|
|
563
|
+
* This method sends a request to list multipart uploads in the specified bucket.
|
|
564
|
+
* @param {string} [delimiter='/'] - The delimiter to use for grouping uploads.
|
|
565
|
+
* @param {string} [prefix=''] - The prefix to filter uploads by.
|
|
566
|
+
* @param {IT.HttpMethod} [method='GET'] - The HTTP method to use for the request (GET or HEAD).
|
|
567
|
+
* @param {Record<string, string | number | boolean | undefined>} [opts={}] - Additional options for the request.
|
|
568
|
+
* @returns A promise that resolves to a list of multipart uploads or an error.
|
|
569
|
+
*/
|
|
493
570
|
public async listMultipartUploads(
|
|
494
571
|
delimiter: string = '/',
|
|
495
572
|
prefix: string = '',
|
|
@@ -516,7 +593,6 @@ class s3mini {
|
|
|
516
593
|
// etag: res.headers.get(C.HEADER_ETAG) ?? '',
|
|
517
594
|
// };
|
|
518
595
|
// }
|
|
519
|
-
|
|
520
596
|
const raw = U.parseXml(await res.text()) as unknown;
|
|
521
597
|
if (typeof raw !== 'object' || raw === null) {
|
|
522
598
|
throw new Error(`${C.ERROR_PREFIX}Unexpected listMultipartUploads response shape`);
|
|
@@ -527,6 +603,13 @@ class s3mini {
|
|
|
527
603
|
return raw as IT.MultipartUploadError;
|
|
528
604
|
}
|
|
529
605
|
|
|
606
|
+
/**
|
|
607
|
+
* Get an object from the S3-compatible service.
|
|
608
|
+
* This method sends a request to retrieve the specified object from the S3-compatible service.
|
|
609
|
+
* @param {string} key - The key of the object to retrieve.
|
|
610
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
611
|
+
* @returns A promise that resolves to the object data (string) or null if not found.
|
|
612
|
+
*/
|
|
530
613
|
public async getObject(key: string, opts: Record<string, unknown> = {}): Promise<string | null> {
|
|
531
614
|
const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
|
|
532
615
|
if ([404, 412, 304].includes(res.status)) {
|
|
@@ -535,6 +618,13 @@ class s3mini {
|
|
|
535
618
|
return res.text();
|
|
536
619
|
}
|
|
537
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Get an object response from the S3-compatible service.
|
|
623
|
+
* This method sends a request to retrieve the specified object and returns the full response.
|
|
624
|
+
* @param {string} key - The key of the object to retrieve.
|
|
625
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
626
|
+
* @returns A promise that resolves to the Response object or null if not found.
|
|
627
|
+
*/
|
|
538
628
|
public async getObjectResponse(key: string, opts: Record<string, unknown> = {}): Promise<Response | null> {
|
|
539
629
|
const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
|
|
540
630
|
if ([404, 412, 304].includes(res.status)) {
|
|
@@ -543,6 +633,13 @@ class s3mini {
|
|
|
543
633
|
return res;
|
|
544
634
|
}
|
|
545
635
|
|
|
636
|
+
/**
|
|
637
|
+
* Get an object as an ArrayBuffer from the S3-compatible service.
|
|
638
|
+
* This method sends a request to retrieve the specified object and returns it as an ArrayBuffer.
|
|
639
|
+
* @param {string} key - The key of the object to retrieve.
|
|
640
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
641
|
+
* @returns A promise that resolves to the object data as an ArrayBuffer or null if not found.
|
|
642
|
+
*/
|
|
546
643
|
public async getObjectArrayBuffer(key: string, opts: Record<string, unknown> = {}): Promise<ArrayBuffer | null> {
|
|
547
644
|
const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
|
|
548
645
|
if ([404, 412, 304].includes(res.status)) {
|
|
@@ -551,6 +648,13 @@ class s3mini {
|
|
|
551
648
|
return res.arrayBuffer();
|
|
552
649
|
}
|
|
553
650
|
|
|
651
|
+
/**
|
|
652
|
+
* Get an object as JSON from the S3-compatible service.
|
|
653
|
+
* This method sends a request to retrieve the specified object and returns it as JSON.
|
|
654
|
+
* @param {string} key - The key of the object to retrieve.
|
|
655
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
656
|
+
* @returns A promise that resolves to the object data as JSON or null if not found.
|
|
657
|
+
*/
|
|
554
658
|
public async getObjectJSON<T = unknown>(key: string, opts: Record<string, unknown> = {}): Promise<T | null> {
|
|
555
659
|
const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
|
|
556
660
|
if ([404, 412, 304].includes(res.status)) {
|
|
@@ -559,6 +663,13 @@ class s3mini {
|
|
|
559
663
|
return res.json() as Promise<T>;
|
|
560
664
|
}
|
|
561
665
|
|
|
666
|
+
/**
|
|
667
|
+
* Get an object with its ETag from the S3-compatible service.
|
|
668
|
+
* This method sends a request to retrieve the specified object and its ETag.
|
|
669
|
+
* @param {string} key - The key of the object to retrieve.
|
|
670
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
671
|
+
* @returns A promise that resolves to an object containing the ETag and the object data as an ArrayBuffer or null if not found.
|
|
672
|
+
*/
|
|
562
673
|
public async getObjectWithETag(
|
|
563
674
|
key: string,
|
|
564
675
|
opts: Record<string, unknown> = {},
|
|
@@ -572,7 +683,7 @@ class s3mini {
|
|
|
572
683
|
|
|
573
684
|
const etag = res.headers.get(C.HEADER_ETAG);
|
|
574
685
|
if (!etag) {
|
|
575
|
-
throw new Error(
|
|
686
|
+
throw new Error(`${C.ERROR_PREFIX}ETag not found in response headers`);
|
|
576
687
|
}
|
|
577
688
|
return { etag: U.sanitizeETag(etag), data: await res.arrayBuffer() };
|
|
578
689
|
} catch (err) {
|
|
@@ -581,6 +692,16 @@ class s3mini {
|
|
|
581
692
|
}
|
|
582
693
|
}
|
|
583
694
|
|
|
695
|
+
/**
|
|
696
|
+
* Get an object as a raw response from the S3-compatible service.
|
|
697
|
+
* This method sends a request to retrieve the specified object and returns the raw response.
|
|
698
|
+
* @param {string} key - The key of the object to retrieve.
|
|
699
|
+
* @param {boolean} [wholeFile=true] - Whether to retrieve the whole file or a range.
|
|
700
|
+
* @param {number} [rangeFrom=0] - The starting byte for the range (if not whole file).
|
|
701
|
+
* @param {number} [rangeTo=this.requestSizeInBytes] - The ending byte for the range (if not whole file).
|
|
702
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
703
|
+
* @returns A promise that resolves to the Response object.
|
|
704
|
+
*/
|
|
584
705
|
public async getObjectRaw(
|
|
585
706
|
key: string,
|
|
586
707
|
wholeFile = true,
|
|
@@ -597,12 +718,31 @@ class s3mini {
|
|
|
597
718
|
});
|
|
598
719
|
}
|
|
599
720
|
|
|
721
|
+
/**
|
|
722
|
+
* Get the content length of an object.
|
|
723
|
+
* This method sends a HEAD request to retrieve the content length of the specified object.
|
|
724
|
+
* @param {string} key - The key of the object to retrieve the content length for.
|
|
725
|
+
* @returns A promise that resolves to the content length of the object in bytes, or 0 if not found.
|
|
726
|
+
* @throws {Error} If the content length header is not found in the response.
|
|
727
|
+
*/
|
|
600
728
|
public async getContentLength(key: string): Promise<number> {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
729
|
+
try {
|
|
730
|
+
const res = await this._signedRequest('HEAD', key);
|
|
731
|
+
const len = res.headers.get(C.HEADER_CONTENT_LENGTH);
|
|
732
|
+
return len ? +len : 0;
|
|
733
|
+
} catch (err) {
|
|
734
|
+
this._log('error', `Error getting content length for object ${key}: ${String(err)}`);
|
|
735
|
+
throw new Error(`${C.ERROR_PREFIX}Error getting content length for object ${key}: ${String(err)}`);
|
|
736
|
+
}
|
|
604
737
|
}
|
|
605
738
|
|
|
739
|
+
/**
|
|
740
|
+
* Checks if an object exists in the S3-compatible service.
|
|
741
|
+
* This method sends a HEAD request to check if the specified object exists.
|
|
742
|
+
* @param {string} key - The key of the object to check.
|
|
743
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
744
|
+
* @returns A promise that resolves to true if the object exists, false if not found, or null if ETag mismatch.
|
|
745
|
+
*/
|
|
606
746
|
public async objectExists(key: string, opts: Record<string, unknown> = {}): Promise<IT.ExistResponseCode> {
|
|
607
747
|
const res = await this._signedRequest('HEAD', key, {
|
|
608
748
|
query: opts,
|
|
@@ -618,6 +758,18 @@ class s3mini {
|
|
|
618
758
|
return true; // found (200)
|
|
619
759
|
}
|
|
620
760
|
|
|
761
|
+
/**
|
|
762
|
+
* Retrieves the ETag of an object without downloading its content.
|
|
763
|
+
* @param {string} key - The key of the object to retrieve the ETag for.
|
|
764
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
765
|
+
* @returns {Promise<string | null>} A promise that resolves to the ETag value or null if the object is not found.
|
|
766
|
+
* @throws {Error} If the ETag header is not found in the response.
|
|
767
|
+
* @example
|
|
768
|
+
* const etag = await s3.getEtag('path/to/file.txt');
|
|
769
|
+
* if (etag) {
|
|
770
|
+
* console.log(`File ETag: ${etag}`);
|
|
771
|
+
* }
|
|
772
|
+
*/
|
|
621
773
|
public async getEtag(key: string, opts: Record<string, unknown> = {}): Promise<string | null> {
|
|
622
774
|
const res = await this._signedRequest('HEAD', key, {
|
|
623
775
|
query: opts,
|
|
@@ -630,23 +782,56 @@ class s3mini {
|
|
|
630
782
|
|
|
631
783
|
const etag = res.headers.get(C.HEADER_ETAG);
|
|
632
784
|
if (!etag) {
|
|
633
|
-
throw new Error(
|
|
785
|
+
throw new Error(`${C.ERROR_PREFIX}ETag not found in response headers`);
|
|
634
786
|
}
|
|
635
787
|
|
|
636
788
|
return U.sanitizeETag(etag);
|
|
637
789
|
}
|
|
638
790
|
|
|
639
|
-
|
|
791
|
+
/**
|
|
792
|
+
* Uploads an object to the S3-compatible service.
|
|
793
|
+
* @param {string} key - The key/path where the object will be stored.
|
|
794
|
+
* @param {string | Buffer} data - The data to upload (string or Buffer).
|
|
795
|
+
* @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
|
|
796
|
+
* @returns {Promise<Response>} A promise that resolves to the Response object from the upload request.
|
|
797
|
+
* @throws {TypeError} If data is not a string or Buffer.
|
|
798
|
+
* @example
|
|
799
|
+
* // Upload text file
|
|
800
|
+
* await s3.putObject('hello.txt', 'Hello, World!', 'text/plain');
|
|
801
|
+
*
|
|
802
|
+
* // Upload binary data
|
|
803
|
+
* const buffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
|
804
|
+
* await s3.putObject('image.png', buffer, 'image/png');
|
|
805
|
+
*/
|
|
806
|
+
public async putObject(
|
|
807
|
+
key: string,
|
|
808
|
+
data: string | Buffer,
|
|
809
|
+
fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
|
|
810
|
+
): Promise<Response> {
|
|
640
811
|
if (!(data instanceof Buffer || typeof data === 'string')) {
|
|
641
812
|
throw new TypeError(C.ERROR_DATA_BUFFER_REQUIRED);
|
|
642
813
|
}
|
|
643
814
|
return this._signedRequest('PUT', key, {
|
|
644
815
|
body: data,
|
|
645
|
-
headers: {
|
|
816
|
+
headers: {
|
|
817
|
+
[C.HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length,
|
|
818
|
+
[C.HEADER_CONTENT_TYPE]: fileType,
|
|
819
|
+
},
|
|
646
820
|
tolerated: [200],
|
|
647
821
|
});
|
|
648
822
|
}
|
|
649
823
|
|
|
824
|
+
/**
|
|
825
|
+
* Initiates a multipart upload and returns the upload ID.
|
|
826
|
+
* @param {string} key - The key/path where the object will be stored.
|
|
827
|
+
* @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
|
|
828
|
+
* @returns {Promise<string>} A promise that resolves to the upload ID for the multipart upload.
|
|
829
|
+
* @throws {TypeError} If key is invalid or fileType is not a string.
|
|
830
|
+
* @throws {Error} If the multipart upload fails to initialize.
|
|
831
|
+
* @example
|
|
832
|
+
* const uploadId = await s3.getMultipartUploadId('large-file.zip', 'application/zip');
|
|
833
|
+
* console.log(`Started multipart upload: ${uploadId}`);
|
|
834
|
+
*/
|
|
650
835
|
public async getMultipartUploadId(key: string, fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE): Promise<string> {
|
|
651
836
|
this._checkKey(key);
|
|
652
837
|
if (typeof fileType !== 'string') {
|
|
@@ -660,22 +845,55 @@ class s3mini {
|
|
|
660
845
|
headers,
|
|
661
846
|
withQuery: true,
|
|
662
847
|
});
|
|
848
|
+
const parsed = U.parseXml(await res.text()) as Record<string, unknown>;
|
|
849
|
+
|
|
850
|
+
// if (
|
|
851
|
+
// parsed &&
|
|
852
|
+
// typeof parsed === 'object' &&
|
|
853
|
+
// 'initiateMultipartUploadResult' in parsed &&
|
|
854
|
+
// parsed.initiateMultipartUploadResult &&
|
|
855
|
+
// 'uploadId' in (parsed.initiateMultipartUploadResult as { uploadId: string })
|
|
856
|
+
// ) {
|
|
857
|
+
// return (parsed.initiateMultipartUploadResult as { uploadId: string }).uploadId;
|
|
858
|
+
// }
|
|
663
859
|
|
|
664
|
-
|
|
860
|
+
if (parsed && typeof parsed === 'object') {
|
|
861
|
+
// Check for both cases of InitiateMultipartUploadResult
|
|
862
|
+
const uploadResult =
|
|
863
|
+
(parsed.initiateMultipartUploadResult as Record<string, unknown>) ||
|
|
864
|
+
(parsed.InitiateMultipartUploadResult as Record<string, unknown>);
|
|
665
865
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
866
|
+
if (uploadResult && typeof uploadResult === 'object') {
|
|
867
|
+
// Check for both cases of uploadId
|
|
868
|
+
const uploadId = uploadResult.uploadId || uploadResult.UploadId;
|
|
869
|
+
|
|
870
|
+
if (uploadId && typeof uploadId === 'string') {
|
|
871
|
+
return uploadId;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
674
874
|
}
|
|
675
875
|
|
|
676
876
|
throw new Error(`${C.ERROR_PREFIX}Failed to create multipart upload: ${JSON.stringify(parsed)}`);
|
|
677
877
|
}
|
|
678
878
|
|
|
879
|
+
/**
|
|
880
|
+
* Uploads a part in a multipart upload.
|
|
881
|
+
* @param {string} key - The key of the object being uploaded.
|
|
882
|
+
* @param {string} uploadId - The upload ID from getMultipartUploadId.
|
|
883
|
+
* @param {Buffer | string} data - The data for this part.
|
|
884
|
+
* @param {number} partNumber - The part number (must be between 1 and 10,000).
|
|
885
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
886
|
+
* @returns {Promise<IT.UploadPart>} A promise that resolves to an object containing the partNumber and etag.
|
|
887
|
+
* @throws {TypeError} If any parameter is invalid.
|
|
888
|
+
* @example
|
|
889
|
+
* const part = await s3.uploadPart(
|
|
890
|
+
* 'large-file.zip',
|
|
891
|
+
* uploadId,
|
|
892
|
+
* partData,
|
|
893
|
+
* 1
|
|
894
|
+
* );
|
|
895
|
+
* console.log(`Part ${part.partNumber} uploaded with ETag: ${part.etag}`);
|
|
896
|
+
*/
|
|
679
897
|
public async uploadPart(
|
|
680
898
|
key: string,
|
|
681
899
|
uploadId: string,
|
|
@@ -695,13 +913,29 @@ class s3mini {
|
|
|
695
913
|
return { partNumber, etag: U.sanitizeETag(res.headers.get('etag') || '') };
|
|
696
914
|
}
|
|
697
915
|
|
|
916
|
+
/**
|
|
917
|
+
* Completes a multipart upload by combining all uploaded parts.
|
|
918
|
+
* @param {string} key - The key of the object being uploaded.
|
|
919
|
+
* @param {string} uploadId - The upload ID from getMultipartUploadId.
|
|
920
|
+
* @param {Array<IT.UploadPart>} parts - Array of uploaded parts with partNumber and etag.
|
|
921
|
+
* @returns {Promise<IT.CompleteMultipartUploadResult>} A promise that resolves to the completion result containing the final ETag.
|
|
922
|
+
* @throws {Error} If the multipart upload fails to complete.
|
|
923
|
+
* @example
|
|
924
|
+
* const result = await s3.completeMultipartUpload(
|
|
925
|
+
* 'large-file.zip',
|
|
926
|
+
* uploadId,
|
|
927
|
+
* [
|
|
928
|
+
* { partNumber: 1, etag: 'abc123' },
|
|
929
|
+
* { partNumber: 2, etag: 'def456' }
|
|
930
|
+
* ]
|
|
931
|
+
* );
|
|
932
|
+
* console.log(`Upload completed with ETag: ${result.etag}`);
|
|
933
|
+
*/
|
|
698
934
|
public async completeMultipartUpload(
|
|
699
935
|
key: string,
|
|
700
936
|
uploadId: string,
|
|
701
937
|
parts: Array<IT.UploadPart>,
|
|
702
938
|
): Promise<IT.CompleteMultipartUploadResult> {
|
|
703
|
-
// …existing validation left untouched …
|
|
704
|
-
|
|
705
939
|
const query = { uploadId };
|
|
706
940
|
const xmlBody = this._buildCompleteMultipartUploadXml(parts);
|
|
707
941
|
const headers = {
|
|
@@ -716,24 +950,45 @@ class s3mini {
|
|
|
716
950
|
withQuery: true,
|
|
717
951
|
});
|
|
718
952
|
|
|
719
|
-
const parsed = U.parseXml(await res.text()) as unknown
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
953
|
+
const parsed = U.parseXml(await res.text()) as Record<string, unknown>;
|
|
954
|
+
if (parsed && typeof parsed === 'object') {
|
|
955
|
+
// Check for both cases
|
|
956
|
+
const result = parsed.completeMultipartUploadResult || parsed.CompleteMultipartUploadResult || parsed;
|
|
957
|
+
|
|
958
|
+
if (result && typeof result === 'object') {
|
|
959
|
+
const resultObj = result as Record<string, unknown>;
|
|
960
|
+
|
|
961
|
+
// Handle ETag in all its variations
|
|
962
|
+
const etag = resultObj.ETag || resultObj.eTag || resultObj.etag;
|
|
963
|
+
if (etag && typeof etag === 'string') {
|
|
964
|
+
return {
|
|
965
|
+
...resultObj,
|
|
966
|
+
etag: this.sanitizeETag(etag),
|
|
967
|
+
} as IT.CompleteMultipartUploadResult;
|
|
968
|
+
}
|
|
725
969
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
}
|
|
729
|
-
if ('ETag' in result || 'eTag' in result) {
|
|
730
|
-
(result as IT.CompleteMultipartUploadResult).etag = this.sanitizeETag(
|
|
731
|
-
(result as IT.CompleteMultipartUploadResult).eTag ?? (result as IT.CompleteMultipartUploadResult).ETag,
|
|
732
|
-
);
|
|
970
|
+
return result as IT.CompleteMultipartUploadResult;
|
|
971
|
+
}
|
|
733
972
|
}
|
|
734
|
-
|
|
973
|
+
|
|
974
|
+
throw new Error(`${C.ERROR_PREFIX}Failed to complete multipart upload: ${JSON.stringify(parsed)}`);
|
|
735
975
|
}
|
|
736
976
|
|
|
977
|
+
/**
|
|
978
|
+
* Aborts a multipart upload and removes all uploaded parts.
|
|
979
|
+
* @param {string} key - The key of the object being uploaded.
|
|
980
|
+
* @param {string} uploadId - The upload ID to abort.
|
|
981
|
+
* @returns {Promise<object>} A promise that resolves to an object containing the abort status and details.
|
|
982
|
+
* @throws {TypeError} If key or uploadId is invalid.
|
|
983
|
+
* @throws {Error} If the abort operation fails.
|
|
984
|
+
* @example
|
|
985
|
+
* try {
|
|
986
|
+
* const result = await s3.abortMultipartUpload('large-file.zip', uploadId);
|
|
987
|
+
* console.log('Upload aborted:', result.status);
|
|
988
|
+
* } catch (error) {
|
|
989
|
+
* console.error('Failed to abort upload:', error);
|
|
990
|
+
* }
|
|
991
|
+
*/
|
|
737
992
|
public async abortMultipartUpload(key: string, uploadId: string): Promise<object> {
|
|
738
993
|
this._checkKey(key);
|
|
739
994
|
if (!uploadId) {
|
|
@@ -748,8 +1003,7 @@ class s3mini {
|
|
|
748
1003
|
headers,
|
|
749
1004
|
withQuery: true,
|
|
750
1005
|
});
|
|
751
|
-
|
|
752
|
-
const parsed = U.parseXml(await res.text()) as object;
|
|
1006
|
+
const parsed = U.parseXml(await res.text()) as Record<string, unknown>;
|
|
753
1007
|
if (
|
|
754
1008
|
parsed &&
|
|
755
1009
|
'error' in parsed &&
|
|
@@ -780,11 +1034,107 @@ class s3mini {
|
|
|
780
1034
|
`;
|
|
781
1035
|
}
|
|
782
1036
|
|
|
1037
|
+
/**
|
|
1038
|
+
* Deletes an object from the bucket.
|
|
1039
|
+
* This method sends a request to delete the specified object from the bucket.
|
|
1040
|
+
* @param {string} key - The key of the object to delete.
|
|
1041
|
+
* @returns A promise that resolves to true if the object was deleted successfully, false otherwise.
|
|
1042
|
+
*/
|
|
783
1043
|
public async deleteObject(key: string): Promise<boolean> {
|
|
784
1044
|
const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] });
|
|
785
1045
|
return res.status === 200 || res.status === 204;
|
|
786
1046
|
}
|
|
787
1047
|
|
|
1048
|
+
private async _deleteObjectsProcess(keys: string[]): Promise<boolean[]> {
|
|
1049
|
+
const xmlBody = `<Delete>${keys.map(key => `<Object><Key>${U.escapeXml(key)}</Key></Object>`).join('')}</Delete>`;
|
|
1050
|
+
const query = { delete: '' };
|
|
1051
|
+
const md5Base64 = U.md5base64(xmlBody);
|
|
1052
|
+
const headers = {
|
|
1053
|
+
[C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
|
|
1054
|
+
[C.HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(),
|
|
1055
|
+
'Content-MD5': md5Base64,
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
const res = await this._signedRequest('POST', '', {
|
|
1059
|
+
query,
|
|
1060
|
+
body: xmlBody,
|
|
1061
|
+
headers,
|
|
1062
|
+
withQuery: true,
|
|
1063
|
+
});
|
|
1064
|
+
const parsed = U.parseXml(await res.text()) as Record<string, unknown>;
|
|
1065
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
1066
|
+
throw new Error(`${C.ERROR_PREFIX}Failed to delete objects: ${JSON.stringify(parsed)}`);
|
|
1067
|
+
}
|
|
1068
|
+
const out = (parsed.DeleteResult || parsed.deleteResult || parsed) as Record<string, unknown>;
|
|
1069
|
+
const resultMap = new Map<string, boolean>();
|
|
1070
|
+
keys.forEach(key => resultMap.set(key, false));
|
|
1071
|
+
const deleted = out.deleted || out.Deleted;
|
|
1072
|
+
if (deleted) {
|
|
1073
|
+
const deletedArray = Array.isArray(deleted) ? deleted : [deleted];
|
|
1074
|
+
deletedArray.forEach((item: unknown) => {
|
|
1075
|
+
if (item && typeof item === 'object') {
|
|
1076
|
+
const obj = item as Record<string, unknown>;
|
|
1077
|
+
// Check both key and Key
|
|
1078
|
+
const key = obj.key || obj.Key;
|
|
1079
|
+
if (key && typeof key === 'string') {
|
|
1080
|
+
resultMap.set(key, true);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Handle errors (check both cases)
|
|
1087
|
+
const errors = out.error || out.Error;
|
|
1088
|
+
if (errors) {
|
|
1089
|
+
const errorsArray = Array.isArray(errors) ? errors : [errors];
|
|
1090
|
+
errorsArray.forEach((item: unknown) => {
|
|
1091
|
+
if (item && typeof item === 'object') {
|
|
1092
|
+
const obj = item as Record<string, unknown>;
|
|
1093
|
+
// Check both cases for all properties
|
|
1094
|
+
const key = obj.key || obj.Key;
|
|
1095
|
+
const code = obj.code || obj.Code;
|
|
1096
|
+
const message = obj.message || obj.Message;
|
|
1097
|
+
|
|
1098
|
+
if (key && typeof key === 'string') {
|
|
1099
|
+
resultMap.set(key, false);
|
|
1100
|
+
// Optionally log the error for debugging
|
|
1101
|
+
this._log('warn', `Failed to delete object: ${key}`, {
|
|
1102
|
+
code: code || 'Unknown',
|
|
1103
|
+
message: message || 'Unknown error',
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// Return boolean array in the same order as input keys
|
|
1111
|
+
return keys.map(key => resultMap.get(key) || false);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Deletes multiple objects from the bucket.
|
|
1116
|
+
* @param {string[]} keys - An array of object keys to delete.
|
|
1117
|
+
* @returns A promise that resolves to an array of booleans indicating success for each key in order.
|
|
1118
|
+
*/
|
|
1119
|
+
public async deleteObjects(keys: string[]): Promise<boolean[]> {
|
|
1120
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
1121
|
+
return [];
|
|
1122
|
+
}
|
|
1123
|
+
const maxBatchSize = 1000; // S3 limit for delete batch size
|
|
1124
|
+
if (keys.length > maxBatchSize) {
|
|
1125
|
+
const allPromises = [];
|
|
1126
|
+
for (let i = 0; i < keys.length; i += maxBatchSize) {
|
|
1127
|
+
const batch = keys.slice(i, i + maxBatchSize);
|
|
1128
|
+
allPromises.push(this._deleteObjectsProcess(batch));
|
|
1129
|
+
}
|
|
1130
|
+
const results = await Promise.all(allPromises);
|
|
1131
|
+
// Flatten the results array
|
|
1132
|
+
return results.flat();
|
|
1133
|
+
} else {
|
|
1134
|
+
return await this._deleteObjectsProcess(keys);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
788
1138
|
private async _sendRequest(
|
|
789
1139
|
url: string,
|
|
790
1140
|
method: IT.HttpMethod,
|