s3mini 0.1.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 ADDED
@@ -0,0 +1,831 @@
1
+ 'use strict';
2
+
3
+ import * as C from './consts.js';
4
+ import type * as IT from './types.js';
5
+ import * as U from './utils.js';
6
+
7
+ /**
8
+ * S3 class for interacting with S3-compatible object storage services.
9
+ * This class provides methods for common S3 operations such as uploading, downloading,
10
+ * and deleting objects, as well as multipart uploads.
11
+ *
12
+ * @class
13
+ * @example
14
+ * const s3 = new CoreS3({
15
+ * accessKeyId: 'your-access-key',
16
+ * secretAccessKey: 'your-secret-key',
17
+ * endpoint: 'https://your-s3-endpoint.com',
18
+ * region: 'us-east-1' // by default is auto
19
+ * });
20
+ *
21
+ * // Upload a file
22
+ * await s3.putObject('example.txt', 'Hello, World!');
23
+ *
24
+ * // Download a file
25
+ * const content = await s3.getObject('example.txt');
26
+ *
27
+ * // Delete a file
28
+ * await s3.deleteObject('example.txt');
29
+ */
30
+ class s3mini {
31
+ /**
32
+ * Creates an instance of the S3 class.
33
+ *
34
+ * @constructor
35
+ * @param {Object} config - Configuration options for the S3 instance.
36
+ * @param {string} config.accessKeyId - The access key ID for authentication.
37
+ * @param {string} config.secretAccessKey - The secret access key for authentication.
38
+ * @param {string} config.endpoint - The endpoint URL of the S3-compatible service.
39
+ * @param {string} [config.region='auto'] - The region of the S3 service.
40
+ * @param {number} [config.requestSizeInBytes=8388608] - The request size of a single request in bytes (AWS S3 is 8MB).
41
+ * @param {number} [config.requestAbortTimeout=undefined] - The timeout in milliseconds after which a request should be aborted (careful on streamed requests).
42
+ * @param {Object} [config.logger=null] - A logger object with methods like info, warn, error.
43
+ * @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type.
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;
52
+ private fullDatetime: string;
53
+ private shortDatetime: string;
54
+ private signingKey: Buffer;
55
+ private credentialScope: string;
56
+
57
+ constructor({
58
+ accessKeyId,
59
+ secretAccessKey,
60
+ endpoint,
61
+ region = 'auto',
62
+ requestSizeInBytes = C.DEFAULT_REQUEST_SIZE_IN_BYTES,
63
+ requestAbortTimeout = undefined,
64
+ logger = undefined,
65
+ }: IT.S3Config) {
66
+ this._validateConstructorParams(accessKeyId, secretAccessKey, endpoint);
67
+ this.accessKeyId = accessKeyId;
68
+ this.secretAccessKey = secretAccessKey;
69
+ this.endpoint = this._ensureValidUrl(endpoint);
70
+ this.region = region;
71
+ this.requestSizeInBytes = requestSizeInBytes;
72
+ this.requestAbortTimeout = requestAbortTimeout;
73
+ this.logger = logger;
74
+ this.fullDatetime = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
75
+ this.shortDatetime = this.fullDatetime.slice(0, 8);
76
+ this.signingKey = this._getSignatureKey(this.shortDatetime);
77
+ this.credentialScope = [this.shortDatetime, this.region, C.S3_SERVICE, C.AWS_REQUEST_TYPE].join('/');
78
+ }
79
+
80
+ private _sanitize(obj: unknown): unknown {
81
+ if (typeof obj !== 'object' || obj === null) {
82
+ return obj;
83
+ }
84
+ return Object.keys(obj).reduce(
85
+ (acc: Record<string, unknown>, key) => {
86
+ if (C.SENSITIVE_KEYS_REDACTED.includes(key.toLowerCase())) {
87
+ acc[key] = '[REDACTED]';
88
+ } else if (
89
+ typeof (obj as Record<string, unknown>)[key] === 'object' &&
90
+ (obj as Record<string, unknown>)[key] !== null
91
+ ) {
92
+ acc[key] = this._sanitize((obj as Record<string, unknown>)[key]);
93
+ } else {
94
+ acc[key] = (obj as Record<string, unknown>)[key];
95
+ }
96
+ return acc;
97
+ },
98
+ Array.isArray(obj) ? [] : {},
99
+ );
100
+ }
101
+
102
+ private _log(
103
+ level: 'info' | 'warn' | 'error',
104
+ message: string,
105
+ additionalData: Record<string, unknown> | string = {},
106
+ ): void {
107
+ if (this.logger && typeof this.logger[level] === 'function') {
108
+ // Function to recursively sanitize an object
109
+
110
+ // Sanitize the additional data
111
+ const sanitizedData = this._sanitize(additionalData);
112
+ // Prepare the log entry
113
+ const logEntry = {
114
+ timestamp: new Date().toISOString(),
115
+ level,
116
+ message,
117
+ details: sanitizedData,
118
+ // Include some general context, but sanitize sensitive parts
119
+ context: this._sanitize({
120
+ region: this.region,
121
+ endpoint: this.endpoint,
122
+ // Only include the first few characters of the access key, if it exists
123
+ accessKeyId: this.accessKeyId ? `${this.accessKeyId.substring(0, 4)}...` : undefined,
124
+ }),
125
+ };
126
+
127
+ // Log the sanitized entry
128
+ this.logger[level](JSON.stringify(logEntry));
129
+ }
130
+ }
131
+
132
+ private _validateConstructorParams(accessKeyId: string, secretAccessKey: string, endpoint: string): void {
133
+ if (typeof accessKeyId !== 'string' || accessKeyId.trim().length === 0) {
134
+ throw new TypeError(C.ERROR_ACCESS_KEY_REQUIRED);
135
+ }
136
+ if (typeof secretAccessKey !== 'string' || secretAccessKey.trim().length === 0) {
137
+ throw new TypeError(C.ERROR_SECRET_KEY_REQUIRED);
138
+ }
139
+ if (typeof endpoint !== 'string' || endpoint.trim().length === 0) {
140
+ throw new TypeError(C.ERROR_ENDPOINT_REQUIRED);
141
+ }
142
+ }
143
+
144
+ private _ensureValidUrl(raw: string): string {
145
+ // prepend https:// if user forgot a scheme
146
+ const candidate = /^(https?:)?\/\//i.test(raw) ? raw : `https://${raw}`;
147
+ try {
148
+ new URL(candidate);
149
+ return candidate.replace(/\/+$/, ''); // strip trailing slash
150
+ } catch {
151
+ const msg = `${C.ERROR_ENDPOINT_FORMAT} But provided: "${raw}"`;
152
+ this._log('error', msg);
153
+ throw new TypeError(msg);
154
+ }
155
+ }
156
+
157
+ private _validateMethodIsGetOrHead(method: string): void {
158
+ if (method !== 'GET' && method !== 'HEAD') {
159
+ this._log('error', `${C.ERROR_PREFIX}method must be either GET or HEAD`);
160
+ throw new Error('method must be either GET or HEAD');
161
+ }
162
+ }
163
+
164
+ private _checkKey(key: string): void {
165
+ if (typeof key !== 'string' || key.trim().length === 0) {
166
+ this._log('error', C.ERROR_KEY_REQUIRED);
167
+ throw new TypeError(C.ERROR_KEY_REQUIRED);
168
+ }
169
+ }
170
+
171
+ private _checkDelimiter(delimiter: string): void {
172
+ if (typeof delimiter !== 'string' || delimiter.trim().length === 0) {
173
+ this._log('error', C.ERROR_DELIMITER_REQUIRED);
174
+ throw new TypeError(C.ERROR_DELIMITER_REQUIRED);
175
+ }
176
+ }
177
+
178
+ private _checkPrefix(prefix: string): void {
179
+ if (typeof prefix !== 'string') {
180
+ this._log('error', C.ERROR_PREFIX_TYPE);
181
+ throw new TypeError(C.ERROR_PREFIX_TYPE);
182
+ }
183
+ }
184
+
185
+ // private _checkMaxKeys(maxKeys: number): void {
186
+ // if (typeof maxKeys !== 'number' || maxKeys <= 0) {
187
+ // this._log('error', C.ERROR_MAX_KEYS_TYPE);
188
+ // throw new TypeError(C.ERROR_MAX_KEYS_TYPE);
189
+ // }
190
+ // }
191
+
192
+ private _checkOpts(opts: object): void {
193
+ if (typeof opts !== 'object') {
194
+ this._log('error', `${C.ERROR_PREFIX}opts must be an object`);
195
+ throw new TypeError(`${C.ERROR_PREFIX}opts must be an object`);
196
+ }
197
+ }
198
+
199
+ private _filterIfHeaders(opts: Record<string, unknown>): {
200
+ filteredOpts: Record<string, string>;
201
+ conditionalHeaders: Record<string, unknown>;
202
+ } {
203
+ const filteredOpts: Record<string, string> = {};
204
+ const conditionalHeaders: Record<string, unknown> = {};
205
+ const ifHeaders = ['if-match', 'if-none-match', 'if-modified-since', 'if-unmodified-since'];
206
+
207
+ for (const [key, value] of Object.entries(opts)) {
208
+ if (ifHeaders.includes(key.toLowerCase())) {
209
+ // Convert to lowercase for consistency
210
+ conditionalHeaders[key] = value;
211
+ } else {
212
+ filteredOpts[key] = value as string;
213
+ }
214
+ }
215
+
216
+ return { filteredOpts, conditionalHeaders };
217
+ }
218
+
219
+ private _validateUploadPartParams(
220
+ key: string,
221
+ uploadId: string,
222
+ data: Buffer | string,
223
+ partNumber: number,
224
+ opts: object,
225
+ ): void {
226
+ this._checkKey(key);
227
+ if (!(data instanceof Buffer || typeof data === 'string')) {
228
+ this._log('error', C.ERROR_DATA_BUFFER_REQUIRED);
229
+ throw new TypeError(C.ERROR_DATA_BUFFER_REQUIRED);
230
+ }
231
+ if (typeof uploadId !== 'string' || uploadId.trim().length === 0) {
232
+ this._log('error', C.ERROR_UPLOAD_ID_REQUIRED);
233
+ throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED);
234
+ }
235
+ if (!Number.isInteger(partNumber) || partNumber <= 0) {
236
+ this._log('error', `${C.ERROR_PREFIX}partNumber must be a positive integer`);
237
+ throw new TypeError(`${C.ERROR_PREFIX}partNumber must be a positive integer`);
238
+ }
239
+ this._checkOpts(opts);
240
+ }
241
+
242
+ private _sign(
243
+ method: IT.HttpMethod,
244
+ keyPath: string,
245
+ query: Record<string, unknown> = {},
246
+ headers: Record<string, string | number> = {},
247
+ ): { url: string; headers: Record<string, string | number> } {
248
+ // Create URL without appending keyPath first
249
+ const url = new URL(this.endpoint);
250
+
251
+ // Properly format the pathname to avoid double slashes
252
+ if (keyPath && keyPath.length > 0) {
253
+ url.pathname =
254
+ url.pathname === '/' ? `/${keyPath.replace(/^\/+/, '')}` : `${url.pathname}/${keyPath.replace(/^\/+/, '')}`;
255
+ }
256
+
257
+ headers[C.HEADER_AMZ_CONTENT_SHA256] = C.UNSIGNED_PAYLOAD; // body ? U.hash(body) : C.UNSIGNED_PAYLOAD;
258
+ headers[C.HEADER_AMZ_DATE] = this.fullDatetime;
259
+ headers[C.HEADER_HOST] = url.host;
260
+ const canonicalHeaders = this._buildCanonicalHeaders(headers);
261
+ const signedHeaders = Object.keys(headers)
262
+ .map(key => key.toLowerCase())
263
+ .sort()
264
+ .join(';');
265
+
266
+ const canonicalRequest = this._buildCanonicalRequest(method, url, query, canonicalHeaders, signedHeaders);
267
+ const stringToSign = this._buildStringToSign(canonicalRequest);
268
+ const signature = this._calculateSignature(stringToSign);
269
+ const authorizationHeader = this._buildAuthorizationHeader(signedHeaders, signature);
270
+ headers[C.HEADER_AUTHORIZATION] = authorizationHeader;
271
+ return { url: url.toString(), headers };
272
+ }
273
+
274
+ private _buildCanonicalHeaders(headers: Record<string, string | number>): string {
275
+ return Object.entries(headers)
276
+ .map(([key, value]) => `${key.toLowerCase()}:${String(value).trim()}`)
277
+ .sort()
278
+ .join('\n');
279
+ }
280
+
281
+ private _buildCanonicalRequest(
282
+ method: IT.HttpMethod,
283
+ url: URL,
284
+ query: Record<string, unknown>,
285
+ canonicalHeaders: string,
286
+ signedHeaders: string,
287
+ ): string {
288
+ return [
289
+ method,
290
+ url.pathname,
291
+ this._buildCanonicalQueryString(query),
292
+ `${canonicalHeaders}\n`,
293
+ signedHeaders,
294
+ C.UNSIGNED_PAYLOAD,
295
+ ].join('\n');
296
+ }
297
+
298
+ private _buildStringToSign(canonicalRequest: string): string {
299
+ return [C.AWS_ALGORITHM, this.fullDatetime, this.credentialScope, U.hash(canonicalRequest)].join('\n');
300
+ }
301
+
302
+ private _calculateSignature(stringToSign: string): string {
303
+ return U.hmac(this.signingKey, stringToSign, 'hex') as string;
304
+ }
305
+
306
+ private _buildAuthorizationHeader(signedHeaders: string, signature: string): string {
307
+ return [
308
+ `${C.AWS_ALGORITHM} Credential=${this.accessKeyId}/${this.credentialScope}`,
309
+ `SignedHeaders=${signedHeaders}`,
310
+ `Signature=${signature}`,
311
+ ].join(', ');
312
+ }
313
+
314
+ private async _signedRequest(
315
+ method: IT.HttpMethod, // 'GET' | 'HEAD' | 'PUT' | 'POST' | 'DELETE'
316
+ key: string, // ‘’ allowed for bucket‑level ops
317
+ {
318
+ query = {}, // ?query=string
319
+ body = '', // string | Buffer | undefined
320
+ headers = {}, // extra/override headers
321
+ tolerated = [], // [200, 404] etc.
322
+ withQuery = false, // append query string to signed URL
323
+ }: {
324
+ query?: Record<string, unknown>;
325
+ body?: string | Buffer | undefined;
326
+ headers?: Record<string, string | number | undefined>;
327
+ tolerated?: number[] | undefined;
328
+ withQuery?: boolean | undefined;
329
+ } = {},
330
+ ): Promise<Response> {
331
+ // Basic validation
332
+ if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) {
333
+ throw new Error(`${C.ERROR_PREFIX}Unsupported HTTP method ${method as string}`);
334
+ }
335
+ if (key) {
336
+ this._checkKey(key); // allow '' for bucket‑level
337
+ }
338
+
339
+ const { filteredOpts, conditionalHeaders } = ['GET', 'HEAD'].includes(method)
340
+ ? this._filterIfHeaders(query)
341
+ : { filteredOpts: query, conditionalHeaders: {} };
342
+
343
+ const baseHeaders: Record<string, string | number> = {
344
+ [C.HEADER_AMZ_CONTENT_SHA256]: C.UNSIGNED_PAYLOAD,
345
+ // ...(['GET', 'HEAD'].includes(method) ? { [C.HEADER_CONTENT_TYPE]: C.JSON_CONTENT_TYPE } : {}),
346
+ ...headers,
347
+ ...conditionalHeaders,
348
+ };
349
+
350
+ const encodedKey = key ? U.uriResourceEscape(key) : '';
351
+ const { url, headers: signedHeaders } = this._sign(method, encodedKey, filteredOpts, baseHeaders);
352
+ if (Object.keys(query).length > 0) {
353
+ withQuery = true; // append query string to signed URL
354
+ }
355
+ const filteredOptsStrings = Object.fromEntries(
356
+ Object.entries(filteredOpts).map(([k, v]) => [k, String(v)]),
357
+ ) as Record<string, string>;
358
+ const finalUrl =
359
+ withQuery && Object.keys(filteredOpts).length ? `${url}?${new URLSearchParams(filteredOptsStrings)}` : url;
360
+ const signedHeadersString = Object.fromEntries(
361
+ Object.entries(signedHeaders).map(([k, v]) => [k, String(v)]),
362
+ ) as Record<string, string>;
363
+ return this._sendRequest(finalUrl, method, signedHeadersString, body, tolerated);
364
+ }
365
+
366
+ public getProps(): IT.S3Config {
367
+ return {
368
+ accessKeyId: this.accessKeyId,
369
+ secretAccessKey: this.secretAccessKey,
370
+ endpoint: this.endpoint,
371
+ region: this.region,
372
+ requestSizeInBytes: this.requestSizeInBytes,
373
+ requestAbortTimeout: this.requestAbortTimeout,
374
+ logger: this.logger,
375
+ };
376
+ }
377
+ public setProps(props: IT.S3Config): void {
378
+ this._validateConstructorParams(props.accessKeyId, props.secretAccessKey, props.endpoint);
379
+ this.accessKeyId = props.accessKeyId;
380
+ this.secretAccessKey = props.secretAccessKey;
381
+ this.region = props.region || 'auto';
382
+ this.endpoint = props.endpoint;
383
+ this.requestSizeInBytes = props.requestSizeInBytes || C.DEFAULT_REQUEST_SIZE_IN_BYTES;
384
+ this.requestAbortTimeout = props.requestAbortTimeout;
385
+ this.logger = props.logger;
386
+ }
387
+
388
+ public sanitizeETag(etag: string): string {
389
+ return U.sanitizeETag(etag);
390
+ }
391
+
392
+ // TBD
393
+ public async createBucket(): Promise<boolean> {
394
+ const xmlBody = `
395
+ <CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
396
+ <LocationConstraint>${this.region}</LocationConstraint>
397
+ </CreateBucketConfiguration>
398
+ `;
399
+ const headers = {
400
+ [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
401
+ [C.HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(),
402
+ };
403
+ const res = await this._signedRequest('PUT', '', {
404
+ body: xmlBody,
405
+ headers,
406
+ tolerated: [200, 404, 403, 409], // don’t throw on 404/403 // 409 = bucket already exists
407
+ });
408
+ return res.status === 200;
409
+ }
410
+
411
+ public async bucketExists(): Promise<boolean> {
412
+ const res = await this._signedRequest('HEAD', '', { tolerated: [200, 404, 403] });
413
+ return res.status === 200;
414
+ }
415
+
416
+ public async listObjects(
417
+ delimiter: string = '/',
418
+ prefix: string = '',
419
+ maxKeys?: number,
420
+ // method: IT.HttpMethod = 'GET', // 'GET' or 'HEAD'
421
+ opts: Record<string, unknown> = {},
422
+ ): Promise<object[] | null> {
423
+ this._checkDelimiter(delimiter);
424
+ this._checkPrefix(prefix);
425
+ this._checkOpts(opts);
426
+
427
+ const keyPath = delimiter === '/' ? delimiter : U.uriEscape(delimiter);
428
+
429
+ const unlimited = !(maxKeys && maxKeys > 0);
430
+ let remaining = unlimited ? Infinity : maxKeys;
431
+ let token: string | undefined;
432
+ const all: object[] = [];
433
+
434
+ do {
435
+ const batchSize = Math.min(remaining, 1000); // S3 ceiling
436
+ const query: Record<string, unknown> = {
437
+ 'list-type': C.LIST_TYPE, // =2 for V2
438
+ 'max-keys': String(batchSize),
439
+ ...(prefix ? { prefix } : {}),
440
+ ...(token ? { 'continuation-token': token } : {}),
441
+ ...opts,
442
+ };
443
+
444
+ const res = await this._signedRequest('GET', keyPath, {
445
+ query,
446
+ withQuery: true,
447
+ tolerated: [200, 404],
448
+ });
449
+
450
+ if (res.status === 404) {
451
+ return null;
452
+ }
453
+ if (res.status !== 200) {
454
+ const errorBody = await res.text();
455
+ const errorCode = res.headers.get('x-amz-error-code') || 'Unknown';
456
+ const errorMessage = res.headers.get('x-amz-error-message') || res.statusText;
457
+ this._log(
458
+ 'error',
459
+ `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
460
+ );
461
+ throw new Error(
462
+ `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
463
+ );
464
+ }
465
+
466
+ const raw = U.parseXml(await res.text()) as Record<string, unknown>;
467
+ if (typeof raw !== 'object' || !raw || 'error' in raw) {
468
+ this._log('error', `${C.ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`);
469
+ throw new Error(`${C.ERROR_PREFIX}Unexpected listObjects response shape`);
470
+ }
471
+ const out = ('listBucketResult' in raw ? raw.listBucketResult : raw) as Record<string, unknown>;
472
+
473
+ /* accumulate Contents */
474
+ const contents = out.contents;
475
+ if (contents) {
476
+ const batch = Array.isArray(contents) ? contents : [contents];
477
+ all.push(...(batch as object[]));
478
+ if (!unlimited) {
479
+ remaining -= batch.length;
480
+ }
481
+ }
482
+ const truncated = out.isTruncated === 'true' || out.IsTruncated === 'true';
483
+ token = truncated
484
+ ? ((out.nextContinuationToken || out.NextContinuationToken || out.nextMarker || out.NextMarker) as
485
+ | string
486
+ | undefined)
487
+ : undefined;
488
+ } while (token && remaining > 0);
489
+
490
+ return all;
491
+ }
492
+
493
+ public async listMultipartUploads(
494
+ delimiter: string = '/',
495
+ prefix: string = '',
496
+ method: IT.HttpMethod = 'GET',
497
+ opts: Record<string, string | number | boolean | undefined> = {},
498
+ ): Promise<IT.ListMultipartUploadSuccess | IT.MultipartUploadError> {
499
+ this._checkDelimiter(delimiter);
500
+ this._checkPrefix(prefix);
501
+ this._validateMethodIsGetOrHead(method);
502
+ this._checkOpts(opts);
503
+
504
+ const query = { uploads: '', ...opts };
505
+ const keyPath = delimiter === '/' ? delimiter : U.uriEscape(delimiter);
506
+
507
+ const res = await this._signedRequest(method, keyPath, {
508
+ query,
509
+ withQuery: true,
510
+ });
511
+ // doublecheck if this is needed
512
+ // if (method === 'HEAD') {
513
+ // return {
514
+ // size: +(res.headers.get(C.HEADER_CONTENT_LENGTH) ?? '0'),
515
+ // mtime: res.headers.get(C.HEADER_LAST_MODIFIED) ? new Date(res.headers.get(C.HEADER_LAST_MODIFIED)!) : undefined,
516
+ // etag: res.headers.get(C.HEADER_ETAG) ?? '',
517
+ // };
518
+ // }
519
+
520
+ const raw = U.parseXml(await res.text()) as unknown;
521
+ if (typeof raw !== 'object' || raw === null) {
522
+ throw new Error(`${C.ERROR_PREFIX}Unexpected listMultipartUploads response shape`);
523
+ }
524
+ if ('listMultipartUploadsResult' in raw) {
525
+ return raw.listMultipartUploadsResult as IT.ListMultipartUploadSuccess;
526
+ }
527
+ return raw as IT.MultipartUploadError;
528
+ }
529
+
530
+ public async getObject(key: string, opts: Record<string, unknown> = {}): Promise<string | null> {
531
+ const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
532
+ if ([404, 412, 304].includes(res.status)) {
533
+ return null;
534
+ }
535
+ return res.text();
536
+ }
537
+
538
+ public async getObjectArrayBuffer(key: string, opts: Record<string, unknown> = {}): Promise<ArrayBuffer | null> {
539
+ const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
540
+ if ([404, 412, 304].includes(res.status)) {
541
+ return null;
542
+ }
543
+ return res.arrayBuffer();
544
+ }
545
+
546
+ public async getObjectWithETag(
547
+ key: string,
548
+ opts: Record<string, unknown> = {},
549
+ ): Promise<{ etag: string | null; data: ArrayBuffer | null }> {
550
+ try {
551
+ const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
552
+
553
+ if ([404, 412, 304].includes(res.status)) {
554
+ return { etag: null, data: null };
555
+ }
556
+
557
+ const etag = res.headers.get(C.HEADER_ETAG);
558
+ if (!etag) {
559
+ throw new Error('ETag not found in response headers');
560
+ }
561
+ return { etag: U.sanitizeETag(etag), data: await res.arrayBuffer() };
562
+ } catch (err) {
563
+ this._log('error', `Error getting object ${key} with ETag: ${String(err)}`);
564
+ throw err;
565
+ }
566
+ }
567
+
568
+ public async getObjectRaw(
569
+ key: string,
570
+ wholeFile = true,
571
+ rangeFrom = 0,
572
+ rangeTo = this.requestSizeInBytes,
573
+ opts: Record<string, unknown> = {},
574
+ ): Promise<Response> {
575
+ const rangeHdr: Record<string, string | number> = wholeFile ? {} : { range: `bytes=${rangeFrom}-${rangeTo - 1}` };
576
+
577
+ return this._signedRequest('GET', key, {
578
+ query: { ...opts },
579
+ headers: rangeHdr,
580
+ withQuery: true, // keep ?query=string behaviour
581
+ });
582
+ }
583
+
584
+ public async getContentLength(key: string): Promise<number> {
585
+ const res = await this._signedRequest('HEAD', key);
586
+ const len = res.headers.get(C.HEADER_CONTENT_LENGTH);
587
+ return len ? +len : 0;
588
+ }
589
+
590
+ public async objectExists(key: string, opts: Record<string, unknown> = {}): Promise<IT.ExistResponseCode> {
591
+ const res = await this._signedRequest('HEAD', key, {
592
+ query: opts,
593
+ tolerated: [200, 404, 412, 304],
594
+ });
595
+
596
+ if (res.status === 404) {
597
+ return false; // not found
598
+ }
599
+ if (res.status === 412 || res.status === 304) {
600
+ return null; // ETag mismatch
601
+ }
602
+ return true; // found (200)
603
+ }
604
+
605
+ public async getEtag(key: string, opts: Record<string, unknown> = {}): Promise<string | null> {
606
+ const res = await this._signedRequest('HEAD', key, {
607
+ query: opts,
608
+ tolerated: [200, 404],
609
+ });
610
+
611
+ if (res.status === 404) {
612
+ return null;
613
+ }
614
+
615
+ const etag = res.headers.get(C.HEADER_ETAG);
616
+ if (!etag) {
617
+ throw new Error('ETag not found in response headers');
618
+ }
619
+
620
+ return U.sanitizeETag(etag);
621
+ }
622
+
623
+ public async putObject(key: string, data: string | Buffer): Promise<Response> {
624
+ if (!(data instanceof Buffer || typeof data === 'string')) {
625
+ throw new TypeError(C.ERROR_DATA_BUFFER_REQUIRED);
626
+ }
627
+ return this._signedRequest('PUT', key, {
628
+ body: data,
629
+ headers: { [C.HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length },
630
+ tolerated: [200],
631
+ });
632
+ }
633
+
634
+ public async getMultipartUploadId(key: string, fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE): Promise<string> {
635
+ this._checkKey(key);
636
+ if (typeof fileType !== 'string') {
637
+ throw new TypeError(`${C.ERROR_PREFIX}fileType must be a string`);
638
+ }
639
+ const query = { uploads: '' };
640
+ const headers = { [C.HEADER_CONTENT_TYPE]: fileType };
641
+
642
+ const res = await this._signedRequest('POST', key, {
643
+ query,
644
+ headers,
645
+ withQuery: true,
646
+ });
647
+
648
+ const parsed = U.parseXml(await res.text()) as unknown;
649
+
650
+ if (
651
+ parsed &&
652
+ typeof parsed === 'object' &&
653
+ 'initiateMultipartUploadResult' in parsed &&
654
+ parsed.initiateMultipartUploadResult &&
655
+ 'uploadId' in (parsed.initiateMultipartUploadResult as { uploadId: string })
656
+ ) {
657
+ return (parsed.initiateMultipartUploadResult as { uploadId: string }).uploadId;
658
+ }
659
+
660
+ throw new Error(`${C.ERROR_PREFIX}Failed to create multipart upload: ${JSON.stringify(parsed)}`);
661
+ }
662
+
663
+ public async uploadPart(
664
+ key: string,
665
+ uploadId: string,
666
+ data: Buffer | string,
667
+ partNumber: number,
668
+ opts: Record<string, unknown> = {},
669
+ ): Promise<IT.UploadPart> {
670
+ this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
671
+
672
+ const query = { uploadId, partNumber, ...opts };
673
+ const res = await this._signedRequest('PUT', key, {
674
+ query,
675
+ body: data,
676
+ headers: { [C.HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length },
677
+ });
678
+
679
+ return { partNumber, etag: U.sanitizeETag(res.headers.get('etag') || '') };
680
+ }
681
+
682
+ public async completeMultipartUpload(
683
+ key: string,
684
+ uploadId: string,
685
+ parts: Array<IT.UploadPart>,
686
+ ): Promise<IT.CompleteMultipartUploadResult> {
687
+ // …existing validation left untouched …
688
+
689
+ const query = { uploadId };
690
+ const xmlBody = this._buildCompleteMultipartUploadXml(parts);
691
+ const headers = {
692
+ [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
693
+ [C.HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(),
694
+ };
695
+
696
+ const res = await this._signedRequest('POST', key, {
697
+ query,
698
+ body: xmlBody,
699
+ headers,
700
+ withQuery: true,
701
+ });
702
+
703
+ const parsed = U.parseXml(await res.text()) as unknown;
704
+
705
+ const result: unknown =
706
+ parsed && typeof parsed === 'object' && 'completeMultipartUploadResult' in parsed
707
+ ? (parsed as { completeMultipartUploadResult: unknown }).completeMultipartUploadResult
708
+ : parsed;
709
+
710
+ if (!result || typeof result !== 'object') {
711
+ throw new Error(`${C.ERROR_PREFIX}Failed to complete multipart upload: ${JSON.stringify(parsed)}`);
712
+ }
713
+ if ('ETag' in result || 'eTag' in result) {
714
+ (result as IT.CompleteMultipartUploadResult).etag = this.sanitizeETag(
715
+ (result as IT.CompleteMultipartUploadResult).eTag ?? (result as IT.CompleteMultipartUploadResult).ETag,
716
+ );
717
+ }
718
+ return result as IT.CompleteMultipartUploadResult;
719
+ }
720
+
721
+ public async abortMultipartUpload(key: string, uploadId: string): Promise<object> {
722
+ this._checkKey(key);
723
+ if (!uploadId) {
724
+ throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED);
725
+ }
726
+
727
+ const query = { uploadId };
728
+ const headers = { [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE };
729
+
730
+ const res = await this._signedRequest('DELETE', key, {
731
+ query,
732
+ headers,
733
+ withQuery: true,
734
+ });
735
+
736
+ const parsed = U.parseXml(await res.text()) as object;
737
+ if (
738
+ parsed &&
739
+ 'error' in parsed &&
740
+ typeof parsed.error === 'object' &&
741
+ parsed.error !== null &&
742
+ 'message' in parsed.error
743
+ ) {
744
+ this._log('error', `${C.ERROR_PREFIX}Failed to abort multipart upload: ${String(parsed.error.message)}`);
745
+ throw new Error(`${C.ERROR_PREFIX}Failed to abort multipart upload: ${String(parsed.error.message)}`);
746
+ }
747
+ return { status: 'Aborted', key, uploadId, response: parsed };
748
+ }
749
+
750
+ private _buildCompleteMultipartUploadXml(parts: Array<IT.UploadPart>): string {
751
+ return `
752
+ <CompleteMultipartUpload>
753
+ ${parts
754
+ .map(
755
+ part => `
756
+ <Part>
757
+ <PartNumber>${part.partNumber}</PartNumber>
758
+ <ETag>${part.etag}</ETag>
759
+ </Part>
760
+ `,
761
+ )
762
+ .join('')}
763
+ </CompleteMultipartUpload>
764
+ `;
765
+ }
766
+
767
+ public async deleteObject(key: string): Promise<boolean> {
768
+ const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] });
769
+ return res.status === 200 || res.status === 204;
770
+ }
771
+
772
+ private async _sendRequest(
773
+ url: string,
774
+ method: IT.HttpMethod,
775
+ headers: Record<string, string>,
776
+ body?: string | Buffer,
777
+ toleratedStatusCodes: number[] = [],
778
+ ): Promise<Response> {
779
+ this._log('info', `Sending ${method} request to ${url}`, `headers: ${JSON.stringify(headers)}`);
780
+ try {
781
+ const res = await fetch(url, {
782
+ method,
783
+ headers,
784
+ keepalive: true,
785
+ body: ['GET', 'HEAD'].includes(method) ? undefined : (body as string),
786
+ signal: this.requestAbortTimeout !== undefined ? AbortSignal.timeout(this.requestAbortTimeout) : undefined,
787
+ });
788
+ this._log('info', `Response status: ${res.status}, tolerated: ${toleratedStatusCodes.join(',')}`);
789
+ if (!res.ok && !toleratedStatusCodes.includes(res.status)) {
790
+ await this._handleErrorResponse(res);
791
+ }
792
+ return res;
793
+ } catch (err: unknown) {
794
+ const code = U.extractErrCode(err);
795
+ if (code && ['ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT', 'ECONNREFUSED'].includes(code)) {
796
+ throw new U.S3NetworkError(`S3 network error: ${code}`, code, err);
797
+ }
798
+ throw err;
799
+ }
800
+ }
801
+
802
+ private async _handleErrorResponse(res: Response): Promise<void> {
803
+ const errorBody = await res.text();
804
+ const svcCode = res.headers.get('x-amz-error-code') ?? 'Unknown';
805
+ const errorMessage = res.headers.get('x-amz-error-message') || res.statusText;
806
+ this._log(
807
+ 'error',
808
+ `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${svcCode} - ${errorMessage},err body: ${errorBody}`,
809
+ );
810
+ throw new U.S3ServiceError(`S3 returned ${res.status} – ${svcCode}`, res.status, svcCode, errorBody);
811
+ }
812
+
813
+ private _buildCanonicalQueryString(queryParams: Record<string, unknown>): string {
814
+ if (!queryParams || Object.keys(queryParams).length === 0) {
815
+ return '';
816
+ }
817
+ return Object.keys(queryParams)
818
+ .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key] as string)}`)
819
+ .sort()
820
+ .join('&');
821
+ }
822
+ private _getSignatureKey(dateStamp: string): Buffer {
823
+ const kDate = U.hmac(`AWS4${this.secretAccessKey}`, dateStamp) as Buffer;
824
+ const kRegion = U.hmac(kDate, this.region) as Buffer;
825
+ const kService = U.hmac(kRegion, C.S3_SERVICE) as Buffer;
826
+ return U.hmac(kService, C.AWS_REQUEST_TYPE) as Buffer;
827
+ }
828
+ }
829
+
830
+ export { s3mini };
831
+ export default s3mini;