s3mini 0.4.0 → 0.6.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
@@ -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,82 +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
- // sort headers alphabetically by key
264
- const ignoredHeaders = ['authorization', 'content-length', 'content-type', 'user-agent'];
265
- let headersForSigning = Object.fromEntries(
266
- Object.entries(headers).filter(([key]) => !ignoredHeaders.includes(key.toLowerCase())),
267
- );
268
275
 
269
- headersForSigning = Object.fromEntries(
270
- Object.entries(headersForSigning).sort(([keyA], [keyB]) => keyA.localeCompare(keyB)),
271
- );
272
- const canonicalHeaders = this._buildCanonicalHeaders(headersForSigning);
273
- const signedHeaders = Object.keys(headersForSigning)
274
- .map(key => key.toLowerCase())
275
- .sort()
276
- .join(';');
277
-
278
- const canonicalRequest = this._buildCanonicalRequest(method, url, query, canonicalHeaders, signedHeaders);
279
- const stringToSign = this._buildStringToSign(fullDatetime, credentialScope, canonicalRequest);
280
- const signature = this._calculateSignature(shortDatetime, stringToSign);
281
- const authorizationHeader = this._buildAuthorizationHeader(credentialScope, signedHeaders, signature);
282
- headers[C.HEADER_AUTHORIZATION] = authorizationHeader;
283
- return { url: url.toString(), headers };
284
- }
285
-
286
- private _buildCanonicalHeaders(headers: Record<string, string | number>): string {
287
- return Object.entries(headers)
288
- .map(([key, value]) => `${key.toLowerCase()}:${String(value).trim()}`)
289
- .join('\n');
290
- }
291
-
292
- private _buildCanonicalRequest(
293
- method: IT.HttpMethod,
294
- url: URL,
295
- query: Record<string, unknown>,
296
- canonicalHeaders: string,
297
- signedHeaders: string,
298
- ): string {
299
- const parts = [
300
- method,
301
- url.pathname,
302
- this._buildCanonicalQueryString(query),
303
- canonicalHeaders + '\n', // Canonical headers end with extra newline
304
- signedHeaders,
305
- C.UNSIGNED_PAYLOAD,
306
- ];
307
- return parts.join('\n');
308
- }
276
+ const ignoredHeaders = new Set(['authorization', 'content-length', 'content-type', 'user-agent']);
309
277
 
310
- private _buildCredentialScope(shortDatetime: string): string {
311
- return [shortDatetime, this.region, C.S3_SERVICE, C.AWS_REQUEST_TYPE].join('/');
312
- }
313
-
314
- private _buildStringToSign(fullDatetime: string, credentialScope: string, canonicalRequest: string): string {
315
- return [C.AWS_ALGORITHM, fullDatetime, credentialScope, U.hash(canonicalRequest)].join('\n');
316
- }
278
+ let canonicalHeaders = '';
279
+ let signedHeaders = '';
317
280
 
318
- private _calculateSignature(shortDatetime: string, stringToSign: string): string {
319
- if (shortDatetime !== this.signingKeyDate) {
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))}`;
294
+ if (shortDatetime !== this.signingKeyDate || !this.signingKey) {
320
295
  this.signingKeyDate = shortDatetime;
321
- this.signingKey = this._getSignatureKey(shortDatetime);
296
+ this.signingKey = await this._getSignatureKey(shortDatetime);
322
297
  }
323
- return U.hmac(this.signingKey!, stringToSign, 'hex') as string;
324
- }
325
-
326
- private _buildAuthorizationHeader(credentialScope: string, signedHeaders: string, signature: string): string {
327
- return [
328
- `${C.AWS_ALGORITHM} Credential=${this.accessKeyId}/${credentialScope}`,
329
- `SignedHeaders=${signedHeaders}`,
330
- `Signature=${signature}`,
331
- ].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 };
332
302
  }
333
303
 
334
304
  private async _signedRequest(
@@ -336,22 +306,22 @@ class S3mini {
336
306
  key: string, // ‘’ allowed for bucket‑level ops
337
307
  {
338
308
  query = {}, // ?query=string
339
- body = '', // string | Buffer | undefined
309
+ body = '', // BodyInit | undefined
340
310
  headers = {}, // extra/override headers
341
311
  tolerated = [], // [200, 404] etc.
342
312
  withQuery = false, // append query string to signed URL
343
313
  }: {
344
- query?: Record<string, unknown> | undefined;
345
- body?: string | Buffer | undefined;
346
- headers?: Record<string, string | number | undefined> | IT.SSECHeaders | undefined;
347
- tolerated?: number[] | undefined;
348
- withQuery?: boolean | undefined;
314
+ query?: Record<string, unknown>;
315
+ body?: BodyInit;
316
+ headers?: Record<string, string | number | undefined> | IT.SSECHeaders | IT.AWSHeaders;
317
+ tolerated?: number[];
318
+ withQuery?: boolean;
349
319
  } = {},
350
320
  ): Promise<Response> {
351
321
  // Basic validation
352
- if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) {
353
- throw new Error(`${C.ERROR_PREFIX}Unsupported HTTP method ${method as string}`);
354
- }
322
+ // if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) {
323
+ // throw new Error(`${C.ERROR_PREFIX}Unsupported HTTP method ${method as string}`);
324
+ // }
355
325
 
356
326
  const { filteredOpts, conditionalHeaders } = ['GET', 'HEAD'].includes(method)
357
327
  ? this._filterIfHeaders(query)
@@ -364,7 +334,7 @@ class S3mini {
364
334
  };
365
335
 
366
336
  const encodedKey = key ? U.uriResourceEscape(key) : '';
367
- const { url, headers: signedHeaders } = this._sign(method, encodedKey, filteredOpts, baseHeaders);
337
+ const { url, headers: signedHeaders } = await this._sign(method, encodedKey, filteredOpts, baseHeaders);
368
338
  if (Object.keys(query).length > 0) {
369
339
  withQuery = true; // append query string to signed URL
370
340
  }
@@ -379,55 +349,6 @@ class S3mini {
379
349
  return this._sendRequest(finalUrl, method, signedHeadersString, body, tolerated);
380
350
  }
381
351
 
382
- /**
383
- * Gets the current configuration properties of the S3 instance.
384
- * @returns {IT.S3Config} The current S3 configuration object containing all settings.
385
- * @example
386
- * const config = s3.getProps();
387
- * console.log(config.endpoint); // 'https://s3.amazonaws.com/my-bucket'
388
- */
389
- public getProps(): IT.S3Config {
390
- return {
391
- accessKeyId: this.accessKeyId,
392
- secretAccessKey: this.secretAccessKey,
393
- endpoint: this.endpoint,
394
- region: this.region,
395
- requestSizeInBytes: this.requestSizeInBytes,
396
- requestAbortTimeout: this.requestAbortTimeout,
397
- logger: this.logger,
398
- };
399
- }
400
-
401
- /**
402
- * Updates the configuration properties of the S3 instance.
403
- * @param {IT.S3Config} props - The new configuration object.
404
- * @param {string} props.accessKeyId - The access key ID for authentication.
405
- * @param {string} props.secretAccessKey - The secret access key for authentication.
406
- * @param {string} props.endpoint - The endpoint URL of the S3-compatible service.
407
- * @param {string} [props.region='auto'] - The region of the S3 service.
408
- * @param {number} [props.requestSizeInBytes=8388608] - The request size of a single request in bytes.
409
- * @param {number} [props.requestAbortTimeout] - The timeout in milliseconds after which a request should be aborted.
410
- * @param {IT.Logger} [props.logger] - A logger object with methods like info, warn, error.
411
- * @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type.
412
- * @example
413
- * s3.setProps({
414
- * accessKeyId: 'new-access-key',
415
- * secretAccessKey: 'new-secret-key',
416
- * endpoint: 'https://new-endpoint.com/my-bucket',
417
- * region: 'us-west-2' // by default is auto
418
- * });
419
- */
420
- public setProps(props: IT.S3Config): void {
421
- this._validateConstructorParams(props.accessKeyId, props.secretAccessKey, props.endpoint);
422
- this.accessKeyId = props.accessKeyId;
423
- this.secretAccessKey = props.secretAccessKey;
424
- this.region = props.region || 'auto';
425
- this.endpoint = props.endpoint;
426
- this.requestSizeInBytes = props.requestSizeInBytes || C.DEFAULT_REQUEST_SIZE_IN_BYTES;
427
- this.requestAbortTimeout = props.requestAbortTimeout;
428
- this.logger = props.logger;
429
- }
430
-
431
352
  /**
432
353
  * Sanitizes an ETag value by removing surrounding quotes and whitespace.
433
354
  * Still returns RFC compliant ETag. https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3
@@ -453,7 +374,7 @@ class S3mini {
453
374
  `;
454
375
  const headers = {
455
376
  [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
456
- [C.HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(),
377
+ [C.HEADER_CONTENT_LENGTH]: U.getByteSize(xmlBody),
457
378
  };
458
379
  const res = await this._signedRequest('PUT', '', {
459
380
  body: xmlBody,
@@ -463,6 +384,42 @@ class S3mini {
463
384
  return res.status === 200;
464
385
  }
465
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
+
466
423
  /**
467
424
  * Checks if a bucket exists.
468
425
  * This method sends a request to check if the specified bucket exists in the S3-compatible service.
@@ -492,7 +449,6 @@ class S3mini {
492
449
  delimiter: string = '/',
493
450
  prefix: string = '',
494
451
  maxKeys?: number,
495
- // method: IT.HttpMethod = 'GET', // 'GET' or 'HEAD'
496
452
  opts: Record<string, unknown> = {},
497
453
  ): Promise<IT.ListObject[] | null> {
498
454
  this._checkDelimiter(delimiter);
@@ -500,69 +456,131 @@ class S3mini {
500
456
  this._checkOpts(opts);
501
457
 
502
458
  const keyPath = delimiter === '/' ? delimiter : U.uriEscape(delimiter);
503
-
504
459
  const unlimited = !(maxKeys && maxKeys > 0);
505
460
  let remaining = unlimited ? Infinity : maxKeys;
506
461
  let token: string | undefined;
507
462
  const all: IT.ListObject[] = [];
508
463
 
509
464
  do {
510
- const batchSize = Math.min(remaining, 1000); // S3 ceiling
511
- const query: Record<string, unknown> = {
512
- 'list-type': C.LIST_TYPE, // =2 for V2
513
- 'max-keys': String(batchSize),
514
- ...(prefix ? { prefix } : {}),
515
- ...(token ? { 'continuation-token': token } : {}),
516
- ...opts,
517
- };
465
+ const batchResult = await this._fetchObjectBatch(keyPath, prefix, remaining, token, opts);
518
466
 
519
- const res = await this._signedRequest('GET', keyPath, {
520
- query,
521
- withQuery: true,
522
- tolerated: [200, 404],
523
- });
524
-
525
- if (res.status === 404) {
526
- return null;
527
- }
528
- if (res.status !== 200) {
529
- const errorBody = await res.text();
530
- const errorCode = res.headers.get('x-amz-error-code') || 'Unknown';
531
- const errorMessage = res.headers.get('x-amz-error-message') || res.statusText;
532
- this._log(
533
- 'error',
534
- `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
535
- );
536
- throw new Error(
537
- `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
538
- );
467
+ if (batchResult === null) {
468
+ return null; // 404 - bucket not found
539
469
  }
540
- const raw = U.parseXml(await res.text()) as Record<string, unknown>;
541
- if (typeof raw !== 'object' || !raw || 'error' in raw) {
542
- this._log('error', `${C.ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`);
543
- throw new Error(`${C.ERROR_PREFIX}Unexpected listObjects response shape`);
544
- }
545
- const out = (raw.ListBucketResult || raw.listBucketResult || raw) as Record<string, unknown>;
546
- /* accumulate Contents */
547
- const contents = out.Contents || out.contents; // S3 v2 vs v1
548
- if (contents) {
549
- const batch = Array.isArray(contents) ? contents : [contents];
550
- all.push(...(batch as IT.ListObject[]));
551
- if (!unlimited) {
552
- remaining -= batch.length;
553
- }
470
+
471
+ all.push(...batchResult.objects);
472
+
473
+ if (!unlimited) {
474
+ remaining -= batchResult.objects.length;
554
475
  }
555
- const truncated = out.IsTruncated === 'true' || out.isTruncated === 'true' || false;
556
- token = truncated
557
- ? ((out.NextContinuationToken || out.nextContinuationToken || out.NextMarker || out.nextMarker) as
558
- | string
559
- | undefined)
560
- : undefined;
476
+
477
+ token = batchResult.continuationToken;
561
478
  } while (token && remaining > 0);
562
479
 
563
480
  return all;
564
481
  }
565
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
+
566
584
  /**
567
585
  * Lists multipart uploads in the bucket.
568
586
  * This method sends a request to list multipart uploads in the specified bucket.
@@ -856,6 +874,7 @@ class S3mini {
856
874
  * @param {string | Buffer} data - The data to upload (string or Buffer).
857
875
  * @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
858
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.
859
878
  * @returns {Promise<Response>} A promise that resolves to the Response object from the upload request.
860
879
  * @throws {TypeError} If data is not a string or Buffer.
861
880
  * @example
@@ -868,18 +887,17 @@ class S3mini {
868
887
  */
869
888
  public async putObject(
870
889
  key: string,
871
- data: string | Buffer,
890
+ data: string | IT.MaybeBuffer,
872
891
  fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
873
892
  ssecHeaders?: IT.SSECHeaders,
893
+ additionalHeaders?: IT.AWSHeaders,
874
894
  ): Promise<Response> {
875
- if (!(data instanceof Buffer || typeof data === 'string')) {
876
- throw new TypeError(C.ERROR_DATA_BUFFER_REQUIRED);
877
- }
878
895
  return this._signedRequest('PUT', key, {
879
- body: data,
896
+ body: this._validateData(data),
880
897
  headers: {
881
- [C.HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length,
898
+ [C.HEADER_CONTENT_LENGTH]: U.getByteSize(data),
882
899
  [C.HEADER_CONTENT_TYPE]: fileType,
900
+ ...additionalHeaders,
883
901
  ...ssecHeaders,
884
902
  },
885
903
  tolerated: [200],
@@ -917,16 +935,6 @@ class S3mini {
917
935
  });
918
936
  const parsed = U.parseXml(await res.text()) as Record<string, unknown>;
919
937
 
920
- // if (
921
- // parsed &&
922
- // typeof parsed === 'object' &&
923
- // 'initiateMultipartUploadResult' in parsed &&
924
- // parsed.initiateMultipartUploadResult &&
925
- // 'uploadId' in (parsed.initiateMultipartUploadResult as { uploadId: string })
926
- // ) {
927
- // return (parsed.initiateMultipartUploadResult as { uploadId: string }).uploadId;
928
- // }
929
-
930
938
  if (parsed && typeof parsed === 'object') {
931
939
  // Check for both cases of InitiateMultipartUploadResult
932
940
  const uploadResult =
@@ -968,19 +976,19 @@ class S3mini {
968
976
  public async uploadPart(
969
977
  key: string,
970
978
  uploadId: string,
971
- data: Buffer | string,
979
+ data: IT.MaybeBuffer | string,
972
980
  partNumber: number,
973
981
  opts: Record<string, unknown> = {},
974
982
  ssecHeaders?: IT.SSECHeaders,
975
983
  ): Promise<IT.UploadPart> {
976
- this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
984
+ const body = this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
977
985
 
978
986
  const query = { uploadId, partNumber, ...opts };
979
987
  const res = await this._signedRequest('PUT', key, {
980
988
  query,
981
- body: data,
989
+ body,
982
990
  headers: {
983
- [C.HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length,
991
+ [C.HEADER_CONTENT_LENGTH]: U.getByteSize(data),
984
992
  ...ssecHeaders,
985
993
  },
986
994
  });
@@ -1015,7 +1023,7 @@ class S3mini {
1015
1023
  const xmlBody = this._buildCompleteMultipartUploadXml(parts);
1016
1024
  const headers = {
1017
1025
  [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
1018
- [C.HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(),
1026
+ [C.HEADER_CONTENT_LENGTH]: U.getByteSize(xmlBody),
1019
1027
  };
1020
1028
 
1021
1029
  const res = await this._signedRequest('POST', key, {
@@ -1038,7 +1046,7 @@ class S3mini {
1038
1046
  if (etag && typeof etag === 'string') {
1039
1047
  return {
1040
1048
  ...resultObj,
1041
- etag: this.sanitizeETag(etag),
1049
+ etag: U.sanitizeETag(etag),
1042
1050
  } as IT.CompleteMultipartUploadResult;
1043
1051
  }
1044
1052
 
@@ -1094,20 +1102,228 @@ class S3mini {
1094
1102
  }
1095
1103
 
1096
1104
  private _buildCompleteMultipartUploadXml(parts: Array<IT.UploadPart>): string {
1097
- return `
1098
- <CompleteMultipartUpload>
1099
- ${parts
1100
- .map(
1101
- part => `
1102
- <Part>
1103
- <PartNumber>${part.partNumber}</PartNumber>
1104
- <ETag>${part.etag}</ETag>
1105
- </Part>
1106
- `,
1107
- )
1108
- .join('')}
1109
- </CompleteMultipartUpload>
1110
- `;
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
+ };
1111
1327
  }
1112
1328
 
1113
1329
  /**
@@ -1122,13 +1338,14 @@ class S3mini {
1122
1338
  }
1123
1339
 
1124
1340
  private async _deleteObjectsProcess(keys: string[]): Promise<boolean[]> {
1125
- 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>';
1126
1343
  const query = { delete: '' };
1127
- const md5Base64 = U.md5base64(xmlBody);
1344
+ const sha256base64 = U.base64FromBuffer(await U.sha256(xmlBody));
1128
1345
  const headers = {
1129
1346
  [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
1130
- [C.HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(),
1131
- 'Content-MD5': md5Base64,
1347
+ [C.HEADER_CONTENT_LENGTH]: U.getByteSize(xmlBody),
1348
+ [C.HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
1132
1349
  };
1133
1350
 
1134
1351
  const res = await this._signedRequest('POST', '', {
@@ -1215,7 +1432,7 @@ class S3mini {
1215
1432
  url: string,
1216
1433
  method: IT.HttpMethod,
1217
1434
  headers: Record<string, string>,
1218
- body?: string | Buffer,
1435
+ body?: BodyInit,
1219
1436
  toleratedStatusCodes: number[] = [],
1220
1437
  ): Promise<Response> {
1221
1438
  this._log('info', `Sending ${method} request to ${url}`, `headers: ${JSON.stringify(headers)}`);
@@ -1223,13 +1440,14 @@ class S3mini {
1223
1440
  const res = await fetch(url, {
1224
1441
  method,
1225
1442
  headers,
1226
- body: ['GET', 'HEAD'].includes(method) ? undefined : (body as string),
1443
+ body: ['GET', 'HEAD'].includes(method) ? undefined : body,
1227
1444
  signal: this.requestAbortTimeout !== undefined ? AbortSignal.timeout(this.requestAbortTimeout) : undefined,
1228
1445
  });
1229
1446
  this._log('info', `Response status: ${res.status}, tolerated: ${toleratedStatusCodes.join(',')}`);
1230
- if (!res.ok && !toleratedStatusCodes.includes(res.status)) {
1231
- await this._handleErrorResponse(res);
1447
+ if (res.ok || toleratedStatusCodes.includes(res.status)) {
1448
+ return res;
1232
1449
  }
1450
+ await this._handleErrorResponse(res);
1233
1451
  return res;
1234
1452
  } catch (err: unknown) {
1235
1453
  const code = U.extractErrCode(err);
@@ -1240,10 +1458,32 @@ class S3mini {
1240
1458
  }
1241
1459
  }
1242
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
+
1243
1482
  private async _handleErrorResponse(res: Response): Promise<void> {
1244
1483
  const errorBody = await res.text();
1245
- const svcCode = res.headers.get('x-amz-error-code') ?? 'Unknown';
1246
- 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;
1247
1487
  this._log(
1248
1488
  'error',
1249
1489
  `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${svcCode} - ${errorMessage},err body: ${errorBody}`,
@@ -1257,21 +1497,16 @@ class S3mini {
1257
1497
  }
1258
1498
  return Object.keys(queryParams)
1259
1499
  .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key] as string)}`)
1260
- .sort()
1500
+ .sort((a, b) => a.localeCompare(b))
1261
1501
  .join('&');
1262
1502
  }
1263
- private _getSignatureKey(dateStamp: string): Buffer {
1264
- const kDate = U.hmac(`AWS4${this.secretAccessKey}`, dateStamp) as Buffer;
1265
- const kRegion = U.hmac(kDate, this.region) as Buffer;
1266
- const kService = U.hmac(kRegion, C.S3_SERVICE) as Buffer;
1267
- 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);
1268
1508
  }
1269
1509
  }
1270
1510
 
1271
- /**
1272
- * @deprecated Use `S3mini` instead.
1273
- */
1274
- const s3mini = S3mini;
1275
-
1276
- export { S3mini, s3mini };
1511
+ export { S3mini };
1277
1512
  export default S3mini;