s3mini 0.2.0 → 0.4.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
@@ -13,9 +13,9 @@ const DEFAULT_REQUEST_SIZE_IN_BYTES = 8 * 1024 * 1024;
13
13
  const HEADER_AMZ_CONTENT_SHA256 = 'x-amz-content-sha256';
14
14
  const HEADER_AMZ_DATE = 'x-amz-date';
15
15
  const HEADER_HOST = 'host';
16
- const HEADER_AUTHORIZATION = 'Authorization';
17
- const HEADER_CONTENT_TYPE = 'Content-Type';
18
- const HEADER_CONTENT_LENGTH = 'Content-Length';
16
+ const HEADER_AUTHORIZATION = 'authorization';
17
+ const HEADER_CONTENT_TYPE = 'content-type';
18
+ const HEADER_CONTENT_LENGTH = 'content-length';
19
19
  const HEADER_ETAG = 'etag';
20
20
  // Error messages
21
21
  const ERROR_PREFIX = '[s3mini] ';
@@ -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
@@ -221,7 +241,7 @@ const runInBatches = async (tasks, batchSize = 30, minIntervalMs = 0) => {
221
241
  * // Delete a file
222
242
  * await s3.deleteObject('example.txt');
223
243
  */
224
- class s3mini {
244
+ class S3mini {
225
245
  /**
226
246
  * Creates an instance of the S3 class.
227
247
  *
@@ -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) {
@@ -405,8 +425,12 @@ class s3mini {
405
425
  headers[HEADER_AMZ_CONTENT_SHA256] = UNSIGNED_PAYLOAD; // body ? U.hash(body) : C.UNSIGNED_PAYLOAD;
406
426
  headers[HEADER_AMZ_DATE] = fullDatetime;
407
427
  headers[HEADER_HOST] = url.host;
408
- const canonicalHeaders = this._buildCanonicalHeaders(headers);
409
- const signedHeaders = Object.keys(headers)
428
+ // sort headers alphabetically by key
429
+ const ignoredHeaders = ['authorization', 'content-length', 'content-type', 'user-agent'];
430
+ let headersForSigning = Object.fromEntries(Object.entries(headers).filter(([key]) => !ignoredHeaders.includes(key.toLowerCase())));
431
+ headersForSigning = Object.fromEntries(Object.entries(headersForSigning).sort(([keyA], [keyB]) => keyA.localeCompare(keyB)));
432
+ const canonicalHeaders = this._buildCanonicalHeaders(headersForSigning);
433
+ const signedHeaders = Object.keys(headersForSigning)
410
434
  .map(key => key.toLowerCase())
411
435
  .sort()
412
436
  .join(';');
@@ -420,18 +444,18 @@ class s3mini {
420
444
  _buildCanonicalHeaders(headers) {
421
445
  return Object.entries(headers)
422
446
  .map(([key, value]) => `${key.toLowerCase()}:${String(value).trim()}`)
423
- .sort()
424
447
  .join('\n');
425
448
  }
426
449
  _buildCanonicalRequest(method, url, query, canonicalHeaders, signedHeaders) {
427
- return [
450
+ const parts = [
428
451
  method,
429
452
  url.pathname,
430
453
  this._buildCanonicalQueryString(query),
431
- `${canonicalHeaders}\n`,
454
+ canonicalHeaders + '\n', // Canonical headers end with extra newline
432
455
  signedHeaders,
433
456
  UNSIGNED_PAYLOAD,
434
- ].join('\n');
457
+ ];
458
+ return parts.join('\n');
435
459
  }
436
460
  _buildCredentialScope(shortDatetime) {
437
461
  return [shortDatetime, this.region, S3_SERVICE, AWS_REQUEST_TYPE].join('/');
@@ -465,9 +489,6 @@ class s3mini {
465
489
  if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) {
466
490
  throw new Error(`${ERROR_PREFIX}Unsupported HTTP method ${method}`);
467
491
  }
468
- if (key) {
469
- this._checkKey(key); // allow '' for bucket‑level
470
- }
471
492
  const { filteredOpts, conditionalHeaders } = ['GET', 'HEAD'].includes(method)
472
493
  ? this._filterIfHeaders(query)
473
494
  : { filteredOpts: query, conditionalHeaders: {} };
@@ -487,6 +508,13 @@ class s3mini {
487
508
  const signedHeadersString = Object.fromEntries(Object.entries(signedHeaders).map(([k, v]) => [k, String(v)]));
488
509
  return this._sendRequest(finalUrl, method, signedHeadersString, body, tolerated);
489
510
  }
511
+ /**
512
+ * Gets the current configuration properties of the S3 instance.
513
+ * @returns {IT.S3Config} The current S3 configuration object containing all settings.
514
+ * @example
515
+ * const config = s3.getProps();
516
+ * console.log(config.endpoint); // 'https://s3.amazonaws.com/my-bucket'
517
+ */
490
518
  getProps() {
491
519
  return {
492
520
  accessKeyId: this.accessKeyId,
@@ -498,6 +526,25 @@ class s3mini {
498
526
  logger: this.logger,
499
527
  };
500
528
  }
529
+ /**
530
+ * Updates the configuration properties of the S3 instance.
531
+ * @param {IT.S3Config} props - The new configuration object.
532
+ * @param {string} props.accessKeyId - The access key ID for authentication.
533
+ * @param {string} props.secretAccessKey - The secret access key for authentication.
534
+ * @param {string} props.endpoint - The endpoint URL of the S3-compatible service.
535
+ * @param {string} [props.region='auto'] - The region of the S3 service.
536
+ * @param {number} [props.requestSizeInBytes=8388608] - The request size of a single request in bytes.
537
+ * @param {number} [props.requestAbortTimeout] - The timeout in milliseconds after which a request should be aborted.
538
+ * @param {IT.Logger} [props.logger] - A logger object with methods like info, warn, error.
539
+ * @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type.
540
+ * @example
541
+ * s3.setProps({
542
+ * accessKeyId: 'new-access-key',
543
+ * secretAccessKey: 'new-secret-key',
544
+ * endpoint: 'https://new-endpoint.com/my-bucket',
545
+ * region: 'us-west-2' // by default is auto
546
+ * });
547
+ */
501
548
  setProps(props) {
502
549
  this._validateConstructorParams(props.accessKeyId, props.secretAccessKey, props.endpoint);
503
550
  this.accessKeyId = props.accessKeyId;
@@ -508,10 +555,22 @@ class s3mini {
508
555
  this.requestAbortTimeout = props.requestAbortTimeout;
509
556
  this.logger = props.logger;
510
557
  }
558
+ /**
559
+ * Sanitizes an ETag value by removing surrounding quotes and whitespace.
560
+ * Still returns RFC compliant ETag. https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3
561
+ * @param {string} etag - The ETag value to sanitize.
562
+ * @returns {string} The sanitized ETag value.
563
+ * @example
564
+ * const cleanEtag = s3.sanitizeETag('"abc123"'); // Returns: 'abc123'
565
+ */
511
566
  sanitizeETag(etag) {
512
567
  return sanitizeETag(etag);
513
568
  }
514
- // TBD
569
+ /**
570
+ * Creates a new bucket.
571
+ * This method sends a request to create a new bucket in the specified in endpoint.
572
+ * @returns A promise that resolves to true if the bucket was created successfully, false otherwise.
573
+ */
515
574
  async createBucket() {
516
575
  const xmlBody = `
517
576
  <CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
@@ -529,10 +588,30 @@ class s3mini {
529
588
  });
530
589
  return res.status === 200;
531
590
  }
591
+ /**
592
+ * Checks if a bucket exists.
593
+ * This method sends a request to check if the specified bucket exists in the S3-compatible service.
594
+ * @returns A promise that resolves to true if the bucket exists, false otherwise.
595
+ */
532
596
  async bucketExists() {
533
597
  const res = await this._signedRequest('HEAD', '', { tolerated: [200, 404, 403] });
534
598
  return res.status === 200;
535
599
  }
600
+ /**
601
+ * Lists objects in the bucket with optional filtering and no pagination.
602
+ * This method retrieves all objects matching the criteria (not paginated like listObjectsV2).
603
+ * @param {string} [delimiter='/'] - The delimiter to use for grouping objects.
604
+ * @param {string} [prefix=''] - The prefix to filter objects by.
605
+ * @param {number} [maxKeys] - The maximum number of keys to return. If not provided, all keys will be returned.
606
+ * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
607
+ * @returns {Promise<IT.ListObject[] | null>} A promise that resolves to an array of objects or null if the bucket is empty.
608
+ * @example
609
+ * // List all objects
610
+ * const objects = await s3.listObjects();
611
+ *
612
+ * // List objects with prefix
613
+ * const photos = await s3.listObjects('/', 'photos/', 100);
614
+ */
536
615
  async listObjects(delimiter = '/', prefix = '', maxKeys,
537
616
  // method: IT.HttpMethod = 'GET', // 'GET' or 'HEAD'
538
617
  opts = {}) {
@@ -573,9 +652,9 @@ class s3mini {
573
652
  this._log('error', `${ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`);
574
653
  throw new Error(`${ERROR_PREFIX}Unexpected listObjects response shape`);
575
654
  }
576
- const out = ('listBucketResult' in raw ? raw.listBucketResult : raw);
655
+ const out = (raw.ListBucketResult || raw.listBucketResult || raw);
577
656
  /* accumulate Contents */
578
- const contents = out.contents;
657
+ const contents = out.Contents || out.contents; // S3 v2 vs v1
579
658
  if (contents) {
580
659
  const batch = Array.isArray(contents) ? contents : [contents];
581
660
  all.push(...batch);
@@ -583,13 +662,22 @@ class s3mini {
583
662
  remaining -= batch.length;
584
663
  }
585
664
  }
586
- const truncated = out.isTruncated === 'true' || out.IsTruncated === 'true';
665
+ const truncated = out.IsTruncated === 'true' || out.isTruncated === 'true' || false;
587
666
  token = truncated
588
- ? (out.nextContinuationToken || out.NextContinuationToken || out.nextMarker || out.NextMarker)
667
+ ? (out.NextContinuationToken || out.nextContinuationToken || out.NextMarker || out.nextMarker)
589
668
  : undefined;
590
669
  } while (token && remaining > 0);
591
670
  return all;
592
671
  }
672
+ /**
673
+ * Lists multipart uploads in the bucket.
674
+ * This method sends a request to list multipart uploads in the specified bucket.
675
+ * @param {string} [delimiter='/'] - The delimiter to use for grouping uploads.
676
+ * @param {string} [prefix=''] - The prefix to filter uploads by.
677
+ * @param {IT.HttpMethod} [method='GET'] - The HTTP method to use for the request (GET or HEAD).
678
+ * @param {Record<string, string | number | boolean | undefined>} [opts={}] - Additional options for the request.
679
+ * @returns A promise that resolves to a list of multipart uploads or an error.
680
+ */
593
681
  async listMultipartUploads(delimiter = '/', prefix = '', method = 'GET', opts = {}) {
594
682
  this._checkDelimiter(delimiter);
595
683
  this._checkPrefix(prefix);
@@ -618,43 +706,104 @@ class s3mini {
618
706
  }
619
707
  return raw;
620
708
  }
621
- async getObject(key, opts = {}) {
622
- const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
709
+ /**
710
+ * Get an object from the S3-compatible service.
711
+ * This method sends a request to retrieve the specified object from the S3-compatible service.
712
+ * @param {string} key - The key of the object to retrieve.
713
+ * @param {Record<string, unknown>} [opts] - Additional options for the request.
714
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
715
+ * @returns A promise that resolves to the object data (string) or null if not found.
716
+ */
717
+ async getObject(key, opts = {}, ssecHeaders) {
718
+ // if ssecHeaders is set, add it to headers
719
+ const res = await this._signedRequest('GET', key, {
720
+ query: opts, // use opts.query if it exists, otherwise use an empty object
721
+ tolerated: [200, 404, 412, 304],
722
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
723
+ });
623
724
  if ([404, 412, 304].includes(res.status)) {
624
725
  return null;
625
726
  }
626
727
  return res.text();
627
728
  }
628
- async getObjectResponse(key, opts = {}) {
629
- const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
729
+ /**
730
+ * Get an object response from the S3-compatible service.
731
+ * This method sends a request to retrieve the specified object and returns the full response.
732
+ * @param {string} key - The key of the object to retrieve.
733
+ * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
734
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
735
+ * @returns A promise that resolves to the Response object or null if not found.
736
+ */
737
+ async getObjectResponse(key, opts = {}, ssecHeaders) {
738
+ const res = await this._signedRequest('GET', key, {
739
+ query: opts,
740
+ tolerated: [200, 404, 412, 304],
741
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
742
+ });
630
743
  if ([404, 412, 304].includes(res.status)) {
631
744
  return null;
632
745
  }
633
746
  return res;
634
747
  }
635
- async getObjectArrayBuffer(key, opts = {}) {
636
- const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
748
+ /**
749
+ * Get an object as an ArrayBuffer from the S3-compatible service.
750
+ * This method sends a request to retrieve the specified object and returns it as an ArrayBuffer.
751
+ * @param {string} key - The key of the object to retrieve.
752
+ * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
753
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
754
+ * @returns A promise that resolves to the object data as an ArrayBuffer or null if not found.
755
+ */
756
+ async getObjectArrayBuffer(key, opts = {}, ssecHeaders) {
757
+ const res = await this._signedRequest('GET', key, {
758
+ query: opts,
759
+ tolerated: [200, 404, 412, 304],
760
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
761
+ });
637
762
  if ([404, 412, 304].includes(res.status)) {
638
763
  return null;
639
764
  }
640
765
  return res.arrayBuffer();
641
766
  }
642
- async getObjectJSON(key, opts = {}) {
643
- const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
767
+ /**
768
+ * Get an object as JSON from the S3-compatible service.
769
+ * This method sends a request to retrieve the specified object and returns it as JSON.
770
+ * @param {string} key - The key of the object to retrieve.
771
+ * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
772
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
773
+ * @returns A promise that resolves to the object data as JSON or null if not found.
774
+ */
775
+ async getObjectJSON(key, opts = {}, ssecHeaders) {
776
+ const res = await this._signedRequest('GET', key, {
777
+ query: opts,
778
+ tolerated: [200, 404, 412, 304],
779
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
780
+ });
644
781
  if ([404, 412, 304].includes(res.status)) {
645
782
  return null;
646
783
  }
647
784
  return res.json();
648
785
  }
649
- async getObjectWithETag(key, opts = {}) {
786
+ /**
787
+ * Get an object with its ETag from the S3-compatible service.
788
+ * This method sends a request to retrieve the specified object and its ETag.
789
+ * @param {string} key - The key of the object to retrieve.
790
+ * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
791
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
792
+ * @returns A promise that resolves to an object containing the ETag and the object data as an ArrayBuffer or null if not found.
793
+ */
794
+ async getObjectWithETag(key, opts = {}, ssecHeaders) {
650
795
  try {
651
- const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
796
+ const res = await this._signedRequest('GET', key, {
797
+ query: opts,
798
+ tolerated: [200, 404, 412, 304],
799
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
800
+ });
652
801
  if ([404, 412, 304].includes(res.status)) {
653
802
  return { etag: null, data: null };
654
803
  }
655
804
  const etag = res.headers.get(HEADER_ETAG);
656
805
  if (!etag) {
657
- throw new Error('ETag not found in response headers');
806
+ throw new Error(`${ERROR_PREFIX}ETag not found in response headers`);
658
807
  }
659
808
  return { etag: sanitizeETag(etag), data: await res.arrayBuffer() };
660
809
  }
@@ -663,19 +812,52 @@ class s3mini {
663
812
  throw err;
664
813
  }
665
814
  }
666
- async getObjectRaw(key, wholeFile = true, rangeFrom = 0, rangeTo = this.requestSizeInBytes, opts = {}) {
815
+ /**
816
+ * Get an object as a raw response from the S3-compatible service.
817
+ * This method sends a request to retrieve the specified object and returns the raw response.
818
+ * @param {string} key - The key of the object to retrieve.
819
+ * @param {boolean} [wholeFile=true] - Whether to retrieve the whole file or a range.
820
+ * @param {number} [rangeFrom=0] - The starting byte for the range (if not whole file).
821
+ * @param {number} [rangeTo=this.requestSizeInBytes] - The ending byte for the range (if not whole file).
822
+ * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
823
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
824
+ * @returns A promise that resolves to the Response object.
825
+ */
826
+ async getObjectRaw(key, wholeFile = true, rangeFrom = 0, rangeTo = this.requestSizeInBytes, opts = {}, ssecHeaders) {
667
827
  const rangeHdr = wholeFile ? {} : { range: `bytes=${rangeFrom}-${rangeTo - 1}` };
668
828
  return this._signedRequest('GET', key, {
669
829
  query: { ...opts },
670
- headers: rangeHdr,
830
+ headers: { ...rangeHdr, ...ssecHeaders },
671
831
  withQuery: true, // keep ?query=string behaviour
672
832
  });
673
833
  }
674
- 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;
834
+ /**
835
+ * Get the content length of an object.
836
+ * This method sends a HEAD request to retrieve the content length of the specified object.
837
+ * @param {string} key - The key of the object to retrieve the content length for.
838
+ * @returns A promise that resolves to the content length of the object in bytes, or 0 if not found.
839
+ * @throws {Error} If the content length header is not found in the response.
840
+ */
841
+ async getContentLength(key, ssecHeaders) {
842
+ try {
843
+ const res = await this._signedRequest('HEAD', key, {
844
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
845
+ });
846
+ const len = res.headers.get(HEADER_CONTENT_LENGTH);
847
+ return len ? +len : 0;
848
+ }
849
+ catch (err) {
850
+ this._log('error', `Error getting content length for object ${key}: ${String(err)}`);
851
+ throw new Error(`${ERROR_PREFIX}Error getting content length for object ${key}: ${String(err)}`);
852
+ }
678
853
  }
854
+ /**
855
+ * Checks if an object exists in the S3-compatible service.
856
+ * This method sends a HEAD request to check if the specified object exists.
857
+ * @param {string} key - The key of the object to check.
858
+ * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
859
+ * @returns A promise that resolves to true if the object exists, false if not found, or null if ETag mismatch.
860
+ */
679
861
  async objectExists(key, opts = {}) {
680
862
  const res = await this._signedRequest('HEAD', key, {
681
863
  query: opts,
@@ -689,21 +871,54 @@ class s3mini {
689
871
  }
690
872
  return true; // found (200)
691
873
  }
692
- async getEtag(key, opts = {}) {
874
+ /**
875
+ * Retrieves the ETag of an object without downloading its content.
876
+ * @param {string} key - The key of the object to retrieve the ETag for.
877
+ * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
878
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
879
+ * @returns {Promise<string | null>} A promise that resolves to the ETag value or null if the object is not found.
880
+ * @throws {Error} If the ETag header is not found in the response.
881
+ * @example
882
+ * const etag = await s3.getEtag('path/to/file.txt');
883
+ * if (etag) {
884
+ * console.log(`File ETag: ${etag}`);
885
+ * }
886
+ */
887
+ async getEtag(key, opts = {}, ssecHeaders) {
693
888
  const res = await this._signedRequest('HEAD', key, {
694
889
  query: opts,
695
- tolerated: [200, 404],
890
+ tolerated: [200, 304, 404, 412],
891
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
696
892
  });
697
893
  if (res.status === 404) {
698
894
  return null;
699
895
  }
896
+ if (res.status === 412 || res.status === 304) {
897
+ return null; // ETag mismatch
898
+ }
700
899
  const etag = res.headers.get(HEADER_ETAG);
701
900
  if (!etag) {
702
- throw new Error('ETag not found in response headers');
901
+ throw new Error(`${ERROR_PREFIX}ETag not found in response headers`);
703
902
  }
704
903
  return sanitizeETag(etag);
705
904
  }
706
- async putObject(key, data, fileType = DEFAULT_STREAM_CONTENT_TYPE) {
905
+ /**
906
+ * Uploads an object to the S3-compatible service.
907
+ * @param {string} key - The key/path where the object will be stored.
908
+ * @param {string | Buffer} data - The data to upload (string or Buffer).
909
+ * @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
910
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
911
+ * @returns {Promise<Response>} A promise that resolves to the Response object from the upload request.
912
+ * @throws {TypeError} If data is not a string or Buffer.
913
+ * @example
914
+ * // Upload text file
915
+ * await s3.putObject('hello.txt', 'Hello, World!', 'text/plain');
916
+ *
917
+ * // Upload binary data
918
+ * const buffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
919
+ * await s3.putObject('image.png', buffer, 'image/png');
920
+ */
921
+ async putObject(key, data, fileType = DEFAULT_STREAM_CONTENT_TYPE, ssecHeaders) {
707
922
  if (!(data instanceof Buffer || typeof data === 'string')) {
708
923
  throw new TypeError(ERROR_DATA_BUFFER_REQUIRED);
709
924
  }
@@ -712,44 +927,110 @@ class s3mini {
712
927
  headers: {
713
928
  [HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length,
714
929
  [HEADER_CONTENT_TYPE]: fileType,
930
+ ...ssecHeaders,
715
931
  },
716
932
  tolerated: [200],
717
933
  });
718
934
  }
719
- async getMultipartUploadId(key, fileType = DEFAULT_STREAM_CONTENT_TYPE) {
935
+ /**
936
+ * Initiates a multipart upload and returns the upload ID.
937
+ * @param {string} key - The key/path where the object will be stored.
938
+ * @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
939
+ * @param {IT.SSECHeaders?} [ssecHeaders] - Server-Side Encryption headers, if any.
940
+ * @returns {Promise<string>} A promise that resolves to the upload ID for the multipart upload.
941
+ * @throws {TypeError} If key is invalid or fileType is not a string.
942
+ * @throws {Error} If the multipart upload fails to initialize.
943
+ * @example
944
+ * const uploadId = await s3.getMultipartUploadId('large-file.zip', 'application/zip');
945
+ * console.log(`Started multipart upload: ${uploadId}`);
946
+ */
947
+ async getMultipartUploadId(key, fileType = DEFAULT_STREAM_CONTENT_TYPE, ssecHeaders) {
720
948
  this._checkKey(key);
721
949
  if (typeof fileType !== 'string') {
722
950
  throw new TypeError(`${ERROR_PREFIX}fileType must be a string`);
723
951
  }
724
952
  const query = { uploads: '' };
725
- const headers = { [HEADER_CONTENT_TYPE]: fileType };
953
+ const headers = { [HEADER_CONTENT_TYPE]: fileType, ...ssecHeaders };
726
954
  const res = await this._signedRequest('POST', key, {
727
955
  query,
728
956
  headers,
729
957
  withQuery: true,
730
958
  });
731
959
  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;
960
+ // if (
961
+ // parsed &&
962
+ // typeof parsed === 'object' &&
963
+ // 'initiateMultipartUploadResult' in parsed &&
964
+ // parsed.initiateMultipartUploadResult &&
965
+ // 'uploadId' in (parsed.initiateMultipartUploadResult as { uploadId: string })
966
+ // ) {
967
+ // return (parsed.initiateMultipartUploadResult as { uploadId: string }).uploadId;
968
+ // }
969
+ if (parsed && typeof parsed === 'object') {
970
+ // Check for both cases of InitiateMultipartUploadResult
971
+ const uploadResult = parsed.initiateMultipartUploadResult ||
972
+ parsed.InitiateMultipartUploadResult;
973
+ if (uploadResult && typeof uploadResult === 'object') {
974
+ // Check for both cases of uploadId
975
+ const uploadId = uploadResult.uploadId || uploadResult.UploadId;
976
+ if (uploadId && typeof uploadId === 'string') {
977
+ return uploadId;
978
+ }
979
+ }
738
980
  }
739
981
  throw new Error(`${ERROR_PREFIX}Failed to create multipart upload: ${JSON.stringify(parsed)}`);
740
982
  }
741
- async uploadPart(key, uploadId, data, partNumber, opts = {}) {
983
+ /**
984
+ * Uploads a part in a multipart upload.
985
+ * @param {string} key - The key of the object being uploaded.
986
+ * @param {string} uploadId - The upload ID from getMultipartUploadId.
987
+ * @param {Buffer | string} data - The data for this part.
988
+ * @param {number} partNumber - The part number (must be between 1 and 10,000).
989
+ * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
990
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
991
+ * @returns {Promise<IT.UploadPart>} A promise that resolves to an object containing the partNumber and etag.
992
+ * @throws {TypeError} If any parameter is invalid.
993
+ * @example
994
+ * const part = await s3.uploadPart(
995
+ * 'large-file.zip',
996
+ * uploadId,
997
+ * partData,
998
+ * 1
999
+ * );
1000
+ * console.log(`Part ${part.partNumber} uploaded with ETag: ${part.etag}`);
1001
+ */
1002
+ async uploadPart(key, uploadId, data, partNumber, opts = {}, ssecHeaders) {
742
1003
  this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
743
1004
  const query = { uploadId, partNumber, ...opts };
744
1005
  const res = await this._signedRequest('PUT', key, {
745
1006
  query,
746
1007
  body: data,
747
- headers: { [HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length },
1008
+ headers: {
1009
+ [HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length,
1010
+ ...ssecHeaders,
1011
+ },
748
1012
  });
749
1013
  return { partNumber, etag: sanitizeETag(res.headers.get('etag') || '') };
750
1014
  }
1015
+ /**
1016
+ * Completes a multipart upload by combining all uploaded parts.
1017
+ * @param {string} key - The key of the object being uploaded.
1018
+ * @param {string} uploadId - The upload ID from getMultipartUploadId.
1019
+ * @param {Array<IT.UploadPart>} parts - Array of uploaded parts with partNumber and etag.
1020
+ * @returns {Promise<IT.CompleteMultipartUploadResult>} A promise that resolves to the completion result containing the final ETag.
1021
+ * @throws {Error} If the multipart upload fails to complete.
1022
+ * @example
1023
+ * const result = await s3.completeMultipartUpload(
1024
+ * 'large-file.zip',
1025
+ * uploadId,
1026
+ * [
1027
+ * { partNumber: 1, etag: 'abc123' },
1028
+ * { partNumber: 2, etag: 'def456' }
1029
+ * ]
1030
+ * );
1031
+ * console.log(`Upload completed with ETag: ${result.etag}`);
1032
+ */
751
1033
  async completeMultipartUpload(key, uploadId, parts) {
752
- // …existing validation left untouched …
753
1034
  const query = { uploadId };
754
1035
  const xmlBody = this._buildCompleteMultipartUploadXml(parts);
755
1036
  const headers = {
@@ -763,24 +1044,47 @@ class s3mini {
763
1044
  withQuery: true,
764
1045
  });
765
1046
  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);
1047
+ if (parsed && typeof parsed === 'object') {
1048
+ // Check for both cases
1049
+ const result = parsed.completeMultipartUploadResult || parsed.CompleteMultipartUploadResult || parsed;
1050
+ if (result && typeof result === 'object') {
1051
+ const resultObj = result;
1052
+ // Handle ETag in all its variations
1053
+ const etag = resultObj.ETag || resultObj.eTag || resultObj.etag;
1054
+ if (etag && typeof etag === 'string') {
1055
+ return {
1056
+ ...resultObj,
1057
+ etag: this.sanitizeETag(etag),
1058
+ };
1059
+ }
1060
+ return result;
1061
+ }
774
1062
  }
775
- return result;
1063
+ throw new Error(`${ERROR_PREFIX}Failed to complete multipart upload: ${JSON.stringify(parsed)}`);
776
1064
  }
777
- async abortMultipartUpload(key, uploadId) {
1065
+ /**
1066
+ * Aborts a multipart upload and removes all uploaded parts.
1067
+ * @param {string} key - The key of the object being uploaded.
1068
+ * @param {string} uploadId - The upload ID to abort.
1069
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
1070
+ * @returns {Promise<object>} A promise that resolves to an object containing the abort status and details.
1071
+ * @throws {TypeError} If key or uploadId is invalid.
1072
+ * @throws {Error} If the abort operation fails.
1073
+ * @example
1074
+ * try {
1075
+ * const result = await s3.abortMultipartUpload('large-file.zip', uploadId);
1076
+ * console.log('Upload aborted:', result.status);
1077
+ * } catch (error) {
1078
+ * console.error('Failed to abort upload:', error);
1079
+ * }
1080
+ */
1081
+ async abortMultipartUpload(key, uploadId, ssecHeaders) {
778
1082
  this._checkKey(key);
779
1083
  if (!uploadId) {
780
1084
  throw new TypeError(ERROR_UPLOAD_ID_REQUIRED);
781
1085
  }
782
1086
  const query = { uploadId };
783
- const headers = { [HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE };
1087
+ const headers = { [HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE, ...(ssecHeaders ? { ...ssecHeaders } : {}) };
784
1088
  const res = await this._signedRequest('DELETE', key, {
785
1089
  query,
786
1090
  headers,
@@ -811,10 +1115,101 @@ class s3mini {
811
1115
  </CompleteMultipartUpload>
812
1116
  `;
813
1117
  }
1118
+ /**
1119
+ * Deletes an object from the bucket.
1120
+ * This method sends a request to delete the specified object from the bucket.
1121
+ * @param {string} key - The key of the object to delete.
1122
+ * @returns A promise that resolves to true if the object was deleted successfully, false otherwise.
1123
+ */
814
1124
  async deleteObject(key) {
815
1125
  const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] });
816
1126
  return res.status === 200 || res.status === 204;
817
1127
  }
1128
+ async _deleteObjectsProcess(keys) {
1129
+ const xmlBody = `<Delete>${keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('')}</Delete>`;
1130
+ const query = { delete: '' };
1131
+ const md5Base64 = md5base64(xmlBody);
1132
+ const headers = {
1133
+ [HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
1134
+ [HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(),
1135
+ 'Content-MD5': md5Base64,
1136
+ };
1137
+ const res = await this._signedRequest('POST', '', {
1138
+ query,
1139
+ body: xmlBody,
1140
+ headers,
1141
+ withQuery: true,
1142
+ });
1143
+ const parsed = parseXml(await res.text());
1144
+ if (!parsed || typeof parsed !== 'object') {
1145
+ throw new Error(`${ERROR_PREFIX}Failed to delete objects: ${JSON.stringify(parsed)}`);
1146
+ }
1147
+ const out = (parsed.DeleteResult || parsed.deleteResult || parsed);
1148
+ const resultMap = new Map();
1149
+ keys.forEach(key => resultMap.set(key, false));
1150
+ const deleted = out.deleted || out.Deleted;
1151
+ if (deleted) {
1152
+ const deletedArray = Array.isArray(deleted) ? deleted : [deleted];
1153
+ deletedArray.forEach((item) => {
1154
+ if (item && typeof item === 'object') {
1155
+ const obj = item;
1156
+ // Check both key and Key
1157
+ const key = obj.key || obj.Key;
1158
+ if (key && typeof key === 'string') {
1159
+ resultMap.set(key, true);
1160
+ }
1161
+ }
1162
+ });
1163
+ }
1164
+ // Handle errors (check both cases)
1165
+ const errors = out.error || out.Error;
1166
+ if (errors) {
1167
+ const errorsArray = Array.isArray(errors) ? errors : [errors];
1168
+ errorsArray.forEach((item) => {
1169
+ if (item && typeof item === 'object') {
1170
+ const obj = item;
1171
+ // Check both cases for all properties
1172
+ const key = obj.key || obj.Key;
1173
+ const code = obj.code || obj.Code;
1174
+ const message = obj.message || obj.Message;
1175
+ if (key && typeof key === 'string') {
1176
+ resultMap.set(key, false);
1177
+ // Optionally log the error for debugging
1178
+ this._log('warn', `Failed to delete object: ${key}`, {
1179
+ code: code || 'Unknown',
1180
+ message: message || 'Unknown error',
1181
+ });
1182
+ }
1183
+ }
1184
+ });
1185
+ }
1186
+ // Return boolean array in the same order as input keys
1187
+ return keys.map(key => resultMap.get(key) || false);
1188
+ }
1189
+ /**
1190
+ * Deletes multiple objects from the bucket.
1191
+ * @param {string[]} keys - An array of object keys to delete.
1192
+ * @returns A promise that resolves to an array of booleans indicating success for each key in order.
1193
+ */
1194
+ async deleteObjects(keys) {
1195
+ if (!Array.isArray(keys) || keys.length === 0) {
1196
+ return [];
1197
+ }
1198
+ const maxBatchSize = 1000; // S3 limit for delete batch size
1199
+ if (keys.length > maxBatchSize) {
1200
+ const allPromises = [];
1201
+ for (let i = 0; i < keys.length; i += maxBatchSize) {
1202
+ const batch = keys.slice(i, i + maxBatchSize);
1203
+ allPromises.push(this._deleteObjectsProcess(batch));
1204
+ }
1205
+ const results = await Promise.all(allPromises);
1206
+ // Flatten the results array
1207
+ return results.flat();
1208
+ }
1209
+ else {
1210
+ return await this._deleteObjectsProcess(keys);
1211
+ }
1212
+ }
818
1213
  async _sendRequest(url, method, headers, body, toleratedStatusCodes = []) {
819
1214
  this._log('info', `Sending ${method} request to ${url}`, `headers: ${JSON.stringify(headers)}`);
820
1215
  try {
@@ -861,6 +1256,10 @@ class s3mini {
861
1256
  return hmac(kService, AWS_REQUEST_TYPE);
862
1257
  }
863
1258
  }
1259
+ /**
1260
+ * @deprecated Use `S3mini` instead.
1261
+ */
1262
+ const s3mini = S3mini;
864
1263
 
865
- export { s3mini as default, runInBatches, s3mini, sanitizeETag };
1264
+ export { S3mini, S3mini as default, runInBatches, s3mini, sanitizeETag };
866
1265
  //# sourceMappingURL=s3mini.js.map