s3mini 0.2.0 → 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/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, '&lt;')
90
+ .replace(/>/g, '&gt;')
91
+ .replace(/"/g, '&quot;')
92
+ .replace(/'/g, '&apos;');
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 RE_TAG = /<(\w)([-\w]+)(?:\/|[^>]*>((?:(?!<\1)[\s\S])*)<\/\1\2)>/gm;
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(input)) !== null) {
90
- const [, prefix = '', key, inner] = match;
91
- const fullKey = `${prefix.toLowerCase()}${key}`;
92
- const node = inner ? parseXml(inner) : '';
93
- const current = result[fullKey];
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
- // first occurrence
96
- result[fullKey] = node;
115
+ // First occurrence
116
+ result[tagName] = node;
97
117
  }
98
118
  else if (Array.isArray(current)) {
99
- // already an array
119
+ // Already an array
100
120
  current.push(node);
101
121
  }
102
122
  else {
103
- // promote to array on the second occurrence
104
- result[fullKey] = [current, node];
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(input);
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
@@ -327,7 +347,7 @@ class s3mini {
327
347
  _validateMethodIsGetOrHead(method) {
328
348
  if (method !== 'GET' && method !== 'HEAD') {
329
349
  this._log('error', `${ERROR_PREFIX}method must be either GET or HEAD`);
330
- throw new Error('method must be either GET or HEAD');
350
+ throw new Error(`${ERROR_PREFIX}method must be either GET or HEAD`);
331
351
  }
332
352
  }
333
353
  _checkKey(key) {
@@ -487,6 +507,13 @@ class s3mini {
487
507
  const signedHeadersString = Object.fromEntries(Object.entries(signedHeaders).map(([k, v]) => [k, String(v)]));
488
508
  return this._sendRequest(finalUrl, method, signedHeadersString, body, tolerated);
489
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
+ */
490
517
  getProps() {
491
518
  return {
492
519
  accessKeyId: this.accessKeyId,
@@ -498,6 +525,25 @@ class s3mini {
498
525
  logger: this.logger,
499
526
  };
500
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
+ */
501
547
  setProps(props) {
502
548
  this._validateConstructorParams(props.accessKeyId, props.secretAccessKey, props.endpoint);
503
549
  this.accessKeyId = props.accessKeyId;
@@ -508,10 +554,22 @@ class s3mini {
508
554
  this.requestAbortTimeout = props.requestAbortTimeout;
509
555
  this.logger = props.logger;
510
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
+ */
511
565
  sanitizeETag(etag) {
512
566
  return sanitizeETag(etag);
513
567
  }
514
- // TBD
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
+ */
515
573
  async createBucket() {
516
574
  const xmlBody = `
517
575
  <CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
@@ -529,10 +587,30 @@ class s3mini {
529
587
  });
530
588
  return res.status === 200;
531
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
+ */
532
595
  async bucketExists() {
533
596
  const res = await this._signedRequest('HEAD', '', { tolerated: [200, 404, 403] });
534
597
  return res.status === 200;
535
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
+ */
536
614
  async listObjects(delimiter = '/', prefix = '', maxKeys,
537
615
  // method: IT.HttpMethod = 'GET', // 'GET' or 'HEAD'
538
616
  opts = {}) {
@@ -573,9 +651,9 @@ class s3mini {
573
651
  this._log('error', `${ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`);
574
652
  throw new Error(`${ERROR_PREFIX}Unexpected listObjects response shape`);
575
653
  }
576
- const out = ('listBucketResult' in raw ? raw.listBucketResult : raw);
654
+ const out = (raw.ListBucketResult || raw.listBucketResult || raw);
577
655
  /* accumulate Contents */
578
- const contents = out.contents;
656
+ const contents = out.Contents || out.contents; // S3 v2 vs v1
579
657
  if (contents) {
580
658
  const batch = Array.isArray(contents) ? contents : [contents];
581
659
  all.push(...batch);
@@ -583,13 +661,22 @@ class s3mini {
583
661
  remaining -= batch.length;
584
662
  }
585
663
  }
586
- const truncated = out.isTruncated === 'true' || out.IsTruncated === 'true';
664
+ const truncated = out.IsTruncated === 'true' || out.isTruncated === 'true' || false;
587
665
  token = truncated
588
- ? (out.nextContinuationToken || out.NextContinuationToken || out.nextMarker || out.NextMarker)
666
+ ? (out.NextContinuationToken || out.nextContinuationToken || out.NextMarker || out.nextMarker)
589
667
  : undefined;
590
668
  } while (token && remaining > 0);
591
669
  return all;
592
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
+ */
593
680
  async listMultipartUploads(delimiter = '/', prefix = '', method = 'GET', opts = {}) {
594
681
  this._checkDelimiter(delimiter);
595
682
  this._checkPrefix(prefix);
@@ -618,6 +705,13 @@ class s3mini {
618
705
  }
619
706
  return raw;
620
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
+ */
621
715
  async getObject(key, opts = {}) {
622
716
  const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
623
717
  if ([404, 412, 304].includes(res.status)) {
@@ -625,6 +719,13 @@ class s3mini {
625
719
  }
626
720
  return res.text();
627
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
+ */
628
729
  async getObjectResponse(key, opts = {}) {
629
730
  const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
630
731
  if ([404, 412, 304].includes(res.status)) {
@@ -632,6 +733,13 @@ class s3mini {
632
733
  }
633
734
  return res;
634
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
+ */
635
743
  async getObjectArrayBuffer(key, opts = {}) {
636
744
  const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
637
745
  if ([404, 412, 304].includes(res.status)) {
@@ -639,6 +747,13 @@ class s3mini {
639
747
  }
640
748
  return res.arrayBuffer();
641
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
+ */
642
757
  async getObjectJSON(key, opts = {}) {
643
758
  const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
644
759
  if ([404, 412, 304].includes(res.status)) {
@@ -646,6 +761,13 @@ class s3mini {
646
761
  }
647
762
  return res.json();
648
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
+ */
649
771
  async getObjectWithETag(key, opts = {}) {
650
772
  try {
651
773
  const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
@@ -654,7 +776,7 @@ class s3mini {
654
776
  }
655
777
  const etag = res.headers.get(HEADER_ETAG);
656
778
  if (!etag) {
657
- throw new Error('ETag not found in response headers');
779
+ throw new Error(`${ERROR_PREFIX}ETag not found in response headers`);
658
780
  }
659
781
  return { etag: sanitizeETag(etag), data: await res.arrayBuffer() };
660
782
  }
@@ -663,6 +785,16 @@ class s3mini {
663
785
  throw err;
664
786
  }
665
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
+ */
666
798
  async getObjectRaw(key, wholeFile = true, rangeFrom = 0, rangeTo = this.requestSizeInBytes, opts = {}) {
667
799
  const rangeHdr = wholeFile ? {} : { range: `bytes=${rangeFrom}-${rangeTo - 1}` };
668
800
  return this._signedRequest('GET', key, {
@@ -671,11 +803,31 @@ class s3mini {
671
803
  withQuery: true, // keep ?query=string behaviour
672
804
  });
673
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
+ */
674
813
  async getContentLength(key) {
675
- const res = await this._signedRequest('HEAD', key);
676
- const len = res.headers.get(HEADER_CONTENT_LENGTH);
677
- return len ? +len : 0;
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
+ }
678
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
+ */
679
831
  async objectExists(key, opts = {}) {
680
832
  const res = await this._signedRequest('HEAD', key, {
681
833
  query: opts,
@@ -689,6 +841,18 @@ class s3mini {
689
841
  }
690
842
  return true; // found (200)
691
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
+ */
692
856
  async getEtag(key, opts = {}) {
693
857
  const res = await this._signedRequest('HEAD', key, {
694
858
  query: opts,
@@ -699,10 +863,25 @@ class s3mini {
699
863
  }
700
864
  const etag = res.headers.get(HEADER_ETAG);
701
865
  if (!etag) {
702
- throw new Error('ETag not found in response headers');
866
+ throw new Error(`${ERROR_PREFIX}ETag not found in response headers`);
703
867
  }
704
868
  return sanitizeETag(etag);
705
869
  }
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
+ */
706
885
  async putObject(key, data, fileType = DEFAULT_STREAM_CONTENT_TYPE) {
707
886
  if (!(data instanceof Buffer || typeof data === 'string')) {
708
887
  throw new TypeError(ERROR_DATA_BUFFER_REQUIRED);
@@ -716,6 +895,17 @@ class s3mini {
716
895
  tolerated: [200],
717
896
  });
718
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
+ */
719
909
  async getMultipartUploadId(key, fileType = DEFAULT_STREAM_CONTENT_TYPE) {
720
910
  this._checkKey(key);
721
911
  if (typeof fileType !== 'string') {
@@ -729,15 +919,47 @@ class s3mini {
729
919
  withQuery: true,
730
920
  });
731
921
  const parsed = parseXml(await res.text());
732
- if (parsed &&
733
- typeof parsed === 'object' &&
734
- 'initiateMultipartUploadResult' in parsed &&
735
- parsed.initiateMultipartUploadResult &&
736
- 'uploadId' in parsed.initiateMultipartUploadResult) {
737
- return parsed.initiateMultipartUploadResult.uploadId;
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
+ }
738
942
  }
739
943
  throw new Error(`${ERROR_PREFIX}Failed to create multipart upload: ${JSON.stringify(parsed)}`);
740
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
+ */
741
963
  async uploadPart(key, uploadId, data, partNumber, opts = {}) {
742
964
  this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
743
965
  const query = { uploadId, partNumber, ...opts };
@@ -748,8 +970,25 @@ class s3mini {
748
970
  });
749
971
  return { partNumber, etag: sanitizeETag(res.headers.get('etag') || '') };
750
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
+ */
751
991
  async completeMultipartUpload(key, uploadId, parts) {
752
- // …existing validation left untouched …
753
992
  const query = { uploadId };
754
993
  const xmlBody = this._buildCompleteMultipartUploadXml(parts);
755
994
  const headers = {
@@ -763,17 +1002,39 @@ class s3mini {
763
1002
  withQuery: true,
764
1003
  });
765
1004
  const parsed = parseXml(await res.text());
766
- const result = parsed && typeof parsed === 'object' && 'completeMultipartUploadResult' in parsed
767
- ? parsed.completeMultipartUploadResult
768
- : parsed;
769
- if (!result || typeof result !== 'object') {
770
- throw new Error(`${ERROR_PREFIX}Failed to complete multipart upload: ${JSON.stringify(parsed)}`);
771
- }
772
- if ('ETag' in result || 'eTag' in result) {
773
- result.etag = this.sanitizeETag(result.eTag ?? result.ETag);
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
+ }
774
1020
  }
775
- return result;
1021
+ throw new Error(`${ERROR_PREFIX}Failed to complete multipart upload: ${JSON.stringify(parsed)}`);
776
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
+ */
777
1038
  async abortMultipartUpload(key, uploadId) {
778
1039
  this._checkKey(key);
779
1040
  if (!uploadId) {
@@ -811,10 +1072,101 @@ class s3mini {
811
1072
  </CompleteMultipartUpload>
812
1073
  `;
813
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
+ */
814
1081
  async deleteObject(key) {
815
1082
  const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] });
816
1083
  return res.status === 200 || res.status === 204;
817
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
+ }
818
1170
  async _sendRequest(url, method, headers, body, toleratedStatusCodes = []) {
819
1171
  this._log('info', `Sending ${method} request to ${url}`, `headers: ${JSON.stringify(headers)}`);
820
1172
  try {