UNPKG

s3mini

Version:

👶 Tiny & fast S3 client for node and edge computing platforms

1,278 lines (1,176 loc) • 49.6 kB
'use strict'; import * as C from './consts.js'; import type * as IT from './types.js'; import * as U from './utils.js'; /** * S3 class for interacting with S3-compatible object storage services. * This class provides methods for common S3 operations such as uploading, downloading, * and deleting objects, as well as multipart uploads. * * @class * @example * const s3 = new CoreS3({ * accessKeyId: 'your-access-key', * secretAccessKey: 'your-secret-key', * endpoint: 'https://your-s3-endpoint.com', * region: 'us-east-1' // by default is auto * }); * * // Upload a file * await s3.putObject('example.txt', 'Hello, World!'); * * // Download a file * const content = await s3.getObject('example.txt'); * * // Delete a file * await s3.deleteObject('example.txt'); */ class S3mini { /** * Creates an instance of the S3 class. * * @constructor * @param {Object} config - Configuration options for the S3 instance. * @param {string} config.accessKeyId - The access key ID for authentication. * @param {string} config.secretAccessKey - The secret access key for authentication. * @param {string} config.endpoint - The endpoint URL of the S3-compatible service. * @param {string} [config.region='auto'] - The region of the S3 service. * @param {number} [config.requestSizeInBytes=8388608] - The request size of a single request in bytes (AWS S3 is 8MB). * @param {number} [config.requestAbortTimeout=undefined] - The timeout in milliseconds after which a request should be aborted (careful on streamed requests). * @param {Object} [config.logger=null] - A logger object with methods like info, warn, error. * @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type. */ private accessKeyId: string; private secretAccessKey: string; private endpoint: string; private region: string; private requestSizeInBytes: number; private requestAbortTimeout?: number; private logger?: IT.Logger; private signingKeyDate?: string; private signingKey?: Buffer; constructor({ accessKeyId, secretAccessKey, endpoint, region = 'auto', requestSizeInBytes = C.DEFAULT_REQUEST_SIZE_IN_BYTES, requestAbortTimeout = undefined, logger = undefined, }: IT.S3Config) { this._validateConstructorParams(accessKeyId, secretAccessKey, endpoint); this.accessKeyId = accessKeyId; this.secretAccessKey = secretAccessKey; this.endpoint = this._ensureValidUrl(endpoint); this.region = region; this.requestSizeInBytes = requestSizeInBytes; this.requestAbortTimeout = requestAbortTimeout; this.logger = logger; } private _sanitize(obj: unknown): unknown { if (typeof obj !== 'object' || obj === null) { return obj; } return Object.keys(obj).reduce( (acc: Record<string, unknown>, key) => { if (C.SENSITIVE_KEYS_REDACTED.includes(key.toLowerCase())) { acc[key] = '[REDACTED]'; } else if ( typeof (obj as Record<string, unknown>)[key] === 'object' && (obj as Record<string, unknown>)[key] !== null ) { acc[key] = this._sanitize((obj as Record<string, unknown>)[key]); } else { acc[key] = (obj as Record<string, unknown>)[key]; } return acc; }, Array.isArray(obj) ? [] : {}, ); } private _log( level: 'info' | 'warn' | 'error', message: string, additionalData: Record<string, unknown> | string = {}, ): void { if (this.logger && typeof this.logger[level] === 'function') { // Function to recursively sanitize an object // Sanitize the additional data const sanitizedData = this._sanitize(additionalData); // Prepare the log entry const logEntry = { timestamp: new Date().toISOString(), level, message, details: sanitizedData, // Include some general context, but sanitize sensitive parts context: this._sanitize({ region: this.region, endpoint: this.endpoint, // Only include the first few characters of the access key, if it exists accessKeyId: this.accessKeyId ? `${this.accessKeyId.substring(0, 4)}...` : undefined, }), }; // Log the sanitized entry this.logger[level](JSON.stringify(logEntry)); } } private _validateConstructorParams(accessKeyId: string, secretAccessKey: string, endpoint: string): void { if (typeof accessKeyId !== 'string' || accessKeyId.trim().length === 0) { throw new TypeError(C.ERROR_ACCESS_KEY_REQUIRED); } if (typeof secretAccessKey !== 'string' || secretAccessKey.trim().length === 0) { throw new TypeError(C.ERROR_SECRET_KEY_REQUIRED); } if (typeof endpoint !== 'string' || endpoint.trim().length === 0) { throw new TypeError(C.ERROR_ENDPOINT_REQUIRED); } } private _ensureValidUrl(raw: string): string { const candidate = /^(https?:)?\/\//i.test(raw) ? raw : `https://${raw}`; try { new URL(candidate); // Find the last non-slash character let endIndex = candidate.length; while (endIndex > 0 && candidate[endIndex - 1] === '/') { endIndex--; } return endIndex === candidate.length ? candidate : candidate.substring(0, endIndex); } catch { const msg = `${C.ERROR_ENDPOINT_FORMAT} But provided: "${raw}"`; this._log('error', msg); throw new TypeError(msg); } } private _validateMethodIsGetOrHead(method: string): void { if (method !== 'GET' && method !== 'HEAD') { this._log('error', `${C.ERROR_PREFIX}method must be either GET or HEAD`); throw new Error(`${C.ERROR_PREFIX}method must be either GET or HEAD`); } } private _checkKey(key: string): void { if (typeof key !== 'string' || key.trim().length === 0) { this._log('error', C.ERROR_KEY_REQUIRED); throw new TypeError(C.ERROR_KEY_REQUIRED); } } private _checkDelimiter(delimiter: string): void { if (typeof delimiter !== 'string' || delimiter.trim().length === 0) { this._log('error', C.ERROR_DELIMITER_REQUIRED); throw new TypeError(C.ERROR_DELIMITER_REQUIRED); } } private _checkPrefix(prefix: string): void { if (typeof prefix !== 'string') { this._log('error', C.ERROR_PREFIX_TYPE); throw new TypeError(C.ERROR_PREFIX_TYPE); } } // private _checkMaxKeys(maxKeys: number): void { // if (typeof maxKeys !== 'number' || maxKeys <= 0) { // this._log('error', C.ERROR_MAX_KEYS_TYPE); // throw new TypeError(C.ERROR_MAX_KEYS_TYPE); // } // } private _checkOpts(opts: object): void { if (typeof opts !== 'object') { this._log('error', `${C.ERROR_PREFIX}opts must be an object`); throw new TypeError(`${C.ERROR_PREFIX}opts must be an object`); } } private _filterIfHeaders(opts: Record<string, unknown>): { filteredOpts: Record<string, string>; conditionalHeaders: Record<string, unknown>; } { const filteredOpts: Record<string, string> = {}; const conditionalHeaders: Record<string, unknown> = {}; const ifHeaders = ['if-match', 'if-none-match', 'if-modified-since', 'if-unmodified-since']; for (const [key, value] of Object.entries(opts)) { if (ifHeaders.includes(key.toLowerCase())) { // Convert to lowercase for consistency conditionalHeaders[key] = value; } else { filteredOpts[key] = value as string; } } return { filteredOpts, conditionalHeaders }; } private _validateUploadPartParams( key: string, uploadId: string, data: Buffer | string, partNumber: number, opts: object, ): void { this._checkKey(key); if (!(data instanceof Buffer || typeof data === 'string')) { this._log('error', C.ERROR_DATA_BUFFER_REQUIRED); throw new TypeError(C.ERROR_DATA_BUFFER_REQUIRED); } if (typeof uploadId !== 'string' || uploadId.trim().length === 0) { this._log('error', C.ERROR_UPLOAD_ID_REQUIRED); throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED); } if (!Number.isInteger(partNumber) || partNumber <= 0) { this._log('error', `${C.ERROR_PREFIX}partNumber must be a positive integer`); throw new TypeError(`${C.ERROR_PREFIX}partNumber must be a positive integer`); } this._checkOpts(opts); } private _sign( method: IT.HttpMethod, keyPath: string, query: Record<string, unknown> = {}, headers: Record<string, string | number> = {}, ): { url: string; headers: Record<string, string | number> } { // Create URL without appending keyPath first const url = new URL(this.endpoint); // Properly format the pathname to avoid double slashes if (keyPath && keyPath.length > 0) { url.pathname = url.pathname === '/' ? `/${keyPath.replace(/^\/+/, '')}` : `${url.pathname}/${keyPath.replace(/^\/+/, '')}`; } const fullDatetime = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ''); const shortDatetime = fullDatetime.slice(0, 8); const credentialScope = this._buildCredentialScope(shortDatetime); headers[C.HEADER_AMZ_CONTENT_SHA256] = C.UNSIGNED_PAYLOAD; // body ? U.hash(body) : C.UNSIGNED_PAYLOAD; headers[C.HEADER_AMZ_DATE] = fullDatetime; headers[C.HEADER_HOST] = url.host; // sort headers alphabetically by key const ignoredHeaders = ['authorization', 'content-length', 'content-type', 'user-agent']; let headersForSigning = Object.fromEntries( Object.entries(headers).filter(([key]) => !ignoredHeaders.includes(key.toLowerCase())), ); headersForSigning = Object.fromEntries( Object.entries(headersForSigning).sort(([keyA], [keyB]) => keyA.localeCompare(keyB)), ); const canonicalHeaders = this._buildCanonicalHeaders(headersForSigning); const signedHeaders = Object.keys(headersForSigning) .map(key => key.toLowerCase()) .sort() .join(';'); const canonicalRequest = this._buildCanonicalRequest(method, url, query, canonicalHeaders, signedHeaders); const stringToSign = this._buildStringToSign(fullDatetime, credentialScope, canonicalRequest); const signature = this._calculateSignature(shortDatetime, stringToSign); const authorizationHeader = this._buildAuthorizationHeader(credentialScope, signedHeaders, signature); headers[C.HEADER_AUTHORIZATION] = authorizationHeader; return { url: url.toString(), headers }; } private _buildCanonicalHeaders(headers: Record<string, string | number>): string { return Object.entries(headers) .map(([key, value]) => `${key.toLowerCase()}:${String(value).trim()}`) .join('\n'); } private _buildCanonicalRequest( method: IT.HttpMethod, url: URL, query: Record<string, unknown>, canonicalHeaders: string, signedHeaders: string, ): string { const parts = [ method, url.pathname, this._buildCanonicalQueryString(query), canonicalHeaders + '\n', // Canonical headers end with extra newline signedHeaders, C.UNSIGNED_PAYLOAD, ]; return parts.join('\n'); } private _buildCredentialScope(shortDatetime: string): string { return [shortDatetime, this.region, C.S3_SERVICE, C.AWS_REQUEST_TYPE].join('/'); } private _buildStringToSign(fullDatetime: string, credentialScope: string, canonicalRequest: string): string { return [C.AWS_ALGORITHM, fullDatetime, credentialScope, U.hash(canonicalRequest)].join('\n'); } private _calculateSignature(shortDatetime: string, stringToSign: string): string { if (shortDatetime !== this.signingKeyDate) { this.signingKeyDate = shortDatetime; this.signingKey = this._getSignatureKey(shortDatetime); } return U.hmac(this.signingKey!, stringToSign, 'hex') as string; } private _buildAuthorizationHeader(credentialScope: string, signedHeaders: string, signature: string): string { return [ `${C.AWS_ALGORITHM} Credential=${this.accessKeyId}/${credentialScope}`, `SignedHeaders=${signedHeaders}`, `Signature=${signature}`, ].join(', '); } private async _signedRequest( method: IT.HttpMethod, // 'GET' | 'HEAD' | 'PUT' | 'POST' | 'DELETE' key: string, // ‘’ allowed for bucket‑level ops { query = {}, // ?query=string body = '', // string | Buffer | undefined headers = {}, // extra/override headers tolerated = [], // [200, 404] etc. withQuery = false, // append query string to signed URL }: { query?: Record<string, unknown> | undefined; body?: string | Buffer | undefined; headers?: Record<string, string | number | undefined> | IT.SSECHeaders | undefined; tolerated?: number[] | undefined; withQuery?: boolean | undefined; } = {}, ): Promise<Response> { // Basic validation if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) { throw new Error(`${C.ERROR_PREFIX}Unsupported HTTP method ${method as string}`); } const { filteredOpts, conditionalHeaders } = ['GET', 'HEAD'].includes(method) ? this._filterIfHeaders(query) : { filteredOpts: query, conditionalHeaders: {} }; const baseHeaders: Record<string, string | number> = { [C.HEADER_AMZ_CONTENT_SHA256]: C.UNSIGNED_PAYLOAD, // ...(['GET', 'HEAD'].includes(method) ? { [C.HEADER_CONTENT_TYPE]: C.JSON_CONTENT_TYPE } : {}), ...headers, ...conditionalHeaders, }; const encodedKey = key ? U.uriResourceEscape(key) : ''; const { url, headers: signedHeaders } = this._sign(method, encodedKey, filteredOpts, baseHeaders); if (Object.keys(query).length > 0) { withQuery = true; // append query string to signed URL } const filteredOptsStrings = Object.fromEntries( Object.entries(filteredOpts).map(([k, v]) => [k, String(v)]), ) as Record<string, string>; const finalUrl = withQuery && Object.keys(filteredOpts).length ? `${url}?${new URLSearchParams(filteredOptsStrings)}` : url; const signedHeadersString = Object.fromEntries( Object.entries(signedHeaders).map(([k, v]) => [k, String(v)]), ) as Record<string, string>; return this._sendRequest(finalUrl, method, signedHeadersString, body, tolerated); } /** * Gets the current configuration properties of the S3 instance. * @returns {IT.S3Config} The current S3 configuration object containing all settings. * @example * const config = s3.getProps(); * console.log(config.endpoint); // 'https://s3.amazonaws.com/my-bucket' */ public getProps(): IT.S3Config { return { accessKeyId: this.accessKeyId, secretAccessKey: this.secretAccessKey, endpoint: this.endpoint, region: this.region, requestSizeInBytes: this.requestSizeInBytes, requestAbortTimeout: this.requestAbortTimeout, logger: this.logger, }; } /** * Updates the configuration properties of the S3 instance. * @param {IT.S3Config} props - The new configuration object. * @param {string} props.accessKeyId - The access key ID for authentication. * @param {string} props.secretAccessKey - The secret access key for authentication. * @param {string} props.endpoint - The endpoint URL of the S3-compatible service. * @param {string} [props.region='auto'] - The region of the S3 service. * @param {number} [props.requestSizeInBytes=8388608] - The request size of a single request in bytes. * @param {number} [props.requestAbortTimeout] - The timeout in milliseconds after which a request should be aborted. * @param {IT.Logger} [props.logger] - A logger object with methods like info, warn, error. * @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type. * @example * s3.setProps({ * accessKeyId: 'new-access-key', * secretAccessKey: 'new-secret-key', * endpoint: 'https://new-endpoint.com/my-bucket', * region: 'us-west-2' // by default is auto * }); */ public setProps(props: IT.S3Config): void { this._validateConstructorParams(props.accessKeyId, props.secretAccessKey, props.endpoint); this.accessKeyId = props.accessKeyId; this.secretAccessKey = props.secretAccessKey; this.region = props.region || 'auto'; this.endpoint = props.endpoint; this.requestSizeInBytes = props.requestSizeInBytes || C.DEFAULT_REQUEST_SIZE_IN_BYTES; this.requestAbortTimeout = props.requestAbortTimeout; this.logger = props.logger; } /** * Sanitizes an ETag value by removing surrounding quotes and whitespace. * Still returns RFC compliant ETag. https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3 * @param {string} etag - The ETag value to sanitize. * @returns {string} The sanitized ETag value. * @example * const cleanEtag = s3.sanitizeETag('"abc123"'); // Returns: 'abc123' */ public sanitizeETag(etag: string): string { return U.sanitizeETag(etag); } /** * Creates a new bucket. * This method sends a request to create a new bucket in the specified in endpoint. * @returns A promise that resolves to true if the bucket was created successfully, false otherwise. */ public async createBucket(): Promise<boolean> { const xmlBody = ` <CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> <LocationConstraint>${this.region}</LocationConstraint> </CreateBucketConfiguration> `; const headers = { [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE, [C.HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(), }; const res = await this._signedRequest('PUT', '', { body: xmlBody, headers, tolerated: [200, 404, 403, 409], // don’t throw on 404/403 // 409 = bucket already exists }); return res.status === 200; } /** * Checks if a bucket exists. * This method sends a request to check if the specified bucket exists in the S3-compatible service. * @returns A promise that resolves to true if the bucket exists, false otherwise. */ public async bucketExists(): Promise<boolean> { const res = await this._signedRequest('HEAD', '', { tolerated: [200, 404, 403] }); return res.status === 200; } /** * Lists objects in the bucket with optional filtering and no pagination. * This method retrieves all objects matching the criteria (not paginated like listObjectsV2). * @param {string} [delimiter='/'] - The delimiter to use for grouping objects. * @param {string} [prefix=''] - The prefix to filter objects by. * @param {number} [maxKeys] - The maximum number of keys to return. If not provided, all keys will be returned. * @param {Record<string, unknown>} [opts={}] - Additional options for the request. * @returns {Promise<IT.ListObject[] | null>} A promise that resolves to an array of objects or null if the bucket is empty. * @example * // List all objects * const objects = await s3.listObjects(); * * // List objects with prefix * const photos = await s3.listObjects('/', 'photos/', 100); */ public async listObjects( delimiter: string = '/', prefix: string = '', maxKeys?: number, // method: IT.HttpMethod = 'GET', // 'GET' or 'HEAD' opts: Record<string, unknown> = {}, ): Promise<IT.ListObject[] | null> { this._checkDelimiter(delimiter); this._checkPrefix(prefix); this._checkOpts(opts); const keyPath = delimiter === '/' ? delimiter : U.uriEscape(delimiter); const unlimited = !(maxKeys && maxKeys > 0); let remaining = unlimited ? Infinity : maxKeys; let token: string | undefined; const all: IT.ListObject[] = []; do { const batchSize = Math.min(remaining, 1000); // S3 ceiling const query: Record<string, unknown> = { 'list-type': C.LIST_TYPE, // =2 for V2 'max-keys': String(batchSize), ...(prefix ? { prefix } : {}), ...(token ? { 'continuation-token': token } : {}), ...opts, }; const res = await this._signedRequest('GET', keyPath, { query, withQuery: true, tolerated: [200, 404], }); if (res.status === 404) { return null; } if (res.status !== 200) { const errorBody = await res.text(); const errorCode = res.headers.get('x-amz-error-code') || 'Unknown'; const errorMessage = res.headers.get('x-amz-error-message') || res.statusText; this._log( 'error', `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`, ); throw new Error( `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`, ); } const raw = U.parseXml(await res.text()) as Record<string, unknown>; if (typeof raw !== 'object' || !raw || 'error' in raw) { this._log('error', `${C.ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`); throw new Error(`${C.ERROR_PREFIX}Unexpected listObjects response shape`); } const out = (raw.ListBucketResult || raw.listBucketResult || raw) as Record<string, unknown>; /* accumulate Contents */ const contents = out.Contents || out.contents; // S3 v2 vs v1 if (contents) { const batch = Array.isArray(contents) ? contents : [contents]; all.push(...(batch as IT.ListObject[])); if (!unlimited) { remaining -= batch.length; } } const truncated = out.IsTruncated === 'true' || out.isTruncated === 'true' || false; token = truncated ? ((out.NextContinuationToken || out.nextContinuationToken || out.NextMarker || out.nextMarker) as | string | undefined) : undefined; } while (token && remaining > 0); return all; } /** * Lists multipart uploads in the bucket. * This method sends a request to list multipart uploads in the specified bucket. * @param {string} [delimiter='/'] - The delimiter to use for grouping uploads. * @param {string} [prefix=''] - The prefix to filter uploads by. * @param {IT.HttpMethod} [method='GET'] - The HTTP method to use for the request (GET or HEAD). * @param {Record<string, string | number | boolean | undefined>} [opts={}] - Additional options for the request. * @returns A promise that resolves to a list of multipart uploads or an error. */ public async listMultipartUploads( delimiter: string = '/', prefix: string = '', method: IT.HttpMethod = 'GET', opts: Record<string, string | number | boolean | undefined> = {}, ): Promise<IT.ListMultipartUploadSuccess | IT.MultipartUploadError> { this._checkDelimiter(delimiter); this._checkPrefix(prefix); this._validateMethodIsGetOrHead(method); this._checkOpts(opts); const query = { uploads: '', ...opts }; const keyPath = delimiter === '/' ? delimiter : U.uriEscape(delimiter); const res = await this._signedRequest(method, keyPath, { query, withQuery: true, }); // doublecheck if this is needed // if (method === 'HEAD') { // return { // size: +(res.headers.get(C.HEADER_CONTENT_LENGTH) ?? '0'), // mtime: res.headers.get(C.HEADER_LAST_MODIFIED) ? new Date(res.headers.get(C.HEADER_LAST_MODIFIED)!) : undefined, // etag: res.headers.get(C.HEADER_ETAG) ?? '', // }; // } const raw = U.parseXml(await res.text()) as unknown; if (typeof raw !== 'object' || raw === null) { throw new Error(`${C.ERROR_PREFIX}Unexpected listMultipartUploads response shape`); } if ('listMultipartUploadsResult' in raw) { return raw.listMultipartUploadsResult as IT.ListMultipartUploadSuccess; } return raw as IT.MultipartUploadError; } /** * Get an object from the S3-compatible service. * This method sends a request to retrieve the specified object from the S3-compatible service. * @param {string} key - The key of the object to retrieve. * @param {Record<string, unknown>} [opts] - Additional options for the request. * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any. * @returns A promise that resolves to the object data (string) or null if not found. */ public async getObject( key: string, opts: Record<string, unknown> = {}, ssecHeaders?: IT.SSECHeaders, ): Promise<string | null> { // if ssecHeaders is set, add it to headers const res = await this._signedRequest('GET', key, { query: opts, // use opts.query if it exists, otherwise use an empty object tolerated: [200, 404, 412, 304], headers: ssecHeaders ? { ...ssecHeaders } : undefined, }); if ([404, 412, 304].includes(res.status)) { return null; } return res.text(); } /** * Get an object response from the S3-compatible service. * This method sends a request to retrieve the specified object and returns the full response. * @param {string} key - The key of the object to retrieve. * @param {Record<string, unknown>} [opts={}] - Additional options for the request. * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any. * @returns A promise that resolves to the Response object or null if not found. */ public async getObjectResponse( key: string, opts: Record<string, unknown> = {}, ssecHeaders?: IT.SSECHeaders, ): Promise<Response | null> { const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304], headers: ssecHeaders ? { ...ssecHeaders } : undefined, }); if ([404, 412, 304].includes(res.status)) { return null; } return res; } /** * Get an object as an ArrayBuffer from the S3-compatible service. * This method sends a request to retrieve the specified object and returns it as an ArrayBuffer. * @param {string} key - The key of the object to retrieve. * @param {Record<string, unknown>} [opts={}] - Additional options for the request. * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any. * @returns A promise that resolves to the object data as an ArrayBuffer or null if not found. */ public async getObjectArrayBuffer( key: string, opts: Record<string, unknown> = {}, ssecHeaders?: IT.SSECHeaders, ): Promise<ArrayBuffer | null> { const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304], headers: ssecHeaders ? { ...ssecHeaders } : undefined, }); if ([404, 412, 304].includes(res.status)) { return null; } return res.arrayBuffer(); } /** * Get an object as JSON from the S3-compatible service. * This method sends a request to retrieve the specified object and returns it as JSON. * @param {string} key - The key of the object to retrieve. * @param {Record<string, unknown>} [opts={}] - Additional options for the request. * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any. * @returns A promise that resolves to the object data as JSON or null if not found. */ public async getObjectJSON<T = unknown>( key: string, opts: Record<string, unknown> = {}, ssecHeaders?: IT.SSECHeaders, ): Promise<T | null> { const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304], headers: ssecHeaders ? { ...ssecHeaders } : undefined, }); if ([404, 412, 304].includes(res.status)) { return null; } return res.json() as Promise<T>; } /** * Get an object with its ETag from the S3-compatible service. * This method sends a request to retrieve the specified object and its ETag. * @param {string} key - The key of the object to retrieve. * @param {Record<string, unknown>} [opts={}] - Additional options for the request. * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any. * @returns A promise that resolves to an object containing the ETag and the object data as an ArrayBuffer or null if not found. */ public async getObjectWithETag( key: string, opts: Record<string, unknown> = {}, ssecHeaders?: IT.SSECHeaders, ): Promise<{ etag: string | null; data: ArrayBuffer | null }> { try { const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304], headers: ssecHeaders ? { ...ssecHeaders } : undefined, }); if ([404, 412, 304].includes(res.status)) { return { etag: null, data: null }; } const etag = res.headers.get(C.HEADER_ETAG); if (!etag) { throw new Error(`${C.ERROR_PREFIX}ETag not found in response headers`); } return { etag: U.sanitizeETag(etag), data: await res.arrayBuffer() }; } catch (err) { this._log('error', `Error getting object ${key} with ETag: ${String(err)}`); throw err; } } /** * Get an object as a raw response from the S3-compatible service. * This method sends a request to retrieve the specified object and returns the raw response. * @param {string} key - The key of the object to retrieve. * @param {boolean} [wholeFile=true] - Whether to retrieve the whole file or a range. * @param {number} [rangeFrom=0] - The starting byte for the range (if not whole file). * @param {number} [rangeTo=this.requestSizeInBytes] - The ending byte for the range (if not whole file). * @param {Record<string, unknown>} [opts={}] - Additional options for the request. * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any. * @returns A promise that resolves to the Response object. */ public async getObjectRaw( key: string, wholeFile = true, rangeFrom = 0, rangeTo = this.requestSizeInBytes, opts: Record<string, unknown> = {}, ssecHeaders?: IT.SSECHeaders, ): Promise<Response> { const rangeHdr: Record<string, string | number> = wholeFile ? {} : { range: `bytes=${rangeFrom}-${rangeTo - 1}` }; return this._signedRequest('GET', key, { query: { ...opts }, headers: { ...rangeHdr, ...ssecHeaders }, withQuery: true, // keep ?query=string behaviour }); } /** * Get the content length of an object. * This method sends a HEAD request to retrieve the content length of the specified object. * @param {string} key - The key of the object to retrieve the content length for. * @returns A promise that resolves to the content length of the object in bytes, or 0 if not found. * @throws {Error} If the content length header is not found in the response. */ public async getContentLength(key: string, ssecHeaders?: IT.SSECHeaders): Promise<number> { try { const res = await this._signedRequest('HEAD', key, { headers: ssecHeaders ? { ...ssecHeaders } : undefined, }); const len = res.headers.get(C.HEADER_CONTENT_LENGTH); return len ? +len : 0; } catch (err) { this._log('error', `Error getting content length for object ${key}: ${String(err)}`); throw new Error(`${C.ERROR_PREFIX}Error getting content length for object ${key}: ${String(err)}`); } } /** * Checks if an object exists in the S3-compatible service. * This method sends a HEAD request to check if the specified object exists. * @param {string} key - The key of the object to check. * @param {Record<string, unknown>} [opts={}] - Additional options for the request. * @returns A promise that resolves to true if the object exists, false if not found, or null if ETag mismatch. */ public async objectExists(key: string, opts: Record<string, unknown> = {}): Promise<IT.ExistResponseCode> { const res = await this._signedRequest('HEAD', key, { query: opts, tolerated: [200, 404, 412, 304], }); if (res.status === 404) { return false; // not found } if (res.status === 412 || res.status === 304) { return null; // ETag mismatch } return true; // found (200) } /** * Retrieves the ETag of an object without downloading its content. * @param {string} key - The key of the object to retrieve the ETag for. * @param {Record<string, unknown>} [opts={}] - Additional options for the request. * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any. * @returns {Promise<string | null>} A promise that resolves to the ETag value or null if the object is not found. * @throws {Error} If the ETag header is not found in the response. * @example * const etag = await s3.getEtag('path/to/file.txt'); * if (etag) { * console.log(`File ETag: ${etag}`); * } */ public async getEtag( key: string, opts: Record<string, unknown> = {}, ssecHeaders?: IT.SSECHeaders, ): Promise<string | null> { const res = await this._signedRequest('HEAD', key, { query: opts, tolerated: [200, 304, 404, 412], headers: ssecHeaders ? { ...ssecHeaders } : undefined, }); if (res.status === 404) { return null; } if (res.status === 412 || res.status === 304) { return null; // ETag mismatch } const etag = res.headers.get(C.HEADER_ETAG); if (!etag) { throw new Error(`${C.ERROR_PREFIX}ETag not found in response headers`); } return U.sanitizeETag(etag); } /** * Uploads an object to the S3-compatible service. * @param {string} key - The key/path where the object will be stored. * @param {string | Buffer} data - The data to upload (string or Buffer). * @param {string} [fileType='application/octet-stream'] - The MIME type of the file. * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any. * @returns {Promise<Response>} A promise that resolves to the Response object from the upload request. * @throws {TypeError} If data is not a string or Buffer. * @example * // Upload text file * await s3.putObject('hello.txt', 'Hello, World!', 'text/plain'); * * // Upload binary data * const buffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]); * await s3.putObject('image.png', buffer, 'image/png'); */ public async putObject( key: string, data: string | Buffer, fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE, ssecHeaders?: IT.SSECHeaders, ): Promise<Response> { if (!(data instanceof Buffer || typeof data === 'string')) { throw new TypeError(C.ERROR_DATA_BUFFER_REQUIRED); } return this._signedRequest('PUT', key, { body: data, headers: { [C.HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length, [C.HEADER_CONTENT_TYPE]: fileType, ...ssecHeaders, }, tolerated: [200], }); } /** * Initiates a multipart upload and returns the upload ID. * @param {string} key - The key/path where the object will be stored. * @param {string} [fileType='application/octet-stream'] - The MIME type of the file. * @param {IT.SSECHeaders?} [ssecHeaders] - Server-Side Encryption headers, if any. * @returns {Promise<string>} A promise that resolves to the upload ID for the multipart upload. * @throws {TypeError} If key is invalid or fileType is not a string. * @throws {Error} If the multipart upload fails to initialize. * @example * const uploadId = await s3.getMultipartUploadId('large-file.zip', 'application/zip'); * console.log(`Started multipart upload: ${uploadId}`); */ public async getMultipartUploadId( key: string, fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE, ssecHeaders?: IT.SSECHeaders, ): Promise<string> { this._checkKey(key); if (typeof fileType !== 'string') { throw new TypeError(`${C.ERROR_PREFIX}fileType must be a string`); } const query = { uploads: '' }; const headers = { [C.HEADER_CONTENT_TYPE]: fileType, ...ssecHeaders }; const res = await this._signedRequest('POST', key, { query, headers, withQuery: true, }); const parsed = U.parseXml(await res.text()) as Record<string, unknown>; // if ( // parsed && // typeof parsed === 'object' && // 'initiateMultipartUploadResult' in parsed && // parsed.initiateMultipartUploadResult && // 'uploadId' in (parsed.initiateMultipartUploadResult as { uploadId: string }) // ) { // return (parsed.initiateMultipartUploadResult as { uploadId: string }).uploadId; // } if (parsed && typeof parsed === 'object') { // Check for both cases of InitiateMultipartUploadResult const uploadResult = (parsed.initiateMultipartUploadResult as Record<string, unknown>) || (parsed.InitiateMultipartUploadResult as Record<string, unknown>); if (uploadResult && typeof uploadResult === 'object') { // Check for both cases of uploadId const uploadId = uploadResult.uploadId || uploadResult.UploadId; if (uploadId && typeof uploadId === 'string') { return uploadId; } } } throw new Error(`${C.ERROR_PREFIX}Failed to create multipart upload: ${JSON.stringify(parsed)}`); } /** * Uploads a part in a multipart upload. * @param {string} key - The key of the object being uploaded. * @param {string} uploadId - The upload ID from getMultipartUploadId. * @param {Buffer | string} data - The data for this part. * @param {number} partNumber - The part number (must be between 1 and 10,000). * @param {Record<string, unknown>} [opts={}] - Additional options for the request. * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any. * @returns {Promise<IT.UploadPart>} A promise that resolves to an object containing the partNumber and etag. * @throws {TypeError} If any parameter is invalid. * @example * const part = await s3.uploadPart( * 'large-file.zip', * uploadId, * partData, * 1 * ); * console.log(`Part ${part.partNumber} uploaded with ETag: ${part.etag}`); */ public async uploadPart( key: string, uploadId: string, data: Buffer | string, partNumber: number, opts: Record<string, unknown> = {}, ssecHeaders?: IT.SSECHeaders, ): Promise<IT.UploadPart> { this._validateUploadPartParams(key, uploadId, data, partNumber, opts); const query = { uploadId, partNumber, ...opts }; const res = await this._signedRequest('PUT', key, { query, body: data, headers: { [C.HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length, ...ssecHeaders, }, }); return { partNumber, etag: U.sanitizeETag(res.headers.get('etag') || '') }; } /** * Completes a multipart upload by combining all uploaded parts. * @param {string} key - The key of the object being uploaded. * @param {string} uploadId - The upload ID from getMultipartUploadId. * @param {Array<IT.UploadPart>} parts - Array of uploaded parts with partNumber and etag. * @returns {Promise<IT.CompleteMultipartUploadResult>} A promise that resolves to the completion result containing the final ETag. * @throws {Error} If the multipart upload fails to complete. * @example * const result = await s3.completeMultipartUpload( * 'large-file.zip', * uploadId, * [ * { partNumber: 1, etag: 'abc123' }, * { partNumber: 2, etag: 'def456' } * ] * ); * console.log(`Upload completed with ETag: ${result.etag}`); */ public async completeMultipartUpload( key: string, uploadId: string, parts: Array<IT.UploadPart>, ): Promise<IT.CompleteMultipartUploadResult> { const query = { uploadId }; const xmlBody = this._buildCompleteMultipartUploadXml(parts); const headers = { [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE, [C.HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(), }; const res = await this._signedRequest('POST', key, { query, body: xmlBody, headers, withQuery: true, }); const parsed = U.parseXml(await res.text()) as Record<string, unknown>; if (parsed && typeof parsed === 'object') { // Check for both cases const result = parsed.completeMultipartUploadResult || parsed.CompleteMultipartUploadResult || parsed; if (result && typeof result === 'object') { const resultObj = result as Record<string, unknown>; // Handle ETag in all its variations const etag = resultObj.ETag || resultObj.eTag || resultObj.etag; if (etag && typeof etag === 'string') { return { ...resultObj, etag: this.sanitizeETag(etag), } as IT.CompleteMultipartUploadResult; } return result as IT.CompleteMultipartUploadResult; } } throw new Error(`${C.ERROR_PREFIX}Failed to complete multipart upload: ${JSON.stringify(parsed)}`); } /** * Aborts a multipart upload and removes all uploaded parts. * @param {string} key - The key of the object being uploaded. * @param {string} uploadId - The upload ID to abort. * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any. * @returns {Promise<object>} A promise that resolves to an object containing the abort status and details. * @throws {TypeError} If key or uploadId is invalid. * @throws {Error} If the abort operation fails. * @example * try { * const result = await s3.abortMultipartUpload('large-file.zip', uploadId); * console.log('Upload aborted:', result.status); * } catch (error) { * console.error('Failed to abort upload:', error); * } */ public async abortMultipartUpload(key: string, uploadId: string, ssecHeaders?: IT.SSECHeaders): Promise<object> { this._checkKey(key); if (!uploadId) { throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED); } const query = { uploadId }; const headers = { [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE, ...(ssecHeaders ? { ...ssecHeaders } : {}) }; const res = await this._signedRequest('DELETE', key, { query, headers, withQuery: true, }); const parsed = U.parseXml(await res.text()) as Record<string, unknown>; if ( parsed && 'error' in parsed && typeof parsed.error === 'object' && parsed.error !== null && 'message' in parsed.error ) { this._log('error', `${C.ERROR_PREFIX}Failed to abort multipart upload: ${String(parsed.error.message)}`); throw new Error(`${C.ERROR_PREFIX}Failed to abort multipart upload: ${String(parsed.error.message)}`); } return { status: 'Aborted', key, uploadId, response: parsed }; } private _buildCompleteMultipartUploadXml(parts: Array<IT.UploadPart>): string { return ` <CompleteMultipartUpload> ${parts .map( part => ` <Part> <PartNumber>${part.partNumber}</PartNumber> <ETag>${part.etag}</ETag> </Part> `, ) .join('')} </CompleteMultipartUpload> `; } /** * Deletes an object from the bucket. * This method sends a request to delete the specified object from the bucket. * @param {string} key - The key of the object to delete. * @returns A promise that resolves to true if the object was deleted successfully, false otherwise. */ public async deleteObject(key: string): Promise<boolean> { const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] }); return res.status === 200 || res.status === 204; } private async _deleteObjectsProcess(keys: string[]): Promise<boolean[]> { const xmlBody = `<Delete>${keys.map(key => `<Object><Key>${U.escapeXml(key)}</Key></Object>`).join('')}</Delete>`; const query = { delete: '' }; const md5Base64 = U.md5base64(xmlBody); const headers = { [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE, [C.HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(), 'Content-MD5': md5Base64, }; const res = await this._signedRequest('POST', '', { query, body: xmlBody, headers, withQuery: true, }); const parsed = U.parseXml(await res.text()) as Record<string, unknown>; if (!parsed || typeof parsed !== 'object') { throw new Error(`${C.ERROR_PREFIX}Failed to delete objects: ${JSON.stringify(parsed)}`); } const out = (parsed.DeleteResult || parsed.deleteResult || parsed) as Record<string, unknown>; const resultMap = new Map<string, boolean>(); keys.forEach(key => resultMap.set(key, false)); const deleted = out.deleted || out.Deleted; if (deleted) { const deletedArray = Array.isArray(deleted) ? deleted : [deleted]; deletedArray.forEach((item: unknown) => { if (item && typeof item === 'object') { const obj = item as Record<string, unknown>; // Check both key and Key const key = obj.key || obj.Key; if (key && typeof key === 'string') { resultMap.set(key, true); } } }); } // Handle errors (check both cases) const errors = out.error || out.Error; if (errors) { const errorsArray = Array.isArray(errors) ? errors : [errors]; errorsArray.forEach((item: unknown) => { if (item && typeof item === 'object') { const obj = item as Record<string, unknown>; // Check both cases for all properties const key = obj.key || obj.Key; const code = obj.code || obj.Code; const message = obj.message || obj.Message; if (key && typeof key === 'string') { resultMap.set(key, false); // Optionally log the error for debugging this._log('warn', `Failed to delete object: ${key}`, { code: code || 'Unknown', message: message || 'Unknown error', }); } } }); } // Return boolean array in the same order as input keys return keys.map(key => resultMap.get(key) || false); } /** * Deletes multiple objects from the bucket. * @param {string[]} keys - An array of object keys to delete. * @returns A promise that resolves to an array of booleans indicating success for each key in order. */ public async deleteObjects(keys: string[]): Promise<boolean[]> { if (!Array.isArray(keys) || keys.length === 0) { return []; } const maxBatchSize = 1000; // S3 limit for delete batch size if (keys.length > maxBatchSize) { const allPromises = []; for (let i = 0; i < keys.length; i += maxBatchSize) { const batch = keys.slice(i, i + maxBatchSize); allPromises.push(this._deleteObjectsProcess(batch)); } const results = await Promise.all(allPromises); // Flatten the results array return results.flat(); } else { return await this._deleteObjectsProcess(keys); } } private async _sendRequest( url: string, method: IT.HttpMethod, headers: Record<string, string>, body?: string | Buffer, toleratedStatusCodes: number[] = [], ): Promise<Response> { this._log('info', `Sending ${method} request to ${url}`, `headers: ${JSON.stringify(headers)}`); try { const res = await fetch(url, { method, headers, body: ['GET', 'HEAD'].includes(method) ? undefined : (body as string), signal: this.requestAbortTimeout !== undefined ? AbortSignal.timeout(this.requestAbortTimeout) : undefined, }); this._log('info', `Response status: ${res.status}, tolerated: ${toleratedStatusCodes.join(',')}`); if (!res.ok && !toleratedStatusCodes.includes(res.status)) { await this._handleErrorResponse(res); } return res; } catch (err: unknown) { const code = U.extractErrCode(err); if (code && ['ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT', 'ECONNREFUSED'].includes(code)) { throw new U.S3NetworkError(`S3 network error: ${code}`, code, err); } throw err; } } private async _handleErrorResponse(res: Response): Promise<void> { const errorBody = await res.text(); const svcCode = res.headers.get('x-amz-error-code') ?? 'Unknown'; const errorMessage = res.headers.get('x-amz-error-message') || res.statusText; this._log( 'error', `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${svcCode} - ${errorMessage},err body: ${errorBody}`, ); throw new U.S3ServiceError(`S3 returned ${res.status} – ${svcCode}`, res.status, svcCode, errorBody); } private _buildCanonicalQueryString(queryParams: Record<string, unknown>): string { if (!queryParams || Object.keys(queryParams).length === 0) { return ''; } return Object.keys(queryParams) .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key] as string)}`) .sort() .join('&'); } private _getSignatureKey(dateStamp: string): Buffer { const kDate = U.hmac(`AWS4${this.secretAccessKey}`, dateStamp) as Buffer; const kRegion = U.hmac(kDate, this.region) as Buffer; const kService = U.hmac(kRegion, C.S3_SERVICE) as Buffer; return U.hmac(kService, C.AWS_REQUEST_TYPE) as Buffer; } } /** * @deprecated Use `S3mini` instead. */ const s3mini = S3mini; export { S3mini, s3mini }; export default S3mini;