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/src/S3.ts CHANGED
@@ -14,8 +14,8 @@ import * as U from './utils.js';
14
14
  * const s3 = new CoreS3({
15
15
  * accessKeyId: 'your-access-key',
16
16
  * secretAccessKey: 'your-secret-key',
17
- * endpoint: 'https://your-s3-endpoint.com',
18
- * region: 'us-east-1' // by default is auto
17
+ * endpoint: 'https://your-s3-endpoint.com/bucket-name',
18
+ * region: 'auto' // by default is auto
19
19
  * });
20
20
  *
21
21
  * // Upload a file
@@ -27,7 +27,7 @@ import * as U from './utils.js';
27
27
  * // Delete a file
28
28
  * await s3.deleteObject('example.txt');
29
29
  */
30
- class s3mini {
30
+ class S3mini {
31
31
  /**
32
32
  * Creates an instance of the S3 class.
33
33
  *
@@ -42,15 +42,16 @@ class s3mini {
42
42
  * @param {Object} [config.logger=null] - A logger object with methods like info, warn, error.
43
43
  * @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type.
44
44
  */
45
- private accessKeyId: string;
46
- private secretAccessKey: string;
47
- private endpoint: string;
48
- private region: string;
49
- private requestSizeInBytes: number;
50
- private requestAbortTimeout?: number;
51
- private logger?: IT.Logger;
45
+ readonly accessKeyId: string;
46
+ readonly secretAccessKey: string;
47
+ readonly endpoint: URL;
48
+ readonly region: string;
49
+ readonly bucketName: string;
50
+ readonly requestSizeInBytes: number;
51
+ readonly requestAbortTimeout?: number;
52
+ readonly logger?: IT.Logger;
52
53
  private signingKeyDate?: string;
53
- private signingKey?: Buffer;
54
+ private signingKey?: ArrayBuffer;
54
55
 
55
56
  constructor({
56
57
  accessKeyId,
@@ -64,8 +65,9 @@ class s3mini {
64
65
  this._validateConstructorParams(accessKeyId, secretAccessKey, endpoint);
65
66
  this.accessKeyId = accessKeyId;
66
67
  this.secretAccessKey = secretAccessKey;
67
- this.endpoint = this._ensureValidUrl(endpoint);
68
+ this.endpoint = new URL(this._ensureValidUrl(endpoint));
68
69
  this.region = region;
70
+ this.bucketName = this._extractBucketName();
69
71
  this.requestSizeInBytes = requestSizeInBytes;
70
72
  this.requestAbortTimeout = requestAbortTimeout;
71
73
  this.logger = logger;
@@ -112,7 +114,7 @@ class s3mini {
112
114
  // Include some general context, but sanitize sensitive parts
113
115
  context: this._sanitize({
114
116
  region: this.region,
115
- endpoint: this.endpoint,
117
+ endpoint: this.endpoint.toString(),
116
118
  // Only include the first few characters of the access key, if it exists
117
119
  accessKeyId: this.accessKeyId ? `${this.accessKeyId.substring(0, 4)}...` : undefined,
118
120
  }),
@@ -215,18 +217,22 @@ class s3mini {
215
217
  return { filteredOpts, conditionalHeaders };
216
218
  }
217
219
 
220
+ private _validateData(data: unknown): BodyInit {
221
+ if (!((globalThis.Buffer && data instanceof globalThis.Buffer) || typeof data === 'string')) {
222
+ this._log('error', C.ERROR_DATA_BUFFER_REQUIRED);
223
+ throw new TypeError(C.ERROR_DATA_BUFFER_REQUIRED);
224
+ }
225
+ return data;
226
+ }
227
+
218
228
  private _validateUploadPartParams(
219
229
  key: string,
220
230
  uploadId: string,
221
- data: Buffer | string,
231
+ data: IT.MaybeBuffer | string,
222
232
  partNumber: number,
223
233
  opts: object,
224
- ): void {
234
+ ): BodyInit {
225
235
  this._checkKey(key);
226
- if (!(data instanceof Buffer || typeof data === 'string')) {
227
- this._log('error', C.ERROR_DATA_BUFFER_REQUIRED);
228
- throw new TypeError(C.ERROR_DATA_BUFFER_REQUIRED);
229
- }
230
236
  if (typeof uploadId !== 'string' || uploadId.trim().length === 0) {
231
237
  this._log('error', C.ERROR_UPLOAD_ID_REQUIRED);
232
238
  throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED);
@@ -236,14 +242,15 @@ class s3mini {
236
242
  throw new TypeError(`${C.ERROR_PREFIX}partNumber must be a positive integer`);
237
243
  }
238
244
  this._checkOpts(opts);
245
+ return this._validateData(data);
239
246
  }
240
247
 
241
- private _sign(
248
+ private async _sign(
242
249
  method: IT.HttpMethod,
243
250
  keyPath: string,
244
251
  query: Record<string, unknown> = {},
245
252
  headers: Record<string, string | number> = {},
246
- ): { url: string; headers: Record<string, string | number> } {
253
+ ): Promise<{ url: string; headers: Record<string, string | number> }> {
247
254
  // Create URL without appending keyPath first
248
255
  const url = new URL(this.endpoint);
249
256
 
@@ -253,73 +260,45 @@ class s3mini {
253
260
  url.pathname === '/' ? `/${keyPath.replace(/^\/+/, '')}` : `${url.pathname}/${keyPath.replace(/^\/+/, '')}`;
254
261
  }
255
262
 
256
- const fullDatetime = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
257
- const shortDatetime = fullDatetime.slice(0, 8);
258
- const credentialScope = this._buildCredentialScope(shortDatetime);
263
+ const d = new Date();
264
+ const year = d.getUTCFullYear();
265
+ const month = String(d.getUTCMonth() + 1).padStart(2, '0');
266
+ const day = String(d.getUTCDate()).padStart(2, '0');
267
+
268
+ const shortDatetime = `${year}${month}${day}`;
269
+ const fullDatetime = `${shortDatetime}T${String(d.getUTCHours()).padStart(2, '0')}${String(d.getUTCMinutes()).padStart(2, '0')}${String(d.getUTCSeconds()).padStart(2, '0')}Z`;
270
+ const credentialScope = `${shortDatetime}/${this.region}/${C.S3_SERVICE}/${C.AWS_REQUEST_TYPE}`;
259
271
 
260
- headers[C.HEADER_AMZ_CONTENT_SHA256] = C.UNSIGNED_PAYLOAD; // body ? U.hash(body) : C.UNSIGNED_PAYLOAD;
272
+ headers[C.HEADER_AMZ_CONTENT_SHA256] = C.UNSIGNED_PAYLOAD;
261
273
  headers[C.HEADER_AMZ_DATE] = fullDatetime;
262
274
  headers[C.HEADER_HOST] = url.host;
263
- const canonicalHeaders = this._buildCanonicalHeaders(headers);
264
- const signedHeaders = Object.keys(headers)
265
- .map(key => key.toLowerCase())
266
- .sort()
267
- .join(';');
268
-
269
- const canonicalRequest = this._buildCanonicalRequest(method, url, query, canonicalHeaders, signedHeaders);
270
- const stringToSign = this._buildStringToSign(fullDatetime, credentialScope, canonicalRequest);
271
- const signature = this._calculateSignature(shortDatetime, stringToSign);
272
- const authorizationHeader = this._buildAuthorizationHeader(credentialScope, signedHeaders, signature);
273
- headers[C.HEADER_AUTHORIZATION] = authorizationHeader;
274
- return { url: url.toString(), headers };
275
- }
276
275
 
277
- private _buildCanonicalHeaders(headers: Record<string, string | number>): string {
278
- return Object.entries(headers)
279
- .map(([key, value]) => `${key.toLowerCase()}:${String(value).trim()}`)
280
- .sort()
281
- .join('\n');
282
- }
276
+ const ignoredHeaders = new Set(['authorization', 'content-length', 'content-type', 'user-agent']);
283
277
 
284
- private _buildCanonicalRequest(
285
- method: IT.HttpMethod,
286
- url: URL,
287
- query: Record<string, unknown>,
288
- canonicalHeaders: string,
289
- signedHeaders: string,
290
- ): string {
291
- return [
292
- method,
293
- url.pathname,
294
- this._buildCanonicalQueryString(query),
295
- `${canonicalHeaders}\n`,
296
- signedHeaders,
297
- C.UNSIGNED_PAYLOAD,
298
- ].join('\n');
299
- }
278
+ let canonicalHeaders = '';
279
+ let signedHeaders = '';
300
280
 
301
- private _buildCredentialScope(shortDatetime: string): string {
302
- return [shortDatetime, this.region, C.S3_SERVICE, C.AWS_REQUEST_TYPE].join('/');
303
- }
304
-
305
- private _buildStringToSign(fullDatetime: string, credentialScope: string, canonicalRequest: string): string {
306
- return [C.AWS_ALGORITHM, fullDatetime, credentialScope, U.hash(canonicalRequest)].join('\n');
307
- }
308
-
309
- private _calculateSignature(shortDatetime: string, stringToSign: string): string {
281
+ for (const [key, value] of Object.entries(headers).sort(([a], [b]) => a.localeCompare(b))) {
282
+ const lowerKey = key.toLowerCase();
283
+ if (!ignoredHeaders.has(lowerKey)) {
284
+ if (canonicalHeaders) {
285
+ canonicalHeaders += '\n';
286
+ signedHeaders += ';';
287
+ }
288
+ canonicalHeaders += `${lowerKey}:${String(value).trim()}`;
289
+ signedHeaders += lowerKey;
290
+ }
291
+ }
292
+ const canonicalRequest = `${method}\n${url.pathname}\n${this._buildCanonicalQueryString(query)}\n${canonicalHeaders}\n\n${signedHeaders}\n${C.UNSIGNED_PAYLOAD}`;
293
+ const stringToSign = `${C.AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${U.hexFromBuffer(await U.sha256(canonicalRequest))}`;
310
294
  if (shortDatetime !== this.signingKeyDate) {
311
295
  this.signingKeyDate = shortDatetime;
312
- this.signingKey = this._getSignatureKey(shortDatetime);
296
+ this.signingKey = await this._getSignatureKey(shortDatetime);
313
297
  }
314
- return U.hmac(this.signingKey!, stringToSign, 'hex') as string;
315
- }
316
-
317
- private _buildAuthorizationHeader(credentialScope: string, signedHeaders: string, signature: string): string {
318
- return [
319
- `${C.AWS_ALGORITHM} Credential=${this.accessKeyId}/${credentialScope}`,
320
- `SignedHeaders=${signedHeaders}`,
321
- `Signature=${signature}`,
322
- ].join(', ');
298
+ const signature = U.hexFromBuffer(await U.hmac(this.signingKey!, stringToSign));
299
+ headers[C.HEADER_AUTHORIZATION] =
300
+ `${C.AWS_ALGORITHM} Credential=${this.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
301
+ return { url: url.toString(), headers };
323
302
  }
324
303
 
325
304
  private async _signedRequest(
@@ -327,30 +306,26 @@ class s3mini {
327
306
  key: string, // ‘’ allowed for bucket‑level ops
328
307
  {
329
308
  query = {}, // ?query=string
330
- body = '', // string | Buffer | undefined
309
+ body = '', // BodyInit | undefined
331
310
  headers = {}, // extra/override headers
332
311
  tolerated = [], // [200, 404] etc.
333
312
  withQuery = false, // append query string to signed URL
334
313
  }: {
335
314
  query?: Record<string, unknown>;
336
- body?: string | Buffer | undefined;
337
- headers?: Record<string, string | number | undefined>;
338
- tolerated?: number[] | undefined;
339
- withQuery?: boolean | undefined;
315
+ body?: BodyInit;
316
+ headers?: Record<string, string | number | undefined> | IT.SSECHeaders | IT.AWSHeaders;
317
+ tolerated?: number[];
318
+ withQuery?: boolean;
340
319
  } = {},
341
320
  ): Promise<Response> {
342
321
  // Basic validation
343
- if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) {
344
- throw new Error(`${C.ERROR_PREFIX}Unsupported HTTP method ${method as string}`);
345
- }
346
- if (key) {
347
- this._checkKey(key); // allow '' for bucket‑level
348
- }
322
+ // if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) {
323
+ // throw new Error(`${C.ERROR_PREFIX}Unsupported HTTP method ${method as string}`);
324
+ // }
349
325
 
350
326
  const { filteredOpts, conditionalHeaders } = ['GET', 'HEAD'].includes(method)
351
327
  ? this._filterIfHeaders(query)
352
328
  : { filteredOpts: query, conditionalHeaders: {} };
353
-
354
329
  const baseHeaders: Record<string, string | number> = {
355
330
  [C.HEADER_AMZ_CONTENT_SHA256]: C.UNSIGNED_PAYLOAD,
356
331
  // ...(['GET', 'HEAD'].includes(method) ? { [C.HEADER_CONTENT_TYPE]: C.JSON_CONTENT_TYPE } : {}),
@@ -359,7 +334,7 @@ class s3mini {
359
334
  };
360
335
 
361
336
  const encodedKey = key ? U.uriResourceEscape(key) : '';
362
- const { url, headers: signedHeaders } = this._sign(method, encodedKey, filteredOpts, baseHeaders);
337
+ const { url, headers: signedHeaders } = await this._sign(method, encodedKey, filteredOpts, baseHeaders);
363
338
  if (Object.keys(query).length > 0) {
364
339
  withQuery = true; // append query string to signed URL
365
340
  }
@@ -374,55 +349,6 @@ class s3mini {
374
349
  return this._sendRequest(finalUrl, method, signedHeadersString, body, tolerated);
375
350
  }
376
351
 
377
- /**
378
- * Gets the current configuration properties of the S3 instance.
379
- * @returns {IT.S3Config} The current S3 configuration object containing all settings.
380
- * @example
381
- * const config = s3.getProps();
382
- * console.log(config.endpoint); // 'https://s3.amazonaws.com/my-bucket'
383
- */
384
- public getProps(): IT.S3Config {
385
- return {
386
- accessKeyId: this.accessKeyId,
387
- secretAccessKey: this.secretAccessKey,
388
- endpoint: this.endpoint,
389
- region: this.region,
390
- requestSizeInBytes: this.requestSizeInBytes,
391
- requestAbortTimeout: this.requestAbortTimeout,
392
- logger: this.logger,
393
- };
394
- }
395
-
396
- /**
397
- * Updates the configuration properties of the S3 instance.
398
- * @param {IT.S3Config} props - The new configuration object.
399
- * @param {string} props.accessKeyId - The access key ID for authentication.
400
- * @param {string} props.secretAccessKey - The secret access key for authentication.
401
- * @param {string} props.endpoint - The endpoint URL of the S3-compatible service.
402
- * @param {string} [props.region='auto'] - The region of the S3 service.
403
- * @param {number} [props.requestSizeInBytes=8388608] - The request size of a single request in bytes.
404
- * @param {number} [props.requestAbortTimeout] - The timeout in milliseconds after which a request should be aborted.
405
- * @param {IT.Logger} [props.logger] - A logger object with methods like info, warn, error.
406
- * @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type.
407
- * @example
408
- * s3.setProps({
409
- * accessKeyId: 'new-access-key',
410
- * secretAccessKey: 'new-secret-key',
411
- * endpoint: 'https://new-endpoint.com/my-bucket',
412
- * region: 'us-west-2' // by default is auto
413
- * });
414
- */
415
- public setProps(props: IT.S3Config): void {
416
- this._validateConstructorParams(props.accessKeyId, props.secretAccessKey, props.endpoint);
417
- this.accessKeyId = props.accessKeyId;
418
- this.secretAccessKey = props.secretAccessKey;
419
- this.region = props.region || 'auto';
420
- this.endpoint = props.endpoint;
421
- this.requestSizeInBytes = props.requestSizeInBytes || C.DEFAULT_REQUEST_SIZE_IN_BYTES;
422
- this.requestAbortTimeout = props.requestAbortTimeout;
423
- this.logger = props.logger;
424
- }
425
-
426
352
  /**
427
353
  * Sanitizes an ETag value by removing surrounding quotes and whitespace.
428
354
  * Still returns RFC compliant ETag. https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3
@@ -448,7 +374,7 @@ class s3mini {
448
374
  `;
449
375
  const headers = {
450
376
  [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
451
- [C.HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(),
377
+ [C.HEADER_CONTENT_LENGTH]: U.getByteSize(xmlBody),
452
378
  };
453
379
  const res = await this._signedRequest('PUT', '', {
454
380
  body: xmlBody,
@@ -458,6 +384,42 @@ class s3mini {
458
384
  return res.status === 200;
459
385
  }
460
386
 
387
+ private _extractBucketName(): string {
388
+ const url = this.endpoint;
389
+
390
+ // First check if bucket is in the pathname (path-style URLs)
391
+ const pathSegments = url.pathname.split('/').filter(p => p);
392
+ if (pathSegments.length > 0) {
393
+ if (typeof pathSegments[0] === 'string') {
394
+ return pathSegments[0];
395
+ }
396
+ }
397
+
398
+ // Otherwise extract from subdomain (virtual-hosted-style URLs)
399
+ const hostParts = url.hostname.split('.');
400
+
401
+ // Common patterns:
402
+ // bucket-name.s3.amazonaws.com
403
+ // bucket-name.s3.region.amazonaws.com
404
+ // bucket-name.region.digitaloceanspaces.com
405
+ // bucket-name.region.cdn.digitaloceanspaces.com
406
+
407
+ if (hostParts.length >= 3) {
408
+ // Check if it's a known S3-compatible service
409
+ const domain = hostParts.slice(-2).join('.');
410
+ const knownDomains = ['amazonaws.com', 'digitaloceanspaces.com', 'cloudflare.com'];
411
+
412
+ if (knownDomains.some(d => domain.includes(d))) {
413
+ if (typeof hostParts[0] === 'string') {
414
+ return hostParts[0];
415
+ }
416
+ }
417
+ }
418
+
419
+ // Fallback: use the first subdomain
420
+ return hostParts[0] || '';
421
+ }
422
+
461
423
  /**
462
424
  * Checks if a bucket exists.
463
425
  * This method sends a request to check if the specified bucket exists in the S3-compatible service.
@@ -475,7 +437,7 @@ class s3mini {
475
437
  * @param {string} [prefix=''] - The prefix to filter objects by.
476
438
  * @param {number} [maxKeys] - The maximum number of keys to return. If not provided, all keys will be returned.
477
439
  * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
478
- * @returns {Promise<object[] | null>} A promise that resolves to an array of objects or null if the bucket is empty.
440
+ * @returns {Promise<IT.ListObject[] | null>} A promise that resolves to an array of objects or null if the bucket is empty.
479
441
  * @example
480
442
  * // List all objects
481
443
  * const objects = await s3.listObjects();
@@ -487,77 +449,138 @@ class s3mini {
487
449
  delimiter: string = '/',
488
450
  prefix: string = '',
489
451
  maxKeys?: number,
490
- // method: IT.HttpMethod = 'GET', // 'GET' or 'HEAD'
491
452
  opts: Record<string, unknown> = {},
492
- ): Promise<object[] | null> {
453
+ ): Promise<IT.ListObject[] | null> {
493
454
  this._checkDelimiter(delimiter);
494
455
  this._checkPrefix(prefix);
495
456
  this._checkOpts(opts);
496
457
 
497
458
  const keyPath = delimiter === '/' ? delimiter : U.uriEscape(delimiter);
498
-
499
459
  const unlimited = !(maxKeys && maxKeys > 0);
500
460
  let remaining = unlimited ? Infinity : maxKeys;
501
461
  let token: string | undefined;
502
- const all: object[] = [];
462
+ const all: IT.ListObject[] = [];
503
463
 
504
464
  do {
505
- const batchSize = Math.min(remaining, 1000); // S3 ceiling
506
- const query: Record<string, unknown> = {
507
- 'list-type': C.LIST_TYPE, // =2 for V2
508
- 'max-keys': String(batchSize),
509
- ...(prefix ? { prefix } : {}),
510
- ...(token ? { 'continuation-token': token } : {}),
511
- ...opts,
512
- };
513
-
514
- const res = await this._signedRequest('GET', keyPath, {
515
- query,
516
- withQuery: true,
517
- tolerated: [200, 404],
518
- });
465
+ const batchResult = await this._fetchObjectBatch(keyPath, prefix, remaining, token, opts);
519
466
 
520
- if (res.status === 404) {
521
- return null;
522
- }
523
- if (res.status !== 200) {
524
- const errorBody = await res.text();
525
- const errorCode = res.headers.get('x-amz-error-code') || 'Unknown';
526
- const errorMessage = res.headers.get('x-amz-error-message') || res.statusText;
527
- this._log(
528
- 'error',
529
- `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
530
- );
531
- throw new Error(
532
- `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
533
- );
467
+ if (batchResult === null) {
468
+ return null; // 404 - bucket not found
534
469
  }
535
- const raw = U.parseXml(await res.text()) as Record<string, unknown>;
536
- if (typeof raw !== 'object' || !raw || 'error' in raw) {
537
- this._log('error', `${C.ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`);
538
- throw new Error(`${C.ERROR_PREFIX}Unexpected listObjects response shape`);
539
- }
540
- const out = (raw.ListBucketResult || raw.listBucketResult || raw) as Record<string, unknown>;
541
- /* accumulate Contents */
542
- const contents = out.Contents || out.contents; // S3 v2 vs v1
543
- if (contents) {
544
- const batch = Array.isArray(contents) ? contents : [contents];
545
- all.push(...(batch as object[]));
546
- if (!unlimited) {
547
- remaining -= batch.length;
548
- }
470
+
471
+ all.push(...batchResult.objects);
472
+
473
+ if (!unlimited) {
474
+ remaining -= batchResult.objects.length;
549
475
  }
550
- const truncated = out.IsTruncated === 'true' || out.isTruncated === 'true' || false;
551
- token = truncated
552
- ? ((out.NextContinuationToken || out.nextContinuationToken || out.NextMarker || out.nextMarker) as
553
- | string
554
- | undefined)
555
- : undefined;
476
+
477
+ token = batchResult.continuationToken;
556
478
  } while (token && remaining > 0);
557
479
 
558
480
  return all;
559
481
  }
560
482
 
483
+ private async _fetchObjectBatch(
484
+ keyPath: string,
485
+ prefix: string,
486
+ remaining: number,
487
+ token: string | undefined,
488
+ opts: Record<string, unknown>,
489
+ ): Promise<{ objects: IT.ListObject[]; continuationToken?: string } | null> {
490
+ const query = this._buildListObjectsQuery(prefix, remaining, token, opts);
491
+
492
+ const res = await this._signedRequest('GET', keyPath, {
493
+ query,
494
+ withQuery: true,
495
+ tolerated: [200, 404],
496
+ });
497
+
498
+ if (res.status === 404) {
499
+ return null;
500
+ }
501
+
502
+ if (res.status !== 200) {
503
+ await this._handleListObjectsError(res);
504
+ }
505
+
506
+ const xmlText = await res.text();
507
+ return this._parseListObjectsResponse(xmlText);
508
+ }
509
+
510
+ private _buildListObjectsQuery(
511
+ prefix: string,
512
+ remaining: number,
513
+ token: string | undefined,
514
+ opts: Record<string, unknown>,
515
+ ): Record<string, unknown> {
516
+ const batchSize = Math.min(remaining, 1000); // S3 ceiling
517
+
518
+ return {
519
+ 'list-type': C.LIST_TYPE, // =2 for V2
520
+ 'max-keys': String(batchSize),
521
+ ...(prefix ? { prefix } : {}),
522
+ ...(token ? { 'continuation-token': token } : {}),
523
+ ...opts,
524
+ };
525
+ }
526
+
527
+ private async _handleListObjectsError(res: Response): Promise<never> {
528
+ const errorBody = await res.text();
529
+ const parsedErrorBody = this._parseErrorXml(res.headers, errorBody);
530
+ const errorCode = res.headers.get('x-amz-error-code') ?? parsedErrorBody.svcCode ?? 'Unknown';
531
+ const errorMessage = res.headers.get('x-amz-error-message') ?? parsedErrorBody.errorMessage ?? res.statusText;
532
+
533
+ this._log(
534
+ 'error',
535
+ `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
536
+ );
537
+
538
+ throw new Error(
539
+ `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
540
+ );
541
+ }
542
+
543
+ private _parseListObjectsResponse(xmlText: string): {
544
+ objects: IT.ListObject[];
545
+ continuationToken?: string;
546
+ } {
547
+ const raw = U.parseXml(xmlText) as Record<string, unknown>;
548
+
549
+ if (typeof raw !== 'object' || !raw || 'error' in raw) {
550
+ this._log('error', `${C.ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`);
551
+ throw new Error(`${C.ERROR_PREFIX}Unexpected listObjects response shape`);
552
+ }
553
+
554
+ const out = (raw.ListBucketResult || raw.listBucketResult || raw) as Record<string, unknown>;
555
+ const objects = this._extractObjectsFromResponse(out);
556
+ const continuationToken = this._extractContinuationToken(out);
557
+
558
+ return { objects, continuationToken };
559
+ }
560
+
561
+ private _extractObjectsFromResponse(response: Record<string, unknown>): IT.ListObject[] {
562
+ const contents = response.Contents || response.contents; // S3 v2 vs v1
563
+
564
+ if (!contents) {
565
+ return [];
566
+ }
567
+
568
+ return Array.isArray(contents) ? (contents as IT.ListObject[]) : [contents as IT.ListObject];
569
+ }
570
+
571
+ private _extractContinuationToken(response: Record<string, unknown>): string | undefined {
572
+ const truncated = response.IsTruncated === 'true' || response.isTruncated === 'true' || false;
573
+
574
+ if (!truncated) {
575
+ return undefined;
576
+ }
577
+
578
+ return (response.NextContinuationToken ||
579
+ response.nextContinuationToken ||
580
+ response.NextMarker ||
581
+ response.nextMarker) as string | undefined;
582
+ }
583
+
561
584
  /**
562
585
  * Lists multipart uploads in the bucket.
563
586
  * This method sends a request to list multipart uploads in the specified bucket.
@@ -607,11 +630,21 @@ class s3mini {
607
630
  * Get an object from the S3-compatible service.
608
631
  * This method sends a request to retrieve the specified object from the S3-compatible service.
609
632
  * @param {string} key - The key of the object to retrieve.
610
- * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
633
+ * @param {Record<string, unknown>} [opts] - Additional options for the request.
634
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
611
635
  * @returns A promise that resolves to the object data (string) or null if not found.
612
636
  */
613
- public async getObject(key: string, opts: Record<string, unknown> = {}): Promise<string | null> {
614
- const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
637
+ public async getObject(
638
+ key: string,
639
+ opts: Record<string, unknown> = {},
640
+ ssecHeaders?: IT.SSECHeaders,
641
+ ): Promise<string | null> {
642
+ // if ssecHeaders is set, add it to headers
643
+ const res = await this._signedRequest('GET', key, {
644
+ query: opts, // use opts.query if it exists, otherwise use an empty object
645
+ tolerated: [200, 404, 412, 304],
646
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
647
+ });
615
648
  if ([404, 412, 304].includes(res.status)) {
616
649
  return null;
617
650
  }
@@ -623,10 +656,19 @@ class s3mini {
623
656
  * This method sends a request to retrieve the specified object and returns the full response.
624
657
  * @param {string} key - The key of the object to retrieve.
625
658
  * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
659
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
626
660
  * @returns A promise that resolves to the Response object or null if not found.
627
661
  */
628
- public async getObjectResponse(key: string, opts: Record<string, unknown> = {}): Promise<Response | null> {
629
- const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
662
+ public async getObjectResponse(
663
+ key: string,
664
+ opts: Record<string, unknown> = {},
665
+ ssecHeaders?: IT.SSECHeaders,
666
+ ): Promise<Response | null> {
667
+ const res = await this._signedRequest('GET', key, {
668
+ query: opts,
669
+ tolerated: [200, 404, 412, 304],
670
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
671
+ });
630
672
  if ([404, 412, 304].includes(res.status)) {
631
673
  return null;
632
674
  }
@@ -638,10 +680,19 @@ class s3mini {
638
680
  * This method sends a request to retrieve the specified object and returns it as an ArrayBuffer.
639
681
  * @param {string} key - The key of the object to retrieve.
640
682
  * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
683
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
641
684
  * @returns A promise that resolves to the object data as an ArrayBuffer or null if not found.
642
685
  */
643
- public async getObjectArrayBuffer(key: string, opts: Record<string, unknown> = {}): Promise<ArrayBuffer | null> {
644
- const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
686
+ public async getObjectArrayBuffer(
687
+ key: string,
688
+ opts: Record<string, unknown> = {},
689
+ ssecHeaders?: IT.SSECHeaders,
690
+ ): Promise<ArrayBuffer | null> {
691
+ const res = await this._signedRequest('GET', key, {
692
+ query: opts,
693
+ tolerated: [200, 404, 412, 304],
694
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
695
+ });
645
696
  if ([404, 412, 304].includes(res.status)) {
646
697
  return null;
647
698
  }
@@ -653,10 +704,19 @@ class s3mini {
653
704
  * This method sends a request to retrieve the specified object and returns it as JSON.
654
705
  * @param {string} key - The key of the object to retrieve.
655
706
  * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
707
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
656
708
  * @returns A promise that resolves to the object data as JSON or null if not found.
657
709
  */
658
- public async getObjectJSON<T = unknown>(key: string, opts: Record<string, unknown> = {}): Promise<T | null> {
659
- const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
710
+ public async getObjectJSON<T = unknown>(
711
+ key: string,
712
+ opts: Record<string, unknown> = {},
713
+ ssecHeaders?: IT.SSECHeaders,
714
+ ): Promise<T | null> {
715
+ const res = await this._signedRequest('GET', key, {
716
+ query: opts,
717
+ tolerated: [200, 404, 412, 304],
718
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
719
+ });
660
720
  if ([404, 412, 304].includes(res.status)) {
661
721
  return null;
662
722
  }
@@ -668,14 +728,20 @@ class s3mini {
668
728
  * This method sends a request to retrieve the specified object and its ETag.
669
729
  * @param {string} key - The key of the object to retrieve.
670
730
  * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
731
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
671
732
  * @returns A promise that resolves to an object containing the ETag and the object data as an ArrayBuffer or null if not found.
672
733
  */
673
734
  public async getObjectWithETag(
674
735
  key: string,
675
736
  opts: Record<string, unknown> = {},
737
+ ssecHeaders?: IT.SSECHeaders,
676
738
  ): Promise<{ etag: string | null; data: ArrayBuffer | null }> {
677
739
  try {
678
- const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
740
+ const res = await this._signedRequest('GET', key, {
741
+ query: opts,
742
+ tolerated: [200, 404, 412, 304],
743
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
744
+ });
679
745
 
680
746
  if ([404, 412, 304].includes(res.status)) {
681
747
  return { etag: null, data: null };
@@ -700,6 +766,7 @@ class s3mini {
700
766
  * @param {number} [rangeFrom=0] - The starting byte for the range (if not whole file).
701
767
  * @param {number} [rangeTo=this.requestSizeInBytes] - The ending byte for the range (if not whole file).
702
768
  * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
769
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
703
770
  * @returns A promise that resolves to the Response object.
704
771
  */
705
772
  public async getObjectRaw(
@@ -708,12 +775,13 @@ class s3mini {
708
775
  rangeFrom = 0,
709
776
  rangeTo = this.requestSizeInBytes,
710
777
  opts: Record<string, unknown> = {},
778
+ ssecHeaders?: IT.SSECHeaders,
711
779
  ): Promise<Response> {
712
780
  const rangeHdr: Record<string, string | number> = wholeFile ? {} : { range: `bytes=${rangeFrom}-${rangeTo - 1}` };
713
781
 
714
782
  return this._signedRequest('GET', key, {
715
783
  query: { ...opts },
716
- headers: rangeHdr,
784
+ headers: { ...rangeHdr, ...ssecHeaders },
717
785
  withQuery: true, // keep ?query=string behaviour
718
786
  });
719
787
  }
@@ -725,9 +793,11 @@ class s3mini {
725
793
  * @returns A promise that resolves to the content length of the object in bytes, or 0 if not found.
726
794
  * @throws {Error} If the content length header is not found in the response.
727
795
  */
728
- public async getContentLength(key: string): Promise<number> {
796
+ public async getContentLength(key: string, ssecHeaders?: IT.SSECHeaders): Promise<number> {
729
797
  try {
730
- const res = await this._signedRequest('HEAD', key);
798
+ const res = await this._signedRequest('HEAD', key, {
799
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
800
+ });
731
801
  const len = res.headers.get(C.HEADER_CONTENT_LENGTH);
732
802
  return len ? +len : 0;
733
803
  } catch (err) {
@@ -762,6 +832,7 @@ class s3mini {
762
832
  * Retrieves the ETag of an object without downloading its content.
763
833
  * @param {string} key - The key of the object to retrieve the ETag for.
764
834
  * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
835
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
765
836
  * @returns {Promise<string | null>} A promise that resolves to the ETag value or null if the object is not found.
766
837
  * @throws {Error} If the ETag header is not found in the response.
767
838
  * @example
@@ -770,16 +841,25 @@ class s3mini {
770
841
  * console.log(`File ETag: ${etag}`);
771
842
  * }
772
843
  */
773
- public async getEtag(key: string, opts: Record<string, unknown> = {}): Promise<string | null> {
844
+ public async getEtag(
845
+ key: string,
846
+ opts: Record<string, unknown> = {},
847
+ ssecHeaders?: IT.SSECHeaders,
848
+ ): Promise<string | null> {
774
849
  const res = await this._signedRequest('HEAD', key, {
775
850
  query: opts,
776
- tolerated: [200, 404],
851
+ tolerated: [200, 304, 404, 412],
852
+ headers: ssecHeaders ? { ...ssecHeaders } : undefined,
777
853
  });
778
854
 
779
855
  if (res.status === 404) {
780
856
  return null;
781
857
  }
782
858
 
859
+ if (res.status === 412 || res.status === 304) {
860
+ return null; // ETag mismatch
861
+ }
862
+
783
863
  const etag = res.headers.get(C.HEADER_ETAG);
784
864
  if (!etag) {
785
865
  throw new Error(`${C.ERROR_PREFIX}ETag not found in response headers`);
@@ -793,6 +873,8 @@ class s3mini {
793
873
  * @param {string} key - The key/path where the object will be stored.
794
874
  * @param {string | Buffer} data - The data to upload (string or Buffer).
795
875
  * @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
876
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
877
+ * @param {IT.AWSHeaders} [additionalHeaders] - Additional x-amz-* headers specific to this request, if any.
796
878
  * @returns {Promise<Response>} A promise that resolves to the Response object from the upload request.
797
879
  * @throws {TypeError} If data is not a string or Buffer.
798
880
  * @example
@@ -805,17 +887,18 @@ class s3mini {
805
887
  */
806
888
  public async putObject(
807
889
  key: string,
808
- data: string | Buffer,
890
+ data: string | IT.MaybeBuffer,
809
891
  fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
892
+ ssecHeaders?: IT.SSECHeaders,
893
+ additionalHeaders?: IT.AWSHeaders,
810
894
  ): Promise<Response> {
811
- if (!(data instanceof Buffer || typeof data === 'string')) {
812
- throw new TypeError(C.ERROR_DATA_BUFFER_REQUIRED);
813
- }
814
895
  return this._signedRequest('PUT', key, {
815
- body: data,
896
+ body: this._validateData(data),
816
897
  headers: {
817
- [C.HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length,
898
+ [C.HEADER_CONTENT_LENGTH]: U.getByteSize(data),
818
899
  [C.HEADER_CONTENT_TYPE]: fileType,
900
+ ...additionalHeaders,
901
+ ...ssecHeaders,
819
902
  },
820
903
  tolerated: [200],
821
904
  });
@@ -825,6 +908,7 @@ class s3mini {
825
908
  * Initiates a multipart upload and returns the upload ID.
826
909
  * @param {string} key - The key/path where the object will be stored.
827
910
  * @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
911
+ * @param {IT.SSECHeaders?} [ssecHeaders] - Server-Side Encryption headers, if any.
828
912
  * @returns {Promise<string>} A promise that resolves to the upload ID for the multipart upload.
829
913
  * @throws {TypeError} If key is invalid or fileType is not a string.
830
914
  * @throws {Error} If the multipart upload fails to initialize.
@@ -832,13 +916,17 @@ class s3mini {
832
916
  * const uploadId = await s3.getMultipartUploadId('large-file.zip', 'application/zip');
833
917
  * console.log(`Started multipart upload: ${uploadId}`);
834
918
  */
835
- public async getMultipartUploadId(key: string, fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE): Promise<string> {
919
+ public async getMultipartUploadId(
920
+ key: string,
921
+ fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
922
+ ssecHeaders?: IT.SSECHeaders,
923
+ ): Promise<string> {
836
924
  this._checkKey(key);
837
925
  if (typeof fileType !== 'string') {
838
926
  throw new TypeError(`${C.ERROR_PREFIX}fileType must be a string`);
839
927
  }
840
928
  const query = { uploads: '' };
841
- const headers = { [C.HEADER_CONTENT_TYPE]: fileType };
929
+ const headers = { [C.HEADER_CONTENT_TYPE]: fileType, ...ssecHeaders };
842
930
 
843
931
  const res = await this._signedRequest('POST', key, {
844
932
  query,
@@ -847,16 +935,6 @@ class s3mini {
847
935
  });
848
936
  const parsed = U.parseXml(await res.text()) as Record<string, unknown>;
849
937
 
850
- // if (
851
- // parsed &&
852
- // typeof parsed === 'object' &&
853
- // 'initiateMultipartUploadResult' in parsed &&
854
- // parsed.initiateMultipartUploadResult &&
855
- // 'uploadId' in (parsed.initiateMultipartUploadResult as { uploadId: string })
856
- // ) {
857
- // return (parsed.initiateMultipartUploadResult as { uploadId: string }).uploadId;
858
- // }
859
-
860
938
  if (parsed && typeof parsed === 'object') {
861
939
  // Check for both cases of InitiateMultipartUploadResult
862
940
  const uploadResult =
@@ -883,6 +961,7 @@ class s3mini {
883
961
  * @param {Buffer | string} data - The data for this part.
884
962
  * @param {number} partNumber - The part number (must be between 1 and 10,000).
885
963
  * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
964
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
886
965
  * @returns {Promise<IT.UploadPart>} A promise that resolves to an object containing the partNumber and etag.
887
966
  * @throws {TypeError} If any parameter is invalid.
888
967
  * @example
@@ -897,17 +976,21 @@ class s3mini {
897
976
  public async uploadPart(
898
977
  key: string,
899
978
  uploadId: string,
900
- data: Buffer | string,
979
+ data: IT.MaybeBuffer | string,
901
980
  partNumber: number,
902
981
  opts: Record<string, unknown> = {},
982
+ ssecHeaders?: IT.SSECHeaders,
903
983
  ): Promise<IT.UploadPart> {
904
- this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
984
+ const body = this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
905
985
 
906
986
  const query = { uploadId, partNumber, ...opts };
907
987
  const res = await this._signedRequest('PUT', key, {
908
988
  query,
909
- body: data,
910
- headers: { [C.HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length },
989
+ body,
990
+ headers: {
991
+ [C.HEADER_CONTENT_LENGTH]: U.getByteSize(data),
992
+ ...ssecHeaders,
993
+ },
911
994
  });
912
995
 
913
996
  return { partNumber, etag: U.sanitizeETag(res.headers.get('etag') || '') };
@@ -940,7 +1023,7 @@ class s3mini {
940
1023
  const xmlBody = this._buildCompleteMultipartUploadXml(parts);
941
1024
  const headers = {
942
1025
  [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
943
- [C.HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(),
1026
+ [C.HEADER_CONTENT_LENGTH]: U.getByteSize(xmlBody),
944
1027
  };
945
1028
 
946
1029
  const res = await this._signedRequest('POST', key, {
@@ -963,7 +1046,7 @@ class s3mini {
963
1046
  if (etag && typeof etag === 'string') {
964
1047
  return {
965
1048
  ...resultObj,
966
- etag: this.sanitizeETag(etag),
1049
+ etag: U.sanitizeETag(etag),
967
1050
  } as IT.CompleteMultipartUploadResult;
968
1051
  }
969
1052
 
@@ -978,6 +1061,7 @@ class s3mini {
978
1061
  * Aborts a multipart upload and removes all uploaded parts.
979
1062
  * @param {string} key - The key of the object being uploaded.
980
1063
  * @param {string} uploadId - The upload ID to abort.
1064
+ * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
981
1065
  * @returns {Promise<object>} A promise that resolves to an object containing the abort status and details.
982
1066
  * @throws {TypeError} If key or uploadId is invalid.
983
1067
  * @throws {Error} If the abort operation fails.
@@ -989,14 +1073,14 @@ class s3mini {
989
1073
  * console.error('Failed to abort upload:', error);
990
1074
  * }
991
1075
  */
992
- public async abortMultipartUpload(key: string, uploadId: string): Promise<object> {
1076
+ public async abortMultipartUpload(key: string, uploadId: string, ssecHeaders?: IT.SSECHeaders): Promise<object> {
993
1077
  this._checkKey(key);
994
1078
  if (!uploadId) {
995
1079
  throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED);
996
1080
  }
997
1081
 
998
1082
  const query = { uploadId };
999
- const headers = { [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE };
1083
+ const headers = { [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE, ...(ssecHeaders ? { ...ssecHeaders } : {}) };
1000
1084
 
1001
1085
  const res = await this._signedRequest('DELETE', key, {
1002
1086
  query,
@@ -1018,20 +1102,228 @@ class s3mini {
1018
1102
  }
1019
1103
 
1020
1104
  private _buildCompleteMultipartUploadXml(parts: Array<IT.UploadPart>): string {
1021
- return `
1022
- <CompleteMultipartUpload>
1023
- ${parts
1024
- .map(
1025
- part => `
1026
- <Part>
1027
- <PartNumber>${part.partNumber}</PartNumber>
1028
- <ETag>${part.etag}</ETag>
1029
- </Part>
1030
- `,
1031
- )
1032
- .join('')}
1033
- </CompleteMultipartUpload>
1034
- `;
1105
+ let xml = '<CompleteMultipartUpload>';
1106
+ for (const part of parts) {
1107
+ xml += `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${part.etag}</ETag></Part>`;
1108
+ }
1109
+ xml += '</CompleteMultipartUpload>';
1110
+ return xml;
1111
+ }
1112
+
1113
+ /**
1114
+ * Executes the copy operation for local copying (same bucket/endpoint).
1115
+ * @private
1116
+ */
1117
+ private async _executeCopyOperation(
1118
+ destinationKey: string,
1119
+ copySource: string,
1120
+ options: IT.CopyObjectOptions,
1121
+ ): Promise<IT.CopyObjectResult> {
1122
+ const {
1123
+ metadataDirective = 'COPY',
1124
+ metadata = {},
1125
+ contentType,
1126
+ storageClass,
1127
+ taggingDirective,
1128
+ websiteRedirectLocation,
1129
+ sourceSSECHeaders = {},
1130
+ destinationSSECHeaders = {},
1131
+ additionalHeaders = {},
1132
+ } = options;
1133
+
1134
+ const headers: Record<string, string | number> = {
1135
+ 'x-amz-copy-source': copySource,
1136
+ 'x-amz-metadata-directive': metadataDirective,
1137
+ ...additionalHeaders,
1138
+ ...(contentType && { [C.HEADER_CONTENT_TYPE]: contentType }),
1139
+ ...(storageClass && { 'x-amz-storage-class': storageClass }),
1140
+ ...(taggingDirective && { 'x-amz-tagging-directive': taggingDirective }),
1141
+ ...(websiteRedirectLocation && { 'x-amz-website-redirect-location': websiteRedirectLocation }),
1142
+ ...this._buildSSECHeaders(sourceSSECHeaders, destinationSSECHeaders),
1143
+ ...(metadataDirective === 'REPLACE' ? this._buildMetadataHeaders(metadata) : {}),
1144
+ };
1145
+
1146
+ try {
1147
+ const res = await this._signedRequest('PUT', destinationKey, {
1148
+ headers,
1149
+ tolerated: [200],
1150
+ });
1151
+ return this._parseCopyObjectResponse(await res.text());
1152
+ } catch (err) {
1153
+ this._log('error', `Error in copy operation to ${destinationKey}`, {
1154
+ error: String(err),
1155
+ });
1156
+ throw err;
1157
+ }
1158
+ }
1159
+
1160
+ /**
1161
+ * Copies an object within the same bucket.
1162
+ *
1163
+ * @param {string} sourceKey - The key of the source object to copy
1164
+ * @param {string} destinationKey - The key where the object will be copied to
1165
+ * @param {IT.CopyObjectOptions} [options={}] - Copy operation options
1166
+ * @param {string} [options.metadataDirective='COPY'] - How to handle metadata ('COPY' | 'REPLACE')
1167
+ * @param {Record<string,string>} [options.metadata={}] - New metadata (only used if metadataDirective='REPLACE')
1168
+ * @param {string} [options.contentType] - New content type for the destination object
1169
+ * @param {string} [options.storageClass] - Storage class for the destination object
1170
+ * @param {string} [options.taggingDirective] - How to handle object tags ('COPY' | 'REPLACE')
1171
+ * @param {string} [options.websiteRedirectLocation] - Website redirect location for the destination
1172
+ * @param {IT.SSECHeaders} [options.sourceSSECHeaders={}] - Encryption headers for reading source (if encrypted)
1173
+ * @param {IT.SSECHeaders} [options.destinationSSECHeaders={}] - Encryption headers for destination
1174
+ * @param {IT.AWSHeaders} [options.additionalHeaders={}] - Extra x-amz-* headers
1175
+ *
1176
+ * @returns {Promise<IT.CopyObjectResult>} Copy result with etag and lastModified date
1177
+ * @throws {TypeError} If sourceKey or destinationKey is invalid
1178
+ * @throws {Error} If copy operation fails or S3 returns an error
1179
+ *
1180
+ * @example
1181
+ * // Simple copy
1182
+ * const result = await s3.copyObject('report-2024.pdf', 'archive/report-2024.pdf');
1183
+ * console.log(`Copied with ETag: ${result.etag}`);
1184
+ *
1185
+ * @example
1186
+ * // Copy with new metadata and content type
1187
+ * const result = await s3.copyObject('data.csv', 'processed/data.csv', {
1188
+ * metadataDirective: 'REPLACE',
1189
+ * metadata: {
1190
+ * 'processed-date': new Date().toISOString(),
1191
+ * 'original-name': 'data.csv'
1192
+ * },
1193
+ * contentType: 'text/csv; charset=utf-8'
1194
+ * });
1195
+ *
1196
+ * @example
1197
+ * // Copy encrypted object (Cloudflare R2 SSE-C)
1198
+ * const ssecKey = 'n1TKiTaVHlYLMX9n0zHXyooMr026vOiTEFfT+719Hho=';
1199
+ * await s3.copyObject('sensitive.json', 'backup/sensitive.json', {
1200
+ * sourceSSECHeaders: {
1201
+ * 'x-amz-copy-source-server-side-encryption-customer-algorithm': 'AES256',
1202
+ * 'x-amz-copy-source-server-side-encryption-customer-key': ssecKey,
1203
+ * 'x-amz-copy-source-server-side-encryption-customer-key-md5': 'gepZmzgR7Be/1+K1Aw+6ow=='
1204
+ * },
1205
+ * destinationSSECHeaders: {
1206
+ * 'x-amz-server-side-encryption-customer-algorithm': 'AES256',
1207
+ * 'x-amz-server-side-encryption-customer-key': ssecKey,
1208
+ * 'x-amz-server-side-encryption-customer-key-md5': 'gepZmzgR7Be/1+K1Aw+6ow=='
1209
+ * }
1210
+ * });
1211
+ */
1212
+ public copyObject(
1213
+ sourceKey: string,
1214
+ destinationKey: string,
1215
+ options: IT.CopyObjectOptions = {},
1216
+ ): Promise<IT.CopyObjectResult> {
1217
+ // Validate parameters
1218
+ this._checkKey(sourceKey);
1219
+ this._checkKey(destinationKey);
1220
+
1221
+ const copySource = `/${this.bucketName}/${U.uriEscape(sourceKey)}`;
1222
+
1223
+ return this._executeCopyOperation(destinationKey, copySource, options);
1224
+ }
1225
+
1226
+ private _buildSSECHeaders(
1227
+ sourceHeaders: Record<string, string | number>,
1228
+ destHeaders: Record<string, string | number>,
1229
+ ): Record<string, string | number> {
1230
+ const headers: Record<string, string | number> = {};
1231
+ Object.entries({ ...sourceHeaders, ...destHeaders }).forEach(([k, v]) => {
1232
+ if (v !== undefined) {
1233
+ headers[k] = v;
1234
+ }
1235
+ });
1236
+ return headers;
1237
+ }
1238
+
1239
+ /**
1240
+ * Moves an object within the same bucket (copy + delete atomic-like operation).
1241
+ *
1242
+ * WARNING: Not truly atomic - if delete fails after successful copy, the object
1243
+ * will exist in both locations. Consider your use case carefully.
1244
+ *
1245
+ * @param {string} sourceKey - The key of the source object to move
1246
+ * @param {string} destinationKey - The key where the object will be moved to
1247
+ * @param {IT.CopyObjectOptions} [options={}] - Options passed to the copy operation
1248
+ *
1249
+ * @returns {Promise<IT.CopyObjectResult>} Result from the copy operation
1250
+ * @throws {TypeError} If sourceKey or destinationKey is invalid
1251
+ * @throws {Error} If copy succeeds but delete fails (includes copy result in error)
1252
+ *
1253
+ * @example
1254
+ * // Simple move
1255
+ * await s3.moveObject('temp/upload.tmp', 'files/document.pdf');
1256
+ *
1257
+ * @example
1258
+ * // Move with metadata update
1259
+ * await s3.moveObject('unprocessed/image.jpg', 'processed/image.jpg', {
1260
+ * metadataDirective: 'REPLACE',
1261
+ * metadata: {
1262
+ * 'status': 'processed',
1263
+ * 'processed-at': Date.now().toString()
1264
+ * },
1265
+ * contentType: 'image/jpeg'
1266
+ * });
1267
+ *
1268
+ * @example
1269
+ * // Safe move with error handling
1270
+ * try {
1271
+ * const result = await s3.moveObject('inbox/file.dat', 'archive/file.dat');
1272
+ * console.log(`Moved successfully: ${result.etag}`);
1273
+ * } catch (error) {
1274
+ * // Check if copy succeeded but delete failed
1275
+ * if (error.message.includes('delete source object after successful copy')) {
1276
+ * console.warn('File copied but not deleted from source - manual cleanup needed');
1277
+ * }
1278
+ * }
1279
+ */
1280
+ public async moveObject(
1281
+ sourceKey: string,
1282
+ destinationKey: string,
1283
+ options: IT.CopyObjectOptions = {},
1284
+ ): Promise<IT.CopyObjectResult> {
1285
+ try {
1286
+ // First copy the object
1287
+ const copyResult = await this.copyObject(sourceKey, destinationKey, options);
1288
+
1289
+ // Then delete the source
1290
+ const deleteSuccess = await this.deleteObject(sourceKey);
1291
+ if (!deleteSuccess) {
1292
+ throw new Error(`${C.ERROR_PREFIX}Failed to delete source object after successful copy`);
1293
+ }
1294
+
1295
+ return copyResult;
1296
+ } catch (err) {
1297
+ this._log('error', `Error moving object from ${sourceKey} to ${destinationKey}`, {
1298
+ error: String(err),
1299
+ });
1300
+ throw err;
1301
+ }
1302
+ }
1303
+
1304
+ private _buildMetadataHeaders(metadata: Record<string, string>): Record<string, string> {
1305
+ const headers: Record<string, string> = {};
1306
+ Object.entries(metadata).forEach(([k, v]) => {
1307
+ headers[k.startsWith('x-amz-meta-') ? k : `x-amz-meta-${k}`] = v;
1308
+ });
1309
+ return headers;
1310
+ }
1311
+
1312
+ private _parseCopyObjectResponse(xmlText: string): IT.CopyObjectResult {
1313
+ const parsed = U.parseXml(xmlText) as Record<string, unknown>;
1314
+ if (!parsed || typeof parsed !== 'object') {
1315
+ throw new Error(`${C.ERROR_PREFIX}Unexpected copyObject response format`);
1316
+ }
1317
+ const result = (parsed.CopyObjectResult || parsed.copyObjectResult || parsed) as Record<string, unknown>;
1318
+ const etag = result.ETag || result.eTag || result.etag;
1319
+ const lastModified = result.LastModified || result.lastModified;
1320
+ if (!etag || typeof etag !== 'string') {
1321
+ throw new Error(`${C.ERROR_PREFIX}ETag not found in copyObject response`);
1322
+ }
1323
+ return {
1324
+ etag: U.sanitizeETag(etag),
1325
+ lastModified: lastModified ? new Date(lastModified as string) : undefined,
1326
+ };
1035
1327
  }
1036
1328
 
1037
1329
  /**
@@ -1046,13 +1338,14 @@ class s3mini {
1046
1338
  }
1047
1339
 
1048
1340
  private async _deleteObjectsProcess(keys: string[]): Promise<boolean[]> {
1049
- const xmlBody = `<Delete>${keys.map(key => `<Object><Key>${U.escapeXml(key)}</Key></Object>`).join('')}</Delete>`;
1341
+ const objectsXml = keys.map(key => `<Object><Key>${U.escapeXml(key)}</Key></Object>`).join('');
1342
+ const xmlBody = '<Delete>' + objectsXml + '</Delete>';
1050
1343
  const query = { delete: '' };
1051
- const md5Base64 = U.md5base64(xmlBody);
1344
+ const sha256base64 = U.base64FromBuffer(await U.sha256(xmlBody));
1052
1345
  const headers = {
1053
1346
  [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
1054
- [C.HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(),
1055
- 'Content-MD5': md5Base64,
1347
+ [C.HEADER_CONTENT_LENGTH]: U.getByteSize(xmlBody),
1348
+ [C.HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
1056
1349
  };
1057
1350
 
1058
1351
  const res = await this._signedRequest('POST', '', {
@@ -1139,7 +1432,7 @@ class s3mini {
1139
1432
  url: string,
1140
1433
  method: IT.HttpMethod,
1141
1434
  headers: Record<string, string>,
1142
- body?: string | Buffer,
1435
+ body?: BodyInit,
1143
1436
  toleratedStatusCodes: number[] = [],
1144
1437
  ): Promise<Response> {
1145
1438
  this._log('info', `Sending ${method} request to ${url}`, `headers: ${JSON.stringify(headers)}`);
@@ -1147,13 +1440,14 @@ class s3mini {
1147
1440
  const res = await fetch(url, {
1148
1441
  method,
1149
1442
  headers,
1150
- body: ['GET', 'HEAD'].includes(method) ? undefined : (body as string),
1443
+ body: ['GET', 'HEAD'].includes(method) ? undefined : body,
1151
1444
  signal: this.requestAbortTimeout !== undefined ? AbortSignal.timeout(this.requestAbortTimeout) : undefined,
1152
1445
  });
1153
1446
  this._log('info', `Response status: ${res.status}, tolerated: ${toleratedStatusCodes.join(',')}`);
1154
- if (!res.ok && !toleratedStatusCodes.includes(res.status)) {
1155
- await this._handleErrorResponse(res);
1447
+ if (res.ok || toleratedStatusCodes.includes(res.status)) {
1448
+ return res;
1156
1449
  }
1450
+ await this._handleErrorResponse(res);
1157
1451
  return res;
1158
1452
  } catch (err: unknown) {
1159
1453
  const code = U.extractErrCode(err);
@@ -1164,10 +1458,32 @@ class s3mini {
1164
1458
  }
1165
1459
  }
1166
1460
 
1461
+ private _parseErrorXml(headers: Headers, body: string): { svcCode?: string; errorMessage?: string } {
1462
+ if (headers.get('content-type') !== 'application/xml') {
1463
+ return {};
1464
+ }
1465
+ const parsedBody = U.parseXml(body);
1466
+ if (
1467
+ !parsedBody ||
1468
+ typeof parsedBody !== 'object' ||
1469
+ !('Error' in parsedBody) ||
1470
+ !parsedBody.Error ||
1471
+ typeof parsedBody.Error !== 'object'
1472
+ ) {
1473
+ return {};
1474
+ }
1475
+ const error = parsedBody.Error;
1476
+ return {
1477
+ svcCode: 'Code' in error && typeof error.Code === 'string' ? error.Code : undefined,
1478
+ errorMessage: 'Message' in error && typeof error.Message === 'string' ? error.Message : undefined,
1479
+ };
1480
+ }
1481
+
1167
1482
  private async _handleErrorResponse(res: Response): Promise<void> {
1168
1483
  const errorBody = await res.text();
1169
- const svcCode = res.headers.get('x-amz-error-code') ?? 'Unknown';
1170
- const errorMessage = res.headers.get('x-amz-error-message') || res.statusText;
1484
+ const parsedErrorBody = this._parseErrorXml(res.headers, errorBody);
1485
+ const svcCode = res.headers.get('x-amz-error-code') ?? parsedErrorBody.svcCode ?? 'Unknown';
1486
+ const errorMessage = res.headers.get('x-amz-error-message') ?? parsedErrorBody.errorMessage ?? res.statusText;
1171
1487
  this._log(
1172
1488
  'error',
1173
1489
  `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${svcCode} - ${errorMessage},err body: ${errorBody}`,
@@ -1181,16 +1497,16 @@ class s3mini {
1181
1497
  }
1182
1498
  return Object.keys(queryParams)
1183
1499
  .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key] as string)}`)
1184
- .sort()
1500
+ .sort((a, b) => a.localeCompare(b))
1185
1501
  .join('&');
1186
1502
  }
1187
- private _getSignatureKey(dateStamp: string): Buffer {
1188
- const kDate = U.hmac(`AWS4${this.secretAccessKey}`, dateStamp) as Buffer;
1189
- const kRegion = U.hmac(kDate, this.region) as Buffer;
1190
- const kService = U.hmac(kRegion, C.S3_SERVICE) as Buffer;
1191
- return U.hmac(kService, C.AWS_REQUEST_TYPE) as Buffer;
1503
+ private async _getSignatureKey(dateStamp: string): Promise<ArrayBuffer> {
1504
+ const kDate = await U.hmac(`AWS4${this.secretAccessKey}`, dateStamp);
1505
+ const kRegion = await U.hmac(kDate, this.region);
1506
+ const kService = await U.hmac(kRegion, C.S3_SERVICE);
1507
+ return await U.hmac(kService, C.AWS_REQUEST_TYPE);
1192
1508
  }
1193
1509
  }
1194
1510
 
1195
- export { s3mini };
1196
- export default s3mini;
1511
+ export { S3mini };
1512
+ export default S3mini;