s3mini 0.3.0 → 0.5.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
@@ -11,11 +11,12 @@ const SENSITIVE_KEYS_REDACTED = ['accessKeyId', 'secretAccessKey', 'sessionToken
11
11
  const DEFAULT_REQUEST_SIZE_IN_BYTES = 8 * 1024 * 1024;
12
12
  // Headers
13
13
  const HEADER_AMZ_CONTENT_SHA256 = 'x-amz-content-sha256';
14
+ const HEADER_AMZ_CHECKSUM_SHA256 = 'x-amz-checksum-sha256';
14
15
  const HEADER_AMZ_DATE = 'x-amz-date';
15
16
  const HEADER_HOST = 'host';
16
- const HEADER_AUTHORIZATION = 'Authorization';
17
- const HEADER_CONTENT_TYPE = 'Content-Type';
18
- const HEADER_CONTENT_LENGTH = 'Content-Length';
17
+ const HEADER_AUTHORIZATION = 'authorization';
18
+ const HEADER_CONTENT_TYPE = 'content-type';
19
+ const HEADER_CONTENT_LENGTH = 'content-length';
19
20
  const HEADER_ETAG = 'etag';
20
21
  // Error messages
21
22
  const ERROR_PREFIX = '[s3mini] ';
@@ -26,35 +27,70 @@ const ERROR_ENDPOINT_FORMAT = `${ERROR_PREFIX}endpoint must be a valid URL. Expe
26
27
  const ERROR_KEY_REQUIRED = `${ERROR_PREFIX}key must be a non-empty string`;
27
28
  const ERROR_UPLOAD_ID_REQUIRED = `${ERROR_PREFIX}uploadId must be a non-empty string`;
28
29
  const ERROR_DATA_BUFFER_REQUIRED = `${ERROR_PREFIX}data must be a Buffer or string`;
29
- // const ERROR_PATH_REQUIRED = `${ERROR_PREFIX}path must be a string`;
30
30
  const ERROR_PREFIX_TYPE = `${ERROR_PREFIX}prefix must be a string`;
31
31
  const ERROR_DELIMITER_REQUIRED = `${ERROR_PREFIX}delimiter must be a string`;
32
32
 
33
- // Initialize crypto functions - this is needed for environments where `crypto` is not available globally
34
- // e.g., in Cloudflare Workers or other non-Node.js environments with nodejs_flags enabled.
35
- const _createHmac = crypto.createHmac || (await import('node:crypto')).createHmac;
36
- const _createHash = crypto.createHash || (await import('node:crypto')).createHash;
33
+ const ENCODR = new TextEncoder();
34
+ const chunkSize = 0x8000; // 32KB chunks
35
+ const HEXS = '0123456789abcdef';
36
+ const getByteSize = (data) => {
37
+ if (typeof data === 'string') {
38
+ return ENCODR.encode(data).byteLength;
39
+ }
40
+ if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
41
+ return data.byteLength;
42
+ }
43
+ if (data instanceof Blob) {
44
+ return data.size;
45
+ }
46
+ throw new Error('Unsupported data type');
47
+ };
48
+ /**
49
+ * Turn a raw ArrayBuffer into its hexadecimal representation.
50
+ * @param {ArrayBuffer} buffer The raw bytes.
51
+ * @returns {string} Hexadecimal string
52
+ */
53
+ const hexFromBuffer = (buffer) => {
54
+ const bytes = new Uint8Array(buffer);
55
+ let hex = '';
56
+ for (const byte of bytes) {
57
+ hex += HEXS[byte >> 4] + HEXS[byte & 0x0f];
58
+ }
59
+ return hex;
60
+ };
37
61
  /**
38
- * Hash content using SHA-256
39
- * @param {string|Buffer} content – data to hash
40
- * @returns {string} Hex encoded hash
62
+ * Turn a raw ArrayBuffer into its base64 representation.
63
+ * @param {ArrayBuffer} buffer The raw bytes.
64
+ * @returns {string} Base64 string
41
65
  */
42
- const hash = (content) => {
43
- return _createHash('sha256').update(content).digest('hex');
66
+ const base64FromBuffer = (buffer) => {
67
+ const bytes = new Uint8Array(buffer);
68
+ let result = '';
69
+ for (let i = 0; i < bytes.length; i += chunkSize) {
70
+ const chunk = bytes.subarray(i, i + chunkSize);
71
+ result += btoa(String.fromCharCode.apply(null, chunk));
72
+ }
73
+ return result;
44
74
  };
45
- const md5base64 = (data) => {
46
- return _createHash('md5').update(data).digest('base64');
75
+ /**
76
+ * Compute SHA-256 hash of arbitrary string data.
77
+ * @param {string} content The content to be hashed.
78
+ * @returns {ArrayBuffer} The raw hash
79
+ */
80
+ const sha256 = async (content) => {
81
+ const data = ENCODR.encode(content);
82
+ return await globalThis.crypto.subtle.digest('SHA-256', data);
47
83
  };
48
84
  /**
49
- * Compute HMAC-SHA-256 of arbitrary data and return a hex string.
50
- * @param {string|Buffer} key secret key
51
- * @param {string|Buffer} content data to authenticate
52
- * @param {BufferEncoding} [encoding='hex'] hex | base64 | …
53
- * @returns {string | Buffer} hex encoded HMAC
85
+ * Compute HMAC-SHA-256 of arbitrary data.
86
+ * @param {string|ArrayBuffer} key The key used to sign the content.
87
+ * @param {string} content The content to be signed.
88
+ * @returns {ArrayBuffer} The raw signature
54
89
  */
55
- const hmac = (key, content, encoding) => {
56
- const mac = _createHmac('sha256', key).update(content);
57
- return encoding ? mac.digest(encoding) : mac.digest();
90
+ const hmac = async (key, content) => {
91
+ const secret = await globalThis.crypto.subtle.importKey('raw', typeof key === 'string' ? ENCODR.encode(key) : key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
92
+ const data = ENCODR.encode(content);
93
+ return await globalThis.crypto.subtle.sign('HMAC', secret, data);
58
94
  };
59
95
  /**
60
96
  * Sanitize ETag value by removing quotes and XML entities
@@ -69,7 +105,7 @@ const sanitizeETag = (etag) => {
69
105
  '&QUOT;': '',
70
106
  '&#x00022': '',
71
107
  };
72
- return etag.replace(/^("|&quot;|&#34;)|("|&quot;|&#34;)$/g, m => replaceChars[m]);
108
+ return etag.replace(/(^("|&quot;|&#34;))|(("|&quot;|&#34;)$)/g, m => replaceChars[m]);
73
109
  };
74
110
  const entityMap = {
75
111
  '&quot;': '"',
@@ -228,8 +264,8 @@ const runInBatches = async (tasks, batchSize = 30, minIntervalMs = 0) => {
228
264
  * const s3 = new CoreS3({
229
265
  * accessKeyId: 'your-access-key',
230
266
  * secretAccessKey: 'your-secret-key',
231
- * endpoint: 'https://your-s3-endpoint.com',
232
- * region: 'us-east-1' // by default is auto
267
+ * endpoint: 'https://your-s3-endpoint.com/bucket-name',
268
+ * region: 'auto' // by default is auto
233
269
  * });
234
270
  *
235
271
  * // Upload a file
@@ -241,7 +277,7 @@ const runInBatches = async (tasks, batchSize = 30, minIntervalMs = 0) => {
241
277
  * // Delete a file
242
278
  * await s3.deleteObject('example.txt');
243
279
  */
244
- class s3mini {
280
+ class S3mini {
245
281
  /**
246
282
  * Creates an instance of the S3 class.
247
283
  *
@@ -260,6 +296,7 @@ class s3mini {
260
296
  secretAccessKey;
261
297
  endpoint;
262
298
  region;
299
+ bucketName;
263
300
  requestSizeInBytes;
264
301
  requestAbortTimeout;
265
302
  logger;
@@ -269,8 +306,9 @@ class s3mini {
269
306
  this._validateConstructorParams(accessKeyId, secretAccessKey, endpoint);
270
307
  this.accessKeyId = accessKeyId;
271
308
  this.secretAccessKey = secretAccessKey;
272
- this.endpoint = this._ensureValidUrl(endpoint);
309
+ this.endpoint = new URL(this._ensureValidUrl(endpoint));
273
310
  this.region = region;
311
+ this.bucketName = this._extractBucketName();
274
312
  this.requestSizeInBytes = requestSizeInBytes;
275
313
  this.requestAbortTimeout = requestAbortTimeout;
276
314
  this.logger = logger;
@@ -307,7 +345,7 @@ class s3mini {
307
345
  // Include some general context, but sanitize sensitive parts
308
346
  context: this._sanitize({
309
347
  region: this.region,
310
- endpoint: this.endpoint,
348
+ endpoint: this.endpoint.toString(),
311
349
  // Only include the first few characters of the access key, if it exists
312
350
  accessKeyId: this.accessKeyId ? `${this.accessKeyId.substring(0, 4)}...` : undefined,
313
351
  }),
@@ -395,12 +433,15 @@ class s3mini {
395
433
  }
396
434
  return { filteredOpts, conditionalHeaders };
397
435
  }
398
- _validateUploadPartParams(key, uploadId, data, partNumber, opts) {
399
- this._checkKey(key);
400
- if (!(data instanceof Buffer || typeof data === 'string')) {
436
+ _validateData(data) {
437
+ if (!((globalThis.Buffer && data instanceof globalThis.Buffer) || typeof data === 'string')) {
401
438
  this._log('error', ERROR_DATA_BUFFER_REQUIRED);
402
439
  throw new TypeError(ERROR_DATA_BUFFER_REQUIRED);
403
440
  }
441
+ return data;
442
+ }
443
+ _validateUploadPartParams(key, uploadId, data, partNumber, opts) {
444
+ this._checkKey(key);
404
445
  if (typeof uploadId !== 'string' || uploadId.trim().length === 0) {
405
446
  this._log('error', ERROR_UPLOAD_ID_REQUIRED);
406
447
  throw new TypeError(ERROR_UPLOAD_ID_REQUIRED);
@@ -410,8 +451,9 @@ class s3mini {
410
451
  throw new TypeError(`${ERROR_PREFIX}partNumber must be a positive integer`);
411
452
  }
412
453
  this._checkOpts(opts);
454
+ return this._validateData(data);
413
455
  }
414
- _sign(method, keyPath, query = {}, headers = {}) {
456
+ async _sign(method, keyPath, query = {}, headers = {}) {
415
457
  // Create URL without appending keyPath first
416
458
  const url = new URL(this.endpoint);
417
459
  // Properly format the pathname to avoid double slashes
@@ -419,75 +461,53 @@ class s3mini {
419
461
  url.pathname =
420
462
  url.pathname === '/' ? `/${keyPath.replace(/^\/+/, '')}` : `${url.pathname}/${keyPath.replace(/^\/+/, '')}`;
421
463
  }
422
- const fullDatetime = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
423
- const shortDatetime = fullDatetime.slice(0, 8);
424
- const credentialScope = this._buildCredentialScope(shortDatetime);
425
- headers[HEADER_AMZ_CONTENT_SHA256] = UNSIGNED_PAYLOAD; // body ? U.hash(body) : C.UNSIGNED_PAYLOAD;
464
+ const d = new Date();
465
+ const year = d.getUTCFullYear();
466
+ const month = String(d.getUTCMonth() + 1).padStart(2, '0');
467
+ const day = String(d.getUTCDate()).padStart(2, '0');
468
+ const shortDatetime = `${year}${month}${day}`;
469
+ const fullDatetime = `${shortDatetime}T${String(d.getUTCHours()).padStart(2, '0')}${String(d.getUTCMinutes()).padStart(2, '0')}${String(d.getUTCSeconds()).padStart(2, '0')}Z`;
470
+ const credentialScope = `${shortDatetime}/${this.region}/${S3_SERVICE}/${AWS_REQUEST_TYPE}`;
471
+ headers[HEADER_AMZ_CONTENT_SHA256] = UNSIGNED_PAYLOAD;
426
472
  headers[HEADER_AMZ_DATE] = fullDatetime;
427
473
  headers[HEADER_HOST] = url.host;
428
- const canonicalHeaders = this._buildCanonicalHeaders(headers);
429
- const signedHeaders = Object.keys(headers)
430
- .map(key => key.toLowerCase())
431
- .sort()
432
- .join(';');
433
- const canonicalRequest = this._buildCanonicalRequest(method, url, query, canonicalHeaders, signedHeaders);
434
- const stringToSign = this._buildStringToSign(fullDatetime, credentialScope, canonicalRequest);
435
- const signature = this._calculateSignature(shortDatetime, stringToSign);
436
- const authorizationHeader = this._buildAuthorizationHeader(credentialScope, signedHeaders, signature);
437
- headers[HEADER_AUTHORIZATION] = authorizationHeader;
438
- return { url: url.toString(), headers };
439
- }
440
- _buildCanonicalHeaders(headers) {
441
- return Object.entries(headers)
442
- .map(([key, value]) => `${key.toLowerCase()}:${String(value).trim()}`)
443
- .sort()
444
- .join('\n');
445
- }
446
- _buildCanonicalRequest(method, url, query, canonicalHeaders, signedHeaders) {
447
- return [
448
- method,
449
- url.pathname,
450
- this._buildCanonicalQueryString(query),
451
- `${canonicalHeaders}\n`,
452
- signedHeaders,
453
- UNSIGNED_PAYLOAD,
454
- ].join('\n');
455
- }
456
- _buildCredentialScope(shortDatetime) {
457
- return [shortDatetime, this.region, S3_SERVICE, AWS_REQUEST_TYPE].join('/');
458
- }
459
- _buildStringToSign(fullDatetime, credentialScope, canonicalRequest) {
460
- return [AWS_ALGORITHM, fullDatetime, credentialScope, hash(canonicalRequest)].join('\n');
461
- }
462
- _calculateSignature(shortDatetime, stringToSign) {
474
+ const ignoredHeaders = new Set(['authorization', 'content-length', 'content-type', 'user-agent']);
475
+ let canonicalHeaders = '';
476
+ let signedHeaders = '';
477
+ for (const [key, value] of Object.entries(headers).sort(([a], [b]) => a.localeCompare(b))) {
478
+ const lowerKey = key.toLowerCase();
479
+ if (!ignoredHeaders.has(lowerKey)) {
480
+ if (canonicalHeaders) {
481
+ canonicalHeaders += '\n';
482
+ signedHeaders += ';';
483
+ }
484
+ canonicalHeaders += `${lowerKey}:${String(value).trim()}`;
485
+ signedHeaders += lowerKey;
486
+ }
487
+ }
488
+ const canonicalRequest = `${method}\n${url.pathname}\n${this._buildCanonicalQueryString(query)}\n${canonicalHeaders}\n\n${signedHeaders}\n${UNSIGNED_PAYLOAD}`;
489
+ const stringToSign = `${AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${hexFromBuffer(await sha256(canonicalRequest))}`;
463
490
  if (shortDatetime !== this.signingKeyDate) {
464
491
  this.signingKeyDate = shortDatetime;
465
- this.signingKey = this._getSignatureKey(shortDatetime);
492
+ this.signingKey = await this._getSignatureKey(shortDatetime);
466
493
  }
467
- return hmac(this.signingKey, stringToSign, 'hex');
468
- }
469
- _buildAuthorizationHeader(credentialScope, signedHeaders, signature) {
470
- return [
471
- `${AWS_ALGORITHM} Credential=${this.accessKeyId}/${credentialScope}`,
472
- `SignedHeaders=${signedHeaders}`,
473
- `Signature=${signature}`,
474
- ].join(', ');
494
+ const signature = hexFromBuffer(await hmac(this.signingKey, stringToSign));
495
+ headers[HEADER_AUTHORIZATION] =
496
+ `${AWS_ALGORITHM} Credential=${this.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
497
+ return { url: url.toString(), headers };
475
498
  }
476
499
  async _signedRequest(method, // 'GET' | 'HEAD' | 'PUT' | 'POST' | 'DELETE'
477
500
  key, // ‘’ allowed for bucket‑level ops
478
501
  { query = {}, // ?query=string
479
- body = '', // string | Buffer | undefined
502
+ body = '', // BodyInit | undefined
480
503
  headers = {}, // extra/override headers
481
504
  tolerated = [], // [200, 404] etc.
482
505
  withQuery = false, // append query string to signed URL
483
506
  } = {}) {
484
507
  // Basic validation
485
- if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) {
486
- throw new Error(`${ERROR_PREFIX}Unsupported HTTP method ${method}`);
487
- }
488
- if (key) {
489
- this._checkKey(key); // allow '' for bucket‑level
490
- }
508
+ // if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) {
509
+ // throw new Error(`${C.ERROR_PREFIX}Unsupported HTTP method ${method as string}`);
510
+ // }
491
511
  const { filteredOpts, conditionalHeaders } = ['GET', 'HEAD'].includes(method)
492
512
  ? this._filterIfHeaders(query)
493
513
  : { filteredOpts: query, conditionalHeaders: {} };
@@ -498,7 +518,7 @@ class s3mini {
498
518
  ...conditionalHeaders,
499
519
  };
500
520
  const encodedKey = key ? uriResourceEscape(key) : '';
501
- const { url, headers: signedHeaders } = this._sign(method, encodedKey, filteredOpts, baseHeaders);
521
+ const { url, headers: signedHeaders } = await this._sign(method, encodedKey, filteredOpts, baseHeaders);
502
522
  if (Object.keys(query).length > 0) {
503
523
  withQuery = true; // append query string to signed URL
504
524
  }
@@ -507,53 +527,6 @@ class s3mini {
507
527
  const signedHeadersString = Object.fromEntries(Object.entries(signedHeaders).map(([k, v]) => [k, String(v)]));
508
528
  return this._sendRequest(finalUrl, method, signedHeadersString, body, tolerated);
509
529
  }
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
- */
517
- getProps() {
518
- return {
519
- accessKeyId: this.accessKeyId,
520
- secretAccessKey: this.secretAccessKey,
521
- endpoint: this.endpoint,
522
- region: this.region,
523
- requestSizeInBytes: this.requestSizeInBytes,
524
- requestAbortTimeout: this.requestAbortTimeout,
525
- logger: this.logger,
526
- };
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
- */
547
- setProps(props) {
548
- this._validateConstructorParams(props.accessKeyId, props.secretAccessKey, props.endpoint);
549
- this.accessKeyId = props.accessKeyId;
550
- this.secretAccessKey = props.secretAccessKey;
551
- this.region = props.region || 'auto';
552
- this.endpoint = props.endpoint;
553
- this.requestSizeInBytes = props.requestSizeInBytes || DEFAULT_REQUEST_SIZE_IN_BYTES;
554
- this.requestAbortTimeout = props.requestAbortTimeout;
555
- this.logger = props.logger;
556
- }
557
530
  /**
558
531
  * Sanitizes an ETag value by removing surrounding quotes and whitespace.
559
532
  * Still returns RFC compliant ETag. https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3
@@ -578,7 +551,7 @@ class s3mini {
578
551
  `;
579
552
  const headers = {
580
553
  [HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
581
- [HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(),
554
+ [HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
582
555
  };
583
556
  const res = await this._signedRequest('PUT', '', {
584
557
  body: xmlBody,
@@ -587,6 +560,35 @@ class s3mini {
587
560
  });
588
561
  return res.status === 200;
589
562
  }
563
+ _extractBucketName() {
564
+ const url = this.endpoint;
565
+ // First check if bucket is in the pathname (path-style URLs)
566
+ const pathSegments = url.pathname.split('/').filter(p => p);
567
+ if (pathSegments.length > 0) {
568
+ if (typeof pathSegments[0] === 'string') {
569
+ return pathSegments[0];
570
+ }
571
+ }
572
+ // Otherwise extract from subdomain (virtual-hosted-style URLs)
573
+ const hostParts = url.hostname.split('.');
574
+ // Common patterns:
575
+ // bucket-name.s3.amazonaws.com
576
+ // bucket-name.s3.region.amazonaws.com
577
+ // bucket-name.region.digitaloceanspaces.com
578
+ // bucket-name.region.cdn.digitaloceanspaces.com
579
+ if (hostParts.length >= 3) {
580
+ // Check if it's a known S3-compatible service
581
+ const domain = hostParts.slice(-2).join('.');
582
+ const knownDomains = ['amazonaws.com', 'digitaloceanspaces.com', 'cloudflare.com'];
583
+ if (knownDomains.some(d => domain.includes(d))) {
584
+ if (typeof hostParts[0] === 'string') {
585
+ return hostParts[0];
586
+ }
587
+ }
588
+ }
589
+ // Fallback: use the first subdomain
590
+ return hostParts[0] || '';
591
+ }
590
592
  /**
591
593
  * Checks if a bucket exists.
592
594
  * This method sends a request to check if the specified bucket exists in the S3-compatible service.
@@ -603,7 +605,7 @@ class s3mini {
603
605
  * @param {string} [prefix=''] - The prefix to filter objects by.
604
606
  * @param {number} [maxKeys] - The maximum number of keys to return. If not provided, all keys will be returned.
605
607
  * @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.
608
+ * @returns {Promise<IT.ListObject[] | null>} A promise that resolves to an array of objects or null if the bucket is empty.
607
609
  * @example
608
610
  * // List all objects
609
611
  * const objects = await s3.listObjects();
@@ -611,9 +613,7 @@ class s3mini {
611
613
  * // List objects with prefix
612
614
  * const photos = await s3.listObjects('/', 'photos/', 100);
613
615
  */
614
- async listObjects(delimiter = '/', prefix = '', maxKeys,
615
- // method: IT.HttpMethod = 'GET', // 'GET' or 'HEAD'
616
- opts = {}) {
616
+ async listObjects(delimiter = '/', prefix = '', maxKeys, opts = {}) {
617
617
  this._checkDelimiter(delimiter);
618
618
  this._checkPrefix(prefix);
619
619
  this._checkOpts(opts);
@@ -623,51 +623,80 @@ class s3mini {
623
623
  let token;
624
624
  const all = [];
625
625
  do {
626
- const batchSize = Math.min(remaining, 1000); // S3 ceiling
627
- const query = {
628
- 'list-type': LIST_TYPE, // =2 for V2
629
- 'max-keys': String(batchSize),
630
- ...(prefix ? { prefix } : {}),
631
- ...(token ? { 'continuation-token': token } : {}),
632
- ...opts,
633
- };
634
- const res = await this._signedRequest('GET', keyPath, {
635
- query,
636
- withQuery: true,
637
- tolerated: [200, 404],
638
- });
639
- if (res.status === 404) {
640
- return null;
641
- }
642
- if (res.status !== 200) {
643
- const errorBody = await res.text();
644
- const errorCode = res.headers.get('x-amz-error-code') || 'Unknown';
645
- const errorMessage = res.headers.get('x-amz-error-message') || res.statusText;
646
- this._log('error', `${ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`);
647
- throw new Error(`${ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`);
626
+ const batchResult = await this._fetchObjectBatch(keyPath, prefix, remaining, token, opts);
627
+ if (batchResult === null) {
628
+ return null; // 404 - bucket not found
648
629
  }
649
- const raw = parseXml(await res.text());
650
- if (typeof raw !== 'object' || !raw || 'error' in raw) {
651
- this._log('error', `${ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`);
652
- throw new Error(`${ERROR_PREFIX}Unexpected listObjects response shape`);
630
+ all.push(...batchResult.objects);
631
+ if (!unlimited) {
632
+ remaining -= batchResult.objects.length;
653
633
  }
654
- const out = (raw.ListBucketResult || raw.listBucketResult || raw);
655
- /* accumulate Contents */
656
- const contents = out.Contents || out.contents; // S3 v2 vs v1
657
- if (contents) {
658
- const batch = Array.isArray(contents) ? contents : [contents];
659
- all.push(...batch);
660
- if (!unlimited) {
661
- remaining -= batch.length;
662
- }
663
- }
664
- const truncated = out.IsTruncated === 'true' || out.isTruncated === 'true' || false;
665
- token = truncated
666
- ? (out.NextContinuationToken || out.nextContinuationToken || out.NextMarker || out.nextMarker)
667
- : undefined;
634
+ token = batchResult.continuationToken;
668
635
  } while (token && remaining > 0);
669
636
  return all;
670
637
  }
638
+ async _fetchObjectBatch(keyPath, prefix, remaining, token, opts) {
639
+ const query = this._buildListObjectsQuery(prefix, remaining, token, opts);
640
+ const res = await this._signedRequest('GET', keyPath, {
641
+ query,
642
+ withQuery: true,
643
+ tolerated: [200, 404],
644
+ });
645
+ if (res.status === 404) {
646
+ return null;
647
+ }
648
+ if (res.status !== 200) {
649
+ await this._handleListObjectsError(res);
650
+ }
651
+ const xmlText = await res.text();
652
+ return this._parseListObjectsResponse(xmlText);
653
+ }
654
+ _buildListObjectsQuery(prefix, remaining, token, opts) {
655
+ const batchSize = Math.min(remaining, 1000); // S3 ceiling
656
+ return {
657
+ 'list-type': LIST_TYPE, // =2 for V2
658
+ 'max-keys': String(batchSize),
659
+ ...(prefix ? { prefix } : {}),
660
+ ...(token ? { 'continuation-token': token } : {}),
661
+ ...opts,
662
+ };
663
+ }
664
+ async _handleListObjectsError(res) {
665
+ const errorBody = await res.text();
666
+ const parsedErrorBody = this._parseErrorXml(res.headers, errorBody);
667
+ const errorCode = res.headers.get('x-amz-error-code') ?? parsedErrorBody.svcCode ?? 'Unknown';
668
+ const errorMessage = res.headers.get('x-amz-error-message') ?? parsedErrorBody.errorMessage ?? res.statusText;
669
+ this._log('error', `${ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`);
670
+ throw new Error(`${ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`);
671
+ }
672
+ _parseListObjectsResponse(xmlText) {
673
+ const raw = parseXml(xmlText);
674
+ if (typeof raw !== 'object' || !raw || 'error' in raw) {
675
+ this._log('error', `${ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`);
676
+ throw new Error(`${ERROR_PREFIX}Unexpected listObjects response shape`);
677
+ }
678
+ const out = (raw.ListBucketResult || raw.listBucketResult || raw);
679
+ const objects = this._extractObjectsFromResponse(out);
680
+ const continuationToken = this._extractContinuationToken(out);
681
+ return { objects, continuationToken };
682
+ }
683
+ _extractObjectsFromResponse(response) {
684
+ const contents = response.Contents || response.contents; // S3 v2 vs v1
685
+ if (!contents) {
686
+ return [];
687
+ }
688
+ return Array.isArray(contents) ? contents : [contents];
689
+ }
690
+ _extractContinuationToken(response) {
691
+ const truncated = response.IsTruncated === 'true' || response.isTruncated === 'true' || false;
692
+ if (!truncated) {
693
+ return undefined;
694
+ }
695
+ return (response.NextContinuationToken ||
696
+ response.nextContinuationToken ||
697
+ response.NextMarker ||
698
+ response.nextMarker);
699
+ }
671
700
  /**
672
701
  * Lists multipart uploads in the bucket.
673
702
  * This method sends a request to list multipart uploads in the specified bucket.
@@ -709,11 +738,17 @@ class s3mini {
709
738
  * Get an object from the S3-compatible service.
710
739
  * This method sends a request to retrieve the specified object from the S3-compatible service.
711
740
  * @param {string} key - The key of the object to retrieve.
712
- * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
741
+ * @param {Record<string, unknown>} [opts] - Additional options for the request.
742
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
713
743
  * @returns A promise that resolves to the object data (string) or null if not found.
714
744
  */
715
- async getObject(key, opts = {}) {
716
- const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
745
+ async getObject(key, opts = {}, ssecHeaders) {
746
+ // if ssecHeaders is set, add it to headers
747
+ const res = await this._signedRequest('GET', key, {
748
+ query: opts, // use opts.query if it exists, otherwise use an empty object
749
+ tolerated: [200, 404, 412, 304],
750
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
751
+ });
717
752
  if ([404, 412, 304].includes(res.status)) {
718
753
  return null;
719
754
  }
@@ -724,10 +759,15 @@ class s3mini {
724
759
  * This method sends a request to retrieve the specified object and returns the full response.
725
760
  * @param {string} key - The key of the object to retrieve.
726
761
  * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
762
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
727
763
  * @returns A promise that resolves to the Response object or null if not found.
728
764
  */
729
- async getObjectResponse(key, opts = {}) {
730
- const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
765
+ async getObjectResponse(key, opts = {}, ssecHeaders) {
766
+ const res = await this._signedRequest('GET', key, {
767
+ query: opts,
768
+ tolerated: [200, 404, 412, 304],
769
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
770
+ });
731
771
  if ([404, 412, 304].includes(res.status)) {
732
772
  return null;
733
773
  }
@@ -738,10 +778,15 @@ class s3mini {
738
778
  * This method sends a request to retrieve the specified object and returns it as an ArrayBuffer.
739
779
  * @param {string} key - The key of the object to retrieve.
740
780
  * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
781
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
741
782
  * @returns A promise that resolves to the object data as an ArrayBuffer or null if not found.
742
783
  */
743
- async getObjectArrayBuffer(key, opts = {}) {
744
- const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
784
+ async getObjectArrayBuffer(key, opts = {}, ssecHeaders) {
785
+ const res = await this._signedRequest('GET', key, {
786
+ query: opts,
787
+ tolerated: [200, 404, 412, 304],
788
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
789
+ });
745
790
  if ([404, 412, 304].includes(res.status)) {
746
791
  return null;
747
792
  }
@@ -752,10 +797,15 @@ class s3mini {
752
797
  * This method sends a request to retrieve the specified object and returns it as JSON.
753
798
  * @param {string} key - The key of the object to retrieve.
754
799
  * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
800
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
755
801
  * @returns A promise that resolves to the object data as JSON or null if not found.
756
802
  */
757
- async getObjectJSON(key, opts = {}) {
758
- const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
803
+ async getObjectJSON(key, opts = {}, ssecHeaders) {
804
+ const res = await this._signedRequest('GET', key, {
805
+ query: opts,
806
+ tolerated: [200, 404, 412, 304],
807
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
808
+ });
759
809
  if ([404, 412, 304].includes(res.status)) {
760
810
  return null;
761
811
  }
@@ -766,11 +816,16 @@ class s3mini {
766
816
  * This method sends a request to retrieve the specified object and its ETag.
767
817
  * @param {string} key - The key of the object to retrieve.
768
818
  * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
819
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
769
820
  * @returns A promise that resolves to an object containing the ETag and the object data as an ArrayBuffer or null if not found.
770
821
  */
771
- async getObjectWithETag(key, opts = {}) {
822
+ async getObjectWithETag(key, opts = {}, ssecHeaders) {
772
823
  try {
773
- const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
824
+ const res = await this._signedRequest('GET', key, {
825
+ query: opts,
826
+ tolerated: [200, 404, 412, 304],
827
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
828
+ });
774
829
  if ([404, 412, 304].includes(res.status)) {
775
830
  return { etag: null, data: null };
776
831
  }
@@ -793,13 +848,14 @@ class s3mini {
793
848
  * @param {number} [rangeFrom=0] - The starting byte for the range (if not whole file).
794
849
  * @param {number} [rangeTo=this.requestSizeInBytes] - The ending byte for the range (if not whole file).
795
850
  * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
851
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
796
852
  * @returns A promise that resolves to the Response object.
797
853
  */
798
- async getObjectRaw(key, wholeFile = true, rangeFrom = 0, rangeTo = this.requestSizeInBytes, opts = {}) {
854
+ async getObjectRaw(key, wholeFile = true, rangeFrom = 0, rangeTo = this.requestSizeInBytes, opts = {}, ssecHeaders) {
799
855
  const rangeHdr = wholeFile ? {} : { range: `bytes=${rangeFrom}-${rangeTo - 1}` };
800
856
  return this._signedRequest('GET', key, {
801
857
  query: { ...opts },
802
- headers: rangeHdr,
858
+ headers: { ...rangeHdr, ...ssecHeaders },
803
859
  withQuery: true, // keep ?query=string behaviour
804
860
  });
805
861
  }
@@ -810,9 +866,11 @@ class s3mini {
810
866
  * @returns A promise that resolves to the content length of the object in bytes, or 0 if not found.
811
867
  * @throws {Error} If the content length header is not found in the response.
812
868
  */
813
- async getContentLength(key) {
869
+ async getContentLength(key, ssecHeaders) {
814
870
  try {
815
- const res = await this._signedRequest('HEAD', key);
871
+ const res = await this._signedRequest('HEAD', key, {
872
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
873
+ });
816
874
  const len = res.headers.get(HEADER_CONTENT_LENGTH);
817
875
  return len ? +len : 0;
818
876
  }
@@ -845,6 +903,7 @@ class s3mini {
845
903
  * Retrieves the ETag of an object without downloading its content.
846
904
  * @param {string} key - The key of the object to retrieve the ETag for.
847
905
  * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
906
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
848
907
  * @returns {Promise<string | null>} A promise that resolves to the ETag value or null if the object is not found.
849
908
  * @throws {Error} If the ETag header is not found in the response.
850
909
  * @example
@@ -853,14 +912,18 @@ class s3mini {
853
912
  * console.log(`File ETag: ${etag}`);
854
913
  * }
855
914
  */
856
- async getEtag(key, opts = {}) {
915
+ async getEtag(key, opts = {}, ssecHeaders) {
857
916
  const res = await this._signedRequest('HEAD', key, {
858
917
  query: opts,
859
- tolerated: [200, 404],
918
+ tolerated: [200, 304, 404, 412],
919
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
860
920
  });
861
921
  if (res.status === 404) {
862
922
  return null;
863
923
  }
924
+ if (res.status === 412 || res.status === 304) {
925
+ return null; // ETag mismatch
926
+ }
864
927
  const etag = res.headers.get(HEADER_ETAG);
865
928
  if (!etag) {
866
929
  throw new Error(`${ERROR_PREFIX}ETag not found in response headers`);
@@ -872,6 +935,8 @@ class s3mini {
872
935
  * @param {string} key - The key/path where the object will be stored.
873
936
  * @param {string | Buffer} data - The data to upload (string or Buffer).
874
937
  * @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
938
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
939
+ * @param {IT.AWSHeaders} [additionalHeaders] - Additional x-amz-* headers specific to this request, if any.
875
940
  * @returns {Promise<Response>} A promise that resolves to the Response object from the upload request.
876
941
  * @throws {TypeError} If data is not a string or Buffer.
877
942
  * @example
@@ -882,15 +947,14 @@ class s3mini {
882
947
  * const buffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
883
948
  * await s3.putObject('image.png', buffer, 'image/png');
884
949
  */
885
- async putObject(key, data, fileType = DEFAULT_STREAM_CONTENT_TYPE) {
886
- if (!(data instanceof Buffer || typeof data === 'string')) {
887
- throw new TypeError(ERROR_DATA_BUFFER_REQUIRED);
888
- }
950
+ async putObject(key, data, fileType = DEFAULT_STREAM_CONTENT_TYPE, ssecHeaders, additionalHeaders) {
889
951
  return this._signedRequest('PUT', key, {
890
- body: data,
952
+ body: this._validateData(data),
891
953
  headers: {
892
- [HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length,
954
+ [HEADER_CONTENT_LENGTH]: getByteSize(data),
893
955
  [HEADER_CONTENT_TYPE]: fileType,
956
+ ...additionalHeaders,
957
+ ...ssecHeaders,
894
958
  },
895
959
  tolerated: [200],
896
960
  });
@@ -899,6 +963,7 @@ class s3mini {
899
963
  * Initiates a multipart upload and returns the upload ID.
900
964
  * @param {string} key - The key/path where the object will be stored.
901
965
  * @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
966
+ * @param {IT.SSECHeaders?} [ssecHeaders] - Server-Side Encryption headers, if any.
902
967
  * @returns {Promise<string>} A promise that resolves to the upload ID for the multipart upload.
903
968
  * @throws {TypeError} If key is invalid or fileType is not a string.
904
969
  * @throws {Error} If the multipart upload fails to initialize.
@@ -906,28 +971,19 @@ class s3mini {
906
971
  * const uploadId = await s3.getMultipartUploadId('large-file.zip', 'application/zip');
907
972
  * console.log(`Started multipart upload: ${uploadId}`);
908
973
  */
909
- async getMultipartUploadId(key, fileType = DEFAULT_STREAM_CONTENT_TYPE) {
974
+ async getMultipartUploadId(key, fileType = DEFAULT_STREAM_CONTENT_TYPE, ssecHeaders) {
910
975
  this._checkKey(key);
911
976
  if (typeof fileType !== 'string') {
912
977
  throw new TypeError(`${ERROR_PREFIX}fileType must be a string`);
913
978
  }
914
979
  const query = { uploads: '' };
915
- const headers = { [HEADER_CONTENT_TYPE]: fileType };
980
+ const headers = { [HEADER_CONTENT_TYPE]: fileType, ...ssecHeaders };
916
981
  const res = await this._signedRequest('POST', key, {
917
982
  query,
918
983
  headers,
919
984
  withQuery: true,
920
985
  });
921
986
  const parsed = parseXml(await res.text());
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
987
  if (parsed && typeof parsed === 'object') {
932
988
  // Check for both cases of InitiateMultipartUploadResult
933
989
  const uploadResult = parsed.initiateMultipartUploadResult ||
@@ -949,6 +1005,7 @@ class s3mini {
949
1005
  * @param {Buffer | string} data - The data for this part.
950
1006
  * @param {number} partNumber - The part number (must be between 1 and 10,000).
951
1007
  * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
1008
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
952
1009
  * @returns {Promise<IT.UploadPart>} A promise that resolves to an object containing the partNumber and etag.
953
1010
  * @throws {TypeError} If any parameter is invalid.
954
1011
  * @example
@@ -960,13 +1017,16 @@ class s3mini {
960
1017
  * );
961
1018
  * console.log(`Part ${part.partNumber} uploaded with ETag: ${part.etag}`);
962
1019
  */
963
- async uploadPart(key, uploadId, data, partNumber, opts = {}) {
964
- this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
1020
+ async uploadPart(key, uploadId, data, partNumber, opts = {}, ssecHeaders) {
1021
+ const body = this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
965
1022
  const query = { uploadId, partNumber, ...opts };
966
1023
  const res = await this._signedRequest('PUT', key, {
967
1024
  query,
968
- body: data,
969
- headers: { [HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length },
1025
+ body,
1026
+ headers: {
1027
+ [HEADER_CONTENT_LENGTH]: getByteSize(data),
1028
+ ...ssecHeaders,
1029
+ },
970
1030
  });
971
1031
  return { partNumber, etag: sanitizeETag(res.headers.get('etag') || '') };
972
1032
  }
@@ -993,7 +1053,7 @@ class s3mini {
993
1053
  const xmlBody = this._buildCompleteMultipartUploadXml(parts);
994
1054
  const headers = {
995
1055
  [HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
996
- [HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(),
1056
+ [HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
997
1057
  };
998
1058
  const res = await this._signedRequest('POST', key, {
999
1059
  query,
@@ -1012,7 +1072,7 @@ class s3mini {
1012
1072
  if (etag && typeof etag === 'string') {
1013
1073
  return {
1014
1074
  ...resultObj,
1015
- etag: this.sanitizeETag(etag),
1075
+ etag: sanitizeETag(etag),
1016
1076
  };
1017
1077
  }
1018
1078
  return result;
@@ -1024,6 +1084,7 @@ class s3mini {
1024
1084
  * Aborts a multipart upload and removes all uploaded parts.
1025
1085
  * @param {string} key - The key of the object being uploaded.
1026
1086
  * @param {string} uploadId - The upload ID to abort.
1087
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
1027
1088
  * @returns {Promise<object>} A promise that resolves to an object containing the abort status and details.
1028
1089
  * @throws {TypeError} If key or uploadId is invalid.
1029
1090
  * @throws {Error} If the abort operation fails.
@@ -1035,13 +1096,13 @@ class s3mini {
1035
1096
  * console.error('Failed to abort upload:', error);
1036
1097
  * }
1037
1098
  */
1038
- async abortMultipartUpload(key, uploadId) {
1099
+ async abortMultipartUpload(key, uploadId, ssecHeaders) {
1039
1100
  this._checkKey(key);
1040
1101
  if (!uploadId) {
1041
1102
  throw new TypeError(ERROR_UPLOAD_ID_REQUIRED);
1042
1103
  }
1043
1104
  const query = { uploadId };
1044
- const headers = { [HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE };
1105
+ const headers = { [HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE, ...(ssecHeaders ? { ...ssecHeaders } : {}) };
1045
1106
  const res = await this._signedRequest('DELETE', key, {
1046
1107
  query,
1047
1108
  headers,
@@ -1059,18 +1120,193 @@ class s3mini {
1059
1120
  return { status: 'Aborted', key, uploadId, response: parsed };
1060
1121
  }
1061
1122
  _buildCompleteMultipartUploadXml(parts) {
1062
- return `
1063
- <CompleteMultipartUpload>
1064
- ${parts
1065
- .map(part => `
1066
- <Part>
1067
- <PartNumber>${part.partNumber}</PartNumber>
1068
- <ETag>${part.etag}</ETag>
1069
- </Part>
1070
- `)
1071
- .join('')}
1072
- </CompleteMultipartUpload>
1073
- `;
1123
+ let xml = '<CompleteMultipartUpload>';
1124
+ for (const part of parts) {
1125
+ xml += `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${part.etag}</ETag></Part>`;
1126
+ }
1127
+ xml += '</CompleteMultipartUpload>';
1128
+ return xml;
1129
+ }
1130
+ /**
1131
+ * Executes the copy operation for local copying (same bucket/endpoint).
1132
+ * @private
1133
+ */
1134
+ async _executeCopyOperation(destinationKey, copySource, options) {
1135
+ const { metadataDirective = 'COPY', metadata = {}, contentType, storageClass, taggingDirective, websiteRedirectLocation, sourceSSECHeaders = {}, destinationSSECHeaders = {}, additionalHeaders = {}, } = options;
1136
+ const headers = {
1137
+ 'x-amz-copy-source': copySource,
1138
+ 'x-amz-metadata-directive': metadataDirective,
1139
+ ...additionalHeaders,
1140
+ ...(contentType && { [HEADER_CONTENT_TYPE]: contentType }),
1141
+ ...(storageClass && { 'x-amz-storage-class': storageClass }),
1142
+ ...(taggingDirective && { 'x-amz-tagging-directive': taggingDirective }),
1143
+ ...(websiteRedirectLocation && { 'x-amz-website-redirect-location': websiteRedirectLocation }),
1144
+ ...this._buildSSECHeaders(sourceSSECHeaders, destinationSSECHeaders),
1145
+ ...(metadataDirective === 'REPLACE' ? this._buildMetadataHeaders(metadata) : {}),
1146
+ };
1147
+ try {
1148
+ const res = await this._signedRequest('PUT', destinationKey, {
1149
+ headers,
1150
+ tolerated: [200],
1151
+ });
1152
+ return this._parseCopyObjectResponse(await res.text());
1153
+ }
1154
+ catch (err) {
1155
+ this._log('error', `Error in copy operation to ${destinationKey}`, {
1156
+ error: String(err),
1157
+ });
1158
+ throw err;
1159
+ }
1160
+ }
1161
+ /**
1162
+ * Copies an object within the same bucket.
1163
+ *
1164
+ * @param {string} sourceKey - The key of the source object to copy
1165
+ * @param {string} destinationKey - The key where the object will be copied to
1166
+ * @param {IT.CopyObjectOptions} [options={}] - Copy operation options
1167
+ * @param {string} [options.metadataDirective='COPY'] - How to handle metadata ('COPY' | 'REPLACE')
1168
+ * @param {Record<string,string>} [options.metadata={}] - New metadata (only used if metadataDirective='REPLACE')
1169
+ * @param {string} [options.contentType] - New content type for the destination object
1170
+ * @param {string} [options.storageClass] - Storage class for the destination object
1171
+ * @param {string} [options.taggingDirective] - How to handle object tags ('COPY' | 'REPLACE')
1172
+ * @param {string} [options.websiteRedirectLocation] - Website redirect location for the destination
1173
+ * @param {IT.SSECHeaders} [options.sourceSSECHeaders={}] - Encryption headers for reading source (if encrypted)
1174
+ * @param {IT.SSECHeaders} [options.destinationSSECHeaders={}] - Encryption headers for destination
1175
+ * @param {IT.AWSHeaders} [options.additionalHeaders={}] - Extra x-amz-* headers
1176
+ *
1177
+ * @returns {Promise<IT.CopyObjectResult>} Copy result with etag and lastModified date
1178
+ * @throws {TypeError} If sourceKey or destinationKey is invalid
1179
+ * @throws {Error} If copy operation fails or S3 returns an error
1180
+ *
1181
+ * @example
1182
+ * // Simple copy
1183
+ * const result = await s3.copyObject('report-2024.pdf', 'archive/report-2024.pdf');
1184
+ * console.log(`Copied with ETag: ${result.etag}`);
1185
+ *
1186
+ * @example
1187
+ * // Copy with new metadata and content type
1188
+ * const result = await s3.copyObject('data.csv', 'processed/data.csv', {
1189
+ * metadataDirective: 'REPLACE',
1190
+ * metadata: {
1191
+ * 'processed-date': new Date().toISOString(),
1192
+ * 'original-name': 'data.csv'
1193
+ * },
1194
+ * contentType: 'text/csv; charset=utf-8'
1195
+ * });
1196
+ *
1197
+ * @example
1198
+ * // Copy encrypted object (Cloudflare R2 SSE-C)
1199
+ * const ssecKey = 'n1TKiTaVHlYLMX9n0zHXyooMr026vOiTEFfT+719Hho=';
1200
+ * await s3.copyObject('sensitive.json', 'backup/sensitive.json', {
1201
+ * sourceSSECHeaders: {
1202
+ * 'x-amz-copy-source-server-side-encryption-customer-algorithm': 'AES256',
1203
+ * 'x-amz-copy-source-server-side-encryption-customer-key': ssecKey,
1204
+ * 'x-amz-copy-source-server-side-encryption-customer-key-md5': 'gepZmzgR7Be/1+K1Aw+6ow=='
1205
+ * },
1206
+ * destinationSSECHeaders: {
1207
+ * 'x-amz-server-side-encryption-customer-algorithm': 'AES256',
1208
+ * 'x-amz-server-side-encryption-customer-key': ssecKey,
1209
+ * 'x-amz-server-side-encryption-customer-key-md5': 'gepZmzgR7Be/1+K1Aw+6ow=='
1210
+ * }
1211
+ * });
1212
+ */
1213
+ copyObject(sourceKey, destinationKey, options = {}) {
1214
+ // Validate parameters
1215
+ this._checkKey(sourceKey);
1216
+ this._checkKey(destinationKey);
1217
+ const copySource = `/${this.bucketName}/${uriEscape(sourceKey)}`;
1218
+ return this._executeCopyOperation(destinationKey, copySource, options);
1219
+ }
1220
+ _buildSSECHeaders(sourceHeaders, destHeaders) {
1221
+ const headers = {};
1222
+ Object.entries({ ...sourceHeaders, ...destHeaders }).forEach(([k, v]) => {
1223
+ if (v !== undefined) {
1224
+ headers[k] = v;
1225
+ }
1226
+ });
1227
+ return headers;
1228
+ }
1229
+ /**
1230
+ * Moves an object within the same bucket (copy + delete atomic-like operation).
1231
+ *
1232
+ * WARNING: Not truly atomic - if delete fails after successful copy, the object
1233
+ * will exist in both locations. Consider your use case carefully.
1234
+ *
1235
+ * @param {string} sourceKey - The key of the source object to move
1236
+ * @param {string} destinationKey - The key where the object will be moved to
1237
+ * @param {IT.CopyObjectOptions} [options={}] - Options passed to the copy operation
1238
+ *
1239
+ * @returns {Promise<IT.CopyObjectResult>} Result from the copy operation
1240
+ * @throws {TypeError} If sourceKey or destinationKey is invalid
1241
+ * @throws {Error} If copy succeeds but delete fails (includes copy result in error)
1242
+ *
1243
+ * @example
1244
+ * // Simple move
1245
+ * await s3.moveObject('temp/upload.tmp', 'files/document.pdf');
1246
+ *
1247
+ * @example
1248
+ * // Move with metadata update
1249
+ * await s3.moveObject('unprocessed/image.jpg', 'processed/image.jpg', {
1250
+ * metadataDirective: 'REPLACE',
1251
+ * metadata: {
1252
+ * 'status': 'processed',
1253
+ * 'processed-at': Date.now().toString()
1254
+ * },
1255
+ * contentType: 'image/jpeg'
1256
+ * });
1257
+ *
1258
+ * @example
1259
+ * // Safe move with error handling
1260
+ * try {
1261
+ * const result = await s3.moveObject('inbox/file.dat', 'archive/file.dat');
1262
+ * console.log(`Moved successfully: ${result.etag}`);
1263
+ * } catch (error) {
1264
+ * // Check if copy succeeded but delete failed
1265
+ * if (error.message.includes('delete source object after successful copy')) {
1266
+ * console.warn('File copied but not deleted from source - manual cleanup needed');
1267
+ * }
1268
+ * }
1269
+ */
1270
+ async moveObject(sourceKey, destinationKey, options = {}) {
1271
+ try {
1272
+ // First copy the object
1273
+ const copyResult = await this.copyObject(sourceKey, destinationKey, options);
1274
+ // Then delete the source
1275
+ const deleteSuccess = await this.deleteObject(sourceKey);
1276
+ if (!deleteSuccess) {
1277
+ throw new Error(`${ERROR_PREFIX}Failed to delete source object after successful copy`);
1278
+ }
1279
+ return copyResult;
1280
+ }
1281
+ catch (err) {
1282
+ this._log('error', `Error moving object from ${sourceKey} to ${destinationKey}`, {
1283
+ error: String(err),
1284
+ });
1285
+ throw err;
1286
+ }
1287
+ }
1288
+ _buildMetadataHeaders(metadata) {
1289
+ const headers = {};
1290
+ Object.entries(metadata).forEach(([k, v]) => {
1291
+ headers[k.startsWith('x-amz-meta-') ? k : `x-amz-meta-${k}`] = v;
1292
+ });
1293
+ return headers;
1294
+ }
1295
+ _parseCopyObjectResponse(xmlText) {
1296
+ const parsed = parseXml(xmlText);
1297
+ if (!parsed || typeof parsed !== 'object') {
1298
+ throw new Error(`${ERROR_PREFIX}Unexpected copyObject response format`);
1299
+ }
1300
+ const result = (parsed.CopyObjectResult || parsed.copyObjectResult || parsed);
1301
+ const etag = result.ETag || result.eTag || result.etag;
1302
+ const lastModified = result.LastModified || result.lastModified;
1303
+ if (!etag || typeof etag !== 'string') {
1304
+ throw new Error(`${ERROR_PREFIX}ETag not found in copyObject response`);
1305
+ }
1306
+ return {
1307
+ etag: sanitizeETag(etag),
1308
+ lastModified: lastModified ? new Date(lastModified) : undefined,
1309
+ };
1074
1310
  }
1075
1311
  /**
1076
1312
  * Deletes an object from the bucket.
@@ -1083,13 +1319,14 @@ class s3mini {
1083
1319
  return res.status === 200 || res.status === 204;
1084
1320
  }
1085
1321
  async _deleteObjectsProcess(keys) {
1086
- const xmlBody = `<Delete>${keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('')}</Delete>`;
1322
+ const objectsXml = keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('');
1323
+ const xmlBody = '<Delete>' + objectsXml + '</Delete>';
1087
1324
  const query = { delete: '' };
1088
- const md5Base64 = md5base64(xmlBody);
1325
+ const sha256base64 = base64FromBuffer(await sha256(xmlBody));
1089
1326
  const headers = {
1090
1327
  [HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
1091
- [HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(),
1092
- 'Content-MD5': md5Base64,
1328
+ [HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
1329
+ [HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
1093
1330
  };
1094
1331
  const res = await this._signedRequest('POST', '', {
1095
1332
  query,
@@ -1177,9 +1414,10 @@ class s3mini {
1177
1414
  signal: this.requestAbortTimeout !== undefined ? AbortSignal.timeout(this.requestAbortTimeout) : undefined,
1178
1415
  });
1179
1416
  this._log('info', `Response status: ${res.status}, tolerated: ${toleratedStatusCodes.join(',')}`);
1180
- if (!res.ok && !toleratedStatusCodes.includes(res.status)) {
1181
- await this._handleErrorResponse(res);
1417
+ if (res.ok || toleratedStatusCodes.includes(res.status)) {
1418
+ return res;
1182
1419
  }
1420
+ await this._handleErrorResponse(res);
1183
1421
  return res;
1184
1422
  }
1185
1423
  catch (err) {
@@ -1190,10 +1428,29 @@ class s3mini {
1190
1428
  throw err;
1191
1429
  }
1192
1430
  }
1431
+ _parseErrorXml(headers, body) {
1432
+ if (headers.get('content-type') !== 'application/xml') {
1433
+ return {};
1434
+ }
1435
+ const parsedBody = parseXml(body);
1436
+ if (!parsedBody ||
1437
+ typeof parsedBody !== 'object' ||
1438
+ !('Error' in parsedBody) ||
1439
+ !parsedBody.Error ||
1440
+ typeof parsedBody.Error !== 'object') {
1441
+ return {};
1442
+ }
1443
+ const error = parsedBody.Error;
1444
+ return {
1445
+ svcCode: 'Code' in error && typeof error.Code === 'string' ? error.Code : undefined,
1446
+ errorMessage: 'Message' in error && typeof error.Message === 'string' ? error.Message : undefined,
1447
+ };
1448
+ }
1193
1449
  async _handleErrorResponse(res) {
1194
1450
  const errorBody = await res.text();
1195
- const svcCode = res.headers.get('x-amz-error-code') ?? 'Unknown';
1196
- const errorMessage = res.headers.get('x-amz-error-message') || res.statusText;
1451
+ const parsedErrorBody = this._parseErrorXml(res.headers, errorBody);
1452
+ const svcCode = res.headers.get('x-amz-error-code') ?? parsedErrorBody.svcCode ?? 'Unknown';
1453
+ const errorMessage = res.headers.get('x-amz-error-message') ?? parsedErrorBody.errorMessage ?? res.statusText;
1197
1454
  this._log('error', `${ERROR_PREFIX}Request failed with status ${res.status}: ${svcCode} - ${errorMessage},err body: ${errorBody}`);
1198
1455
  throw new S3ServiceError(`S3 returned ${res.status} – ${svcCode}`, res.status, svcCode, errorBody);
1199
1456
  }
@@ -1203,16 +1460,16 @@ class s3mini {
1203
1460
  }
1204
1461
  return Object.keys(queryParams)
1205
1462
  .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}`)
1206
- .sort()
1463
+ .sort((a, b) => a.localeCompare(b))
1207
1464
  .join('&');
1208
1465
  }
1209
- _getSignatureKey(dateStamp) {
1210
- const kDate = hmac(`AWS4${this.secretAccessKey}`, dateStamp);
1211
- const kRegion = hmac(kDate, this.region);
1212
- const kService = hmac(kRegion, S3_SERVICE);
1213
- return hmac(kService, AWS_REQUEST_TYPE);
1466
+ async _getSignatureKey(dateStamp) {
1467
+ const kDate = await hmac(`AWS4${this.secretAccessKey}`, dateStamp);
1468
+ const kRegion = await hmac(kDate, this.region);
1469
+ const kService = await hmac(kRegion, S3_SERVICE);
1470
+ return await hmac(kService, AWS_REQUEST_TYPE);
1214
1471
  }
1215
1472
  }
1216
1473
 
1217
- export { s3mini as default, runInBatches, s3mini, sanitizeETag };
1474
+ export { S3mini, S3mini as default, runInBatches, sanitizeETag };
1218
1475
  //# sourceMappingURL=s3mini.js.map