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