UNPKG

s3mini

Version:

đź‘¶ Tiny & fast S3 client for node and edge computing platforms

1,283 lines • 84.1 kB
//#region src/consts.ts const AWS_ALGORITHM = "AWS4-HMAC-SHA256"; const AWS_REQUEST_TYPE = "aws4_request"; const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"; const DEFAULT_STREAM_CONTENT_TYPE = "application/octet-stream"; const XML_CONTENT_TYPE = "application/xml"; const SENSITIVE_KEYS_REDACTED = /* @__PURE__ */ new Set([ "accesskeyid", "secretaccesskey", "sessiontoken", "password", "token" ]); const IFHEADERS = /* @__PURE__ */ new Set([ "if-match", "if-none-match", "if-modified-since", "if-unmodified-since" ]); const DEFAULT_REQUEST_SIZE_IN_BYTES = 8388608; const MIN_PART_SIZE = 8388608; /** Error codes the S3 API pins to one HTTP status, used to restore the status Bun's S3Error drops. */ const S3_CODE_STATUS = { NoSuchKey: 404, NoSuchBucket: 404, NoSuchUpload: 404, NotFound: 404, AccessDenied: 403, InvalidAccessKeyId: 403, SignatureDoesNotMatch: 403, InvalidArgument: 400, InvalidRequest: 400, PreconditionFailed: 412, SlowDown: 503 }; const HEADER_AMZ_CONTENT_SHA256 = "x-amz-content-sha256"; const HEADER_AMZ_CHECKSUM_SHA256 = "x-amz-checksum-sha256"; const HEADER_AMZ_DATE = "x-amz-date"; const HEADER_HOST = "host"; const HEADER_AUTHORIZATION = "authorization"; const HEADER_CONTENT_TYPE = "content-type"; const HEADER_CONTENT_LENGTH = "content-length"; const HEADER_ETAG = "etag"; const ERROR_PREFIX = "[s3mini] "; const ERROR_ACCESS_KEY_REQUIRED = `${ERROR_PREFIX}accessKeyId must be a non-empty string`; const ERROR_SECRET_KEY_REQUIRED = `${ERROR_PREFIX}secretAccessKey must be a non-empty string`; const ERROR_ENDPOINT_REQUIRED = `${ERROR_PREFIX}endpoint must be a non-empty string`; const ERROR_ENDPOINT_FORMAT = `${ERROR_PREFIX}endpoint must be a valid URL. Expected format: https://<host>[:port][/base-path]`; const ERROR_KEY_REQUIRED = `${ERROR_PREFIX}key must be a non-empty string`; const ERROR_UPLOAD_ID_REQUIRED = `${ERROR_PREFIX}uploadId must be a non-empty string`; const ERROR_PREFIX_TYPE = `${ERROR_PREFIX}prefix must be a string`; const ERROR_DELIMITER_REQUIRED = `${ERROR_PREFIX}delimiter must be a string`; const ERROR_BUN_PAGINATION_STALLED = `${ERROR_PREFIX}Bun S3 list pagination stalled: truncated page did not advance the continuation token`; //#endregion //#region src/utils.ts /** * True when running on a Bun version that exposes the native S3 client. * `navigator.userAgent` is `Bun/<version>`, so the runtime is identified by `process.versions.bun` * and the client is capability-checked: older Bun releases have no `Bun.S3Client`. */ const isBun = typeof process !== "undefined" && !!process.versions?.bun && typeof globalThis.Bun?.S3Client === "function"; /** * Compare two strings by code point, as required for AWS SigV4 canonical * ordering of query parameters and headers. `localeCompare` MUST NOT be used * here: it is locale-aware and case-insensitive by default, so it mis-orders * mixed-case names (e.g. `partNumber` before `X-Amz-*`) and breaks signatures. * @param a First string * @param b Second string * @returns -1, 0, or 1 */ const byCodePoint = (a, b) => { if (a < b) return -1; if (a > b) return 1; return 0; }; const ENCODR = new TextEncoder(); const chunkSize = 32768; const HEX_CHARS = new Uint8Array([ 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102 ]); const getByteSize = (data) => { if (typeof data === "string") return ENCODR.encode(data).byteLength; if (data instanceof ArrayBuffer || data instanceof Uint8Array) return data.byteLength; if (data instanceof Blob || data instanceof File) return data.size; if (data instanceof ReadableStream) return NaN; throw new Error("Unsupported data type"); }; const toUint8Array = (data) => { if (typeof data === "string") return ENCODR.encode(data); if (data instanceof ArrayBuffer) return new Uint8Array(data); if (data instanceof Uint8Array) return data; if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); return null; }; /** * Turn a raw ArrayBuffer into its hexadecimal representation. * @param {ArrayBuffer} buffer The raw bytes. * @returns {string} Hexadecimal string */ const hexFromBuffer = (buffer) => { const bytes = new Uint8Array(buffer); const hex = new Uint8Array(bytes.length * 2); for (let i = 0, j = 0; i < bytes.length; i++) { hex[j++] = HEX_CHARS[bytes[i] >> 4]; hex[j++] = HEX_CHARS[bytes[i] & 15]; } return String.fromCodePoint(...hex); }; /** * Turn a raw ArrayBuffer into its base64 representation. * @param {ArrayBuffer} buffer The raw bytes. * @returns {string} Base64 string */ const base64FromBuffer = (buffer) => { const bytes = new Uint8Array(buffer); let result = ""; for (let i = 0; i < bytes.length; i += chunkSize) { const chunk = bytes.subarray(i, i + chunkSize); result += btoa(String.fromCodePoint(...chunk)); } return result; }; /** * Compute SHA-256 hash of arbitrary string data. * @param {string} content The content to be hashed. * @returns {ArrayBuffer} The raw hash */ const sha256 = async (content) => { const data = ENCODR.encode(content); return await globalThis.crypto.subtle.digest("SHA-256", data); }; /** * Compute HMAC-SHA-256 of arbitrary data. * @param {string|ArrayBuffer} key The key used to sign the content. * @param {string} content The content to be signed. * @returns {ArrayBuffer} The raw signature */ const hmac = async (key, content) => { const secret = await globalThis.crypto.subtle.importKey("raw", typeof key === "string" ? ENCODR.encode(key) : key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const data = ENCODR.encode(content); return await globalThis.crypto.subtle.sign("HMAC", secret, data); }; /** * Sanitize ETag value by removing quotes and XML entities * @param etag ETag value to sanitize * @returns Sanitized ETag */ const sanitizeETag = (etag) => { const replaceChars = { "\"": "", "&quot;": "", "&#34;": "" }; return etag.replaceAll(/(^("|&quot;|&#34;))|(("|&quot;|&#34;)$)/g, (m) => replaceChars[m] || ""); }; const entityMap = { "&quot;": "\"", "&apos;": "'", "&lt;": "<", "&gt;": ">", "&amp;": "&" }; /** * Escape special characters for XML * @param value String to escape * @returns XML-escaped string */ const escapeXml = (value) => { return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;"); }; const unescapeXml = (value) => value.replaceAll(/&(quot|apos|lt|gt|amp);/g, (m) => entityMap[m] ?? m); /** * Parse a very small subset of XML into a JS structure. * * @param input raw XML string * @returns string for leaf nodes, otherwise a map of children */ const parseXml = (input) => { const xmlContent = input.replace(/<\?xml[^?]*\?>\s*/, ""); const RE_TAG = /<([A-Za-z_][\w\-.]*)(?:\s[^>]*?)?(?:>([\s\S]*?)<\/\1>|\/>)/gm; const result = {}; let match; while ((match = RE_TAG.exec(xmlContent)) !== null) { const tagName = match[1]; const innerContent = match[2]; const node = innerContent ? parseXml(innerContent) : unescapeXml(innerContent?.trim() || ""); if (!tagName) continue; const current = result[tagName]; if (current === void 0) result[tagName] = node; else if (Array.isArray(current)) current.push(node); else result[tagName] = [current, node]; } return Object.keys(result).length > 0 ? result : unescapeXml(xmlContent.trim()); }; /** * Encode a character as a URI percent-encoded hex value * @param c Character to encode * @returns Percent-encoded character */ const encodeAsHex = (c) => `%${(c.codePointAt(0) ?? 0).toString(16).toUpperCase()}`; /** * Escape a URI string using percent encoding * @param uriStr URI string to escape * @returns Escaped URI string */ const uriEscape = (uriStr) => { return encodeURIComponent(uriStr).replaceAll(/[!'()*]/g, encodeAsHex); }; /** * Escape a URI resource path while preserving forward slashes * @param string URI path to escape * @returns Escaped URI path */ const uriResourceEscape = (string) => { return uriEscape(string).replaceAll("%2F", "/"); }; const extractErrCode = (e) => { if (typeof e !== "object" || e === null) return; const err = e; if (typeof err.code === "string") return err.code; return typeof err.cause?.code === "string" ? err.cause.code : void 0; }; var S3Error = class extends Error { code; constructor(msg, code, cause) { super(msg); this.name = new.target.name; this.code = code; this.cause = cause; } }; var S3NetworkError = class extends S3Error {}; var S3ServiceError = class extends S3Error { status; serviceCode; body; constructor(msg, status, serviceCode, body) { super(msg, serviceCode); this.status = status; this.serviceCode = serviceCode; this.body = body; } }; /** * Run async-returning tasks in batches with an *optional* minimum * spacing (minIntervalMs) between the *start* times of successive batches. * * @param {Iterable<() => Promise<unknonw>>} tasks – functions returning Promises * @param {number} [batchSize=30] – max concurrent requests * @param {number} [minIntervalMs=0] – ≥0; 0 means “no pacing” * @returns {Promise<Array<PromiseSettledResult<T>>>} */ const runInBatches = async (tasks, batchSize = 30, minIntervalMs = 0) => { const allResults = []; let batch = []; for (const task of tasks) { batch.push(task); if (batch.length === batchSize) { await executeBatch(batch); batch = []; } } if (batch.length) await executeBatch(batch); return allResults; async function executeBatch(batchFns) { const start = Date.now(); const settled = await Promise.allSettled(batchFns.map((fn) => fn())); allResults.push(...settled); if (minIntervalMs > 0) { const wait = minIntervalMs - (Date.now() - start); if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); } } }; const generateParts = async function* (data, partSize) { const bytes = toUint8Array(data); if (bytes) yield* generateBufferParts(bytes, partSize); else if (data instanceof Blob) yield* generateBlobParts(data, partSize); else if (data instanceof ReadableStream) yield* generateStreamParts(data, partSize); else throw new TypeError(`${ERROR_PREFIX}Unsupported data type for multipart upload`); }; function* generateBufferParts(bytes, partSize) { for (let offset = 0; offset < bytes.byteLength; offset += partSize) yield bytes.subarray(offset, Math.min(offset + partSize, bytes.byteLength)); } /** * Zero-copy: yields Blob slices. Data is only read when fetch consumes it. */ const generateBlobParts = function* (blob, partSize) { for (let offset = 0; offset < blob.size; offset += partSize) yield blob.slice(offset, Math.min(offset + partSize, blob.size)); }; const generateStreamParts = async function* (stream, partSize) { const reader = stream.getReader(); const chunks = []; let buffered = 0; try { while (true) { const { done, value } = await reader.read(); if (value) { chunks.push(value); buffered += value.byteLength; while (buffered >= partSize) { yield extractPart(chunks, partSize); buffered -= partSize; } } if (done) break; } if (buffered > 0) yield extractPart(chunks, buffered); } finally { reader.releaseLock(); } }; const extractPart = (chunks, size) => { const part = new Uint8Array(size); let offset = 0; while (offset < size && chunks.length > 0) { const chunk = chunks[0]; const needed = size - offset; if (chunk.byteLength <= needed) { part.set(chunk, offset); offset += chunk.byteLength; chunks.shift(); } else { part.set(chunk.subarray(0, needed), offset); chunks[0] = chunk.subarray(needed); offset = size; } } return part.buffer; }; //#endregion //#region src/S3.ts /** * 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 S3mini({ * accessKeyId: 'your-access-key', * secretAccessKey: 'your-secret-key', * endpoint: 'https://your-s3-endpoint.com/bucket-name', * region: 'auto' // 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'); */ var S3mini = class { /** * 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. * @param {typeof fetch} [config.fetch=globalThis.fetch] - Custom fetch implementation to use for HTTP requests. * @param {number} [config.minPartSize=8388608] - The minimum part size for multipart uploads in bytes (default is 8MB). * @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type. */ #accessKeyId; #secretAccessKey; endpoint; region; bucketName; requestSizeInBytes; requestAbortTimeout; logger; _fetch; minPartSize; _bun; signingKeyDate; signingKey; constructor({ accessKeyId, secretAccessKey, endpoint, region = "auto", requestSizeInBytes = DEFAULT_REQUEST_SIZE_IN_BYTES, requestAbortTimeout = void 0, logger = void 0, fetch = globalThis.fetch, minPartSize = MIN_PART_SIZE }) { this._validateConstructorParams(accessKeyId, secretAccessKey, endpoint); this.#accessKeyId = accessKeyId; this.#secretAccessKey = secretAccessKey; this.endpoint = new URL(this._ensureValidUrl(endpoint)); this.region = region; this.bucketName = this._extractBucketName(); this.requestSizeInBytes = requestSizeInBytes; this.requestAbortTimeout = requestAbortTimeout; this.logger = logger; this._fetch = (input, init) => fetch(input, init); this.minPartSize = minPartSize; if (isBun && fetch === globalThis.fetch && this._hasCredentials()) { const segments = this.endpoint.pathname.split("/").filter(Boolean); if (segments.length < 2) { const { S3Client } = globalThis.Bun; this._bun = new S3Client({ accessKeyId, secretAccessKey, endpoint: this.endpoint.origin, region: this.region, bucket: this.bucketName, virtualHostedStyle: segments.length === 0 }); } } } _sanitize(obj) { if (typeof obj !== "object" || obj === null) return obj; return Object.keys(obj).reduce((acc, key) => { if (SENSITIVE_KEYS_REDACTED.has(key.toLowerCase())) acc[key] = "[REDACTED]"; else if (typeof obj[key] === "object" && obj[key] !== null) acc[key] = this._sanitize(obj[key]); else acc[key] = obj[key]; return acc; }, Array.isArray(obj) ? [] : {}); } _log(level, message, additionalData = {}) { if (this.logger && typeof this.logger[level] === "function") { const sanitizedData = this._sanitize(additionalData); const logEntry = { timestamp: (/* @__PURE__ */ new Date()).toISOString(), level, message, details: sanitizedData, context: this._sanitize({ region: this.region, endpoint: this.endpoint.toString(), accessKeyId: this.#accessKeyId ? `${this.#accessKeyId.substring(0, 4)}...` : void 0 }) }; this.logger[level](JSON.stringify(logEntry)); } } _asArray(value) { return Array.isArray(value) ? value : [value]; } _validateConstructorParams(accessKeyId, secretAccessKey, endpoint) { if (typeof accessKeyId !== "string") throw new TypeError(ERROR_ACCESS_KEY_REQUIRED); if (typeof secretAccessKey !== "string") throw new TypeError(ERROR_SECRET_KEY_REQUIRED); if (typeof endpoint !== "string" || endpoint.trim().length === 0) throw new TypeError(ERROR_ENDPOINT_REQUIRED); } /** * Check if credentials are configured (non-empty). * @returns true if both accessKeyId and secretAccessKey are non-empty. */ _hasCredentials() { return this.#accessKeyId.trim().length > 0 && this.#secretAccessKey.trim().length > 0; } /** * Re-shape a Bun S3Error as the S3ServiceError the signed path throws, so callers see one error * type on every runtime. Bun does not expose the HTTP status, so it is recovered from the error * code where the S3 API pins it and left as 0 (unknown) otherwise. */ _bunError(e) { const err = e; if (err?.name !== "S3Error") return e; const status = err.code ? S3_CODE_STATUS[err.code] ?? 0 : 0; return new S3ServiceError(status ? `S3 returned ${status} – ${err.code}` : err.message ?? String(e), status, err.code, err.message); } /** True for a Bun S3Error the signed path would have absorbed as a tolerated 404. */ _isBunNotFound(e) { const code = e?.code; return !!code && S3_CODE_STATUS[code] === 404; } /** Run a read op via Bun-native S3, returning null when the object or its bucket is absent. */ async _bunRead(key, op) { try { return await op(this._bun.file(key)); } catch (e) { if (this._isBunNotFound(e)) return null; throw this._bunError(e); } } _ensureValidUrl(raw) { const candidate = /^(https?:)?\/\//i.test(raw) ? raw : `https://${raw}`; try { new URL(candidate); let endIndex = candidate.length; while (endIndex > 0 && candidate[endIndex - 1] === "/") endIndex--; return endIndex === candidate.length ? candidate : candidate.substring(0, endIndex); } catch { const msg = `${ERROR_ENDPOINT_FORMAT} But provided: "${raw}"`; this._log("error", msg); throw new TypeError(msg); } } _validateMethodIsGetOrHead(method) { if (method !== "GET" && method !== "HEAD") { this._log("error", `${ERROR_PREFIX}method must be either GET or HEAD`); throw new Error(`${ERROR_PREFIX}method must be either GET or HEAD`); } } _checkKey(key) { if (typeof key !== "string" || key.trim().length === 0) { this._log("error", ERROR_KEY_REQUIRED); throw new TypeError(ERROR_KEY_REQUIRED); } } _checkDelimiter(delimiter) { if (typeof delimiter !== "string" || delimiter.trim().length === 0) { this._log("error", ERROR_DELIMITER_REQUIRED); throw new TypeError(ERROR_DELIMITER_REQUIRED); } } _checkPrefix(prefix) { if (typeof prefix !== "string") { this._log("error", ERROR_PREFIX_TYPE); throw new TypeError(ERROR_PREFIX_TYPE); } } _checkOpts(opts) { if (typeof opts !== "object") { this._log("error", `${ERROR_PREFIX}opts must be an object`); throw new TypeError(`${ERROR_PREFIX}opts must be an object`); } } _filterIfHeaders(opts) { const filteredOpts = {}; const conditionalHeaders = {}; for (const [key, value] of Object.entries(opts)) if (IFHEADERS.has(key.toLowerCase())) conditionalHeaders[key] = value; else filteredOpts[key] = value; return { filteredOpts, conditionalHeaders }; } _validateUploadPartParams(key, uploadId, data, partNumber, opts) { this._checkKey(key); if (typeof uploadId !== "string" || uploadId.trim().length === 0) { this._log("error", ERROR_UPLOAD_ID_REQUIRED); throw new TypeError(ERROR_UPLOAD_ID_REQUIRED); } if (!Number.isInteger(partNumber) || partNumber <= 0) { this._log("error", `${ERROR_PREFIX}partNumber must be a positive integer`); throw new TypeError(`${ERROR_PREFIX}partNumber must be a positive integer`); } this._checkOpts(opts); return data; } async _sign(method, keyPath, query = {}, headers = {}) { const url = new URL(this.endpoint); if (keyPath && keyPath.length > 0) url.pathname = url.pathname === "/" ? `/${keyPath.replace(/^\/+/, "")}` : `${url.pathname}/${keyPath.replace(/^\/+/, "")}`; if (!this._hasCredentials()) { headers[HEADER_HOST] = url.host; return { url: url.toString(), headers }; } const d = /* @__PURE__ */ new Date(); const shortDatetime = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, "0")}${String(d.getUTCDate()).padStart(2, "0")}`; const fullDatetime = `${shortDatetime}T${String(d.getUTCHours()).padStart(2, "0")}${String(d.getUTCMinutes()).padStart(2, "0")}${String(d.getUTCSeconds()).padStart(2, "0")}Z`; const credentialScope = `${shortDatetime}/${this.region}/s3/${AWS_REQUEST_TYPE}`; headers[HEADER_AMZ_CONTENT_SHA256] = UNSIGNED_PAYLOAD; headers[HEADER_AMZ_DATE] = fullDatetime; headers[HEADER_HOST] = url.host; const ignoredHeaders = /* @__PURE__ */ new Set([ "authorization", "content-length", "content-type", "user-agent" ]); const sortedHeaders = Object.entries(headers).map(([key, value]) => [key.toLowerCase(), String(value).trim()]).filter(([lowerKey]) => !ignoredHeaders.has(lowerKey)).sort(([a], [b]) => byCodePoint(a, b)); const canonicalHeaders = sortedHeaders.map(([k, v]) => `${k}:${v}`).join("\n"); const signedHeaders = sortedHeaders.map(([k]) => k).join(";"); const canonicalRequest = `${method}\n${url.pathname}\n${this._buildCanonicalQueryString(query)}\n${canonicalHeaders}\n\n${signedHeaders}\n${UNSIGNED_PAYLOAD}`; const stringToSign = `${AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${hexFromBuffer(await sha256(canonicalRequest))}`; if (shortDatetime !== this.signingKeyDate || !this.signingKey) { this.signingKeyDate = shortDatetime; this.signingKey = await this._getSignatureKey(shortDatetime); } const signature = hexFromBuffer(await hmac(this.signingKey, stringToSign)); headers[HEADER_AUTHORIZATION] = `${AWS_ALGORITHM} Credential=${this.#accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; return { url: url.toString(), headers }; } async _signedRequest(method, key, { query = {}, body = "", headers = {}, tolerated = [], withQuery = false } = {}) { const { filteredOpts, conditionalHeaders } = ["GET", "HEAD"].includes(method) ? this._filterIfHeaders(query) : { filteredOpts: query, conditionalHeaders: {} }; const baseHeaders = { [HEADER_AMZ_CONTENT_SHA256]: UNSIGNED_PAYLOAD, ...headers, ...conditionalHeaders }; const encodedKey = key ? uriResourceEscape(key) : ""; const { url, headers: signedHeaders } = await this._sign(method, encodedKey, filteredOpts, baseHeaders); if (Object.keys(query).length > 0) withQuery = true; const finalUrl = withQuery && Object.keys(filteredOpts).length ? `${url}?${this._buildCanonicalQueryString(filteredOpts)}` : url; const signedHeadersString = Object.fromEntries(Object.entries(signedHeaders).map(([k, v]) => [k, String(v)])); return this._sendRequest(finalUrl, method, signedHeadersString, body, tolerated); } /** * 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' */ sanitizeETag(etag) { return 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. */ async createBucket() { const xmlBody = ` <CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> <LocationConstraint>${this.region}</LocationConstraint> </CreateBucketConfiguration> `; const headers = { [HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE, [HEADER_CONTENT_LENGTH]: getByteSize(xmlBody) }; return (await this._signedRequest("PUT", "", { body: xmlBody, headers, tolerated: [ 200, 404, 403, 409 ] })).status === 200; } _extractBucketName() { const url = this.endpoint; const firstSegment = url.pathname.split("/").find(Boolean); if (firstSegment) return firstSegment; const hostname = url.hostname; if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(":")) return ""; const labels = hostname.split("."); if (labels.length < 3) return ""; return labels[0]; } /** * 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. */ async bucketExists() { return (await this._signedRequest("HEAD", "", { tolerated: [ 200, 404, 403 ] })).status === 200; } /** * Sets bucket versioning status (PutBucketVersioning). * Required before object versioning APIs (`listObjectVersions`, versioned delete/copy) are useful. * @param {'Enabled' | 'Suspended'} status - Versioning status to apply. * @returns {Promise<boolean>} True when the service accepts the configuration (HTTP 200). * @example * await s3.setBucketVersioning('Enabled'); */ async setBucketVersioning(status) { if (status !== "Enabled" && status !== "Suspended") throw new TypeError(`${ERROR_PREFIX}status must be 'Enabled' or 'Suspended'`); const xmlBody = `<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>${status}</Status></VersioningConfiguration>`; return (await this._signedRequest("PUT", "", { query: { versioning: "" }, body: xmlBody, headers: { [HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE, [HEADER_CONTENT_LENGTH]: getByteSize(xmlBody) }, withQuery: true, tolerated: [200] })).status === 200; } /** * Gets bucket versioning status (GetBucketVersioning). * @returns {Promise<'Enabled' | 'Suspended' | 'Off'>} Current status. `'Off'` when the config is empty/unset. */ async getBucketVersioning() { const res = await this._signedRequest("GET", "", { query: { versioning: "" }, withQuery: true, tolerated: [200, 404] }); if (res.status !== 200) { res.body?.cancel(); return "Off"; } const raw = parseXml(await res.text()); const cfg = raw.VersioningConfiguration || raw.versioningConfiguration || raw; const status = cfg.Status ?? cfg.status; if (status === "Enabled" || status === "Suspended") return status; return "Off"; } /** * Lists objects in the bucket with optional filtering and no pagination. * This method retrieves all objects matching the criteria (not paginated like listObjectsV2). * Pass `{ versions: true }` in opts to list object versions (ListObjectVersions API). * @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. Use `{ versions: true }` for version listing. * @returns {Promise<IT.ListObject[] | null>} A promise that resolves to an array of objects, or null if the bucket does not exist. An empty bucket resolves to an empty array. * @example * // List all objects * const objects = await s3.listObjects(); * * // List objects with prefix * const photos = await s3.listObjects('/', 'photos/', 100); * * // List object versions (includes VersionId / IsLatest; may include delete markers) * const versions = await s3.listObjects('/', 'photos/', undefined, { versions: true }); */ async listObjects(delimiter = "/", prefix = "", maxKeys, opts = {}) { this._checkDelimiter(delimiter); this._checkPrefix(prefix); this._checkOpts(opts); if (this._bun && delimiter === "/" && !this._isVersionsMode(opts)) { if (Object.keys(opts).filter((k) => k !== "delimiter").length === 0) return this._bunListAll(prefix, maxKeys, opts.delimiter); } const keyPath = delimiter === "/" ? delimiter : uriEscape(delimiter); const unlimited = !(maxKeys && maxKeys > 0); let remaining = unlimited ? Infinity : maxKeys; let token; const all = []; do { const batchResult = await this._fetchObjectBatch(keyPath, prefix, remaining, token, opts); if (batchResult === null) return null; all.push(...batchResult.objects); if (!unlimited) remaining -= batchResult.objects.length; token = batchResult.continuationToken; } while (token && remaining > 0); return all; } /** * Lists objects in the bucket with optional filtering and pagination using a continuation token. * This method retrieves objects matching the criteria (paginated like listObjectsV2). * Pass `{ versions: true }` in opts to list object versions (uses key-marker / version-id-marker under the hood; * the returned token is opaque and only valid with the same opts). * @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. Uses a default value of 100. * @param {string} [nextContinuationToken] - The nextContinuationToken to continue previous results. If not provided, starts from the beginning. * @param {Record<string, unknown>} [opts={}] - Additional options for the request. Use `{ versions: true }` for version listing. * @returns {Promise<{objects: IT.ListObject[] | null; nextContinuationToken?: string } | undefined | null>} A promise that resolves to an array of objects, along with nextContinuationToken if there are more reccords, or null if the bucket does not exist. * @example * // List all objects * const { objects, nextContinuationToken } = await s3.listObjectsPaged(); * * // List 200 objects with prefix * const photos = await s3.listObjectsPaged('/', 'photos/', 200, "token..."); */ async listObjectsPaged(delimiter = "/", prefix = "", maxKeys = 100, nextContinuationToken, opts = {}) { this._checkDelimiter(delimiter); this._checkPrefix(prefix); this._checkOpts(opts); const keyPath = delimiter === "/" ? delimiter : uriEscape(delimiter); let token = nextContinuationToken; let remaining = maxKeys; const all = []; do { const batchResult = await this._fetchObjectBatch(keyPath, prefix, remaining, token, opts); if (batchResult === null) return null; all.push(...batchResult.objects); remaining -= batchResult.objects.length; token = batchResult.continuationToken; } while (token && remaining > 0); return { objects: all, nextContinuationToken: token }; } /** * Lists all versions (and delete markers) of a specific object key. * Auto-paginates until every version is returned (or maxKeys is reached). * Entries include `VersionId`, `IsLatest`, and optionally `IsDeleteMarker`. * * @param {string} key - Exact object key whose versions to list. * @param {number} [maxKeys] - Optional cap on how many version entries to return. * @returns {Promise<IT.ListObject[] | null>} All versions for the key, or null if the bucket is not found. * @example * const versions = await s3.listObjectVersions('file.jpg'); * const latest = versions?.find(v => v.IsLatest); * const older = versions?.filter(v => !v.IsLatest && !v.IsDeleteMarker); */ async listObjectVersions(key, maxKeys) { this._checkKey(key); const listed = await this.listObjects("/", key, maxKeys, { versions: true }); if (listed === null) return null; return listed.filter((obj) => obj.Key === key); } async _fetchObjectBatch(keyPath, prefix, remaining, token, opts) { const query = this._buildListObjectsQuery(prefix, remaining, token, opts); const res = await this._signedRequest("GET", keyPath, { query, withQuery: true, tolerated: [200, 404] }); if (res.status === 404) { res.body?.cancel(); return null; } if (res.status !== 200) await this._handleListObjectsError(res); const xmlText = await res.text(); return this._parseListObjectsResponse(xmlText, this._isVersionsMode(opts)); } _isVersionsMode(opts) { const v = opts.versions; return v === true || v === "" || v === "true" || v === 1; } _encodeVersionListToken(keyMarker, versionIdMarker) { return JSON.stringify({ k: keyMarker, v: versionIdMarker }); } _decodeVersionListToken(token) { try { const parsed = JSON.parse(token); if (parsed && typeof parsed.k === "string") return { keyMarker: parsed.k, versionIdMarker: typeof parsed.v === "string" ? parsed.v : "" }; } catch {} return { keyMarker: token, versionIdMarker: "" }; } _buildListObjectsQuery(prefix, remaining, token, opts) { const batchSize = Math.min(remaining, 1e3); const versionsMode = this._isVersionsMode(opts); const restOpts = { ...opts }; delete restOpts.versions; if (versionsMode) { const markers = token ? this._decodeVersionListToken(token) : void 0; return { versions: "", "max-keys": String(batchSize), ...prefix ? { prefix } : {}, ...markers ? { "key-marker": markers.keyMarker, "version-id-marker": markers.versionIdMarker } : {}, ...restOpts }; } return { "list-type": "2", "max-keys": String(batchSize), ...prefix ? { prefix } : {}, ...token ? { "continuation-token": token } : {}, ...restOpts }; } async _handleListObjectsError(res) { const errorBody = await res.text(); const parsedErrorBody = this._parseErrorXml(res.headers, errorBody); const errorCode = res.headers.get("x-amz-error-code") ?? parsedErrorBody.svcCode ?? "Unknown"; const errorMessage = res.headers.get("x-amz-error-message") ?? parsedErrorBody.errorMessage ?? res.statusText; this._log("error", `${ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`); throw new Error(`${ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`); } _parseListObjectsResponse(xmlText, versionsMode = false) { const raw = parseXml(xmlText); if (typeof raw !== "object" || !raw || "error" in raw) { this._log("error", `${ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`); throw new Error(`${ERROR_PREFIX}Unexpected listObjects response shape`); } const out = raw.ListVersionsResult || raw.listVersionsResult || raw.ListBucketResult || raw.listBucketResult || raw; return { objects: this._extractObjectsFromResponse(out), continuationToken: versionsMode ? this._extractVersionListToken(out) : this._extractContinuationToken(out) }; } _mapListEntry(item, isDeleteMarker = false) { const keyRaw = item.Key ?? item.key ?? ""; const key = typeof keyRaw === "string" ? keyRaw : ""; const versionId = item.VersionId ?? item.versionId; const isLatestRaw = item.IsLatest ?? item.isLatest; const etagRaw = item.ETag ?? item.etag ?? item.eTag ?? ""; const storageRaw = item.StorageClass ?? item.storageClass ?? ""; const lmRaw = item.LastModified ?? item.lastModified ?? 0; const entry = { Key: key, Size: Number(item.Size ?? item.size ?? 0), LastModified: new Date(typeof lmRaw === "string" || typeof lmRaw === "number" ? lmRaw : 0), ETag: typeof etagRaw === "string" ? etagRaw : "", StorageClass: typeof storageRaw === "string" ? storageRaw : "" }; if (typeof versionId === "string" && versionId !== "") entry.VersionId = versionId; if (isLatestRaw !== void 0 && isLatestRaw !== null && isLatestRaw !== "") entry.IsLatest = isLatestRaw === true || isLatestRaw === "true"; if (isDeleteMarker) entry.IsDeleteMarker = true; return entry; } _pushListEntries(raw, isDeleteMarker, out) { if (!raw) return; for (const item of this._asArray(raw)) out.push(this._mapListEntry(item, isDeleteMarker)); } _pushCommonPrefixes(raw, out) { if (!raw) return; for (const item of this._asArray(raw)) { const entry = item; const prefix = entry.Prefix || entry.prefix; if (typeof prefix === "string") out.push({ Key: prefix, Size: 0, LastModified: /* @__PURE__ */ new Date(0), ETag: "", StorageClass: "" }); } } _extractObjectsFromResponse(response) { const objects = []; this._pushListEntries(response.Contents || response.contents, false, objects); this._pushListEntries(response.Version || response.version, false, objects); this._pushListEntries(response.DeleteMarker || response.deleteMarker, true, objects); this._pushCommonPrefixes(response.CommonPrefixes || response.commonPrefixes, objects); return objects; } _extractContinuationToken(response) { if (!(response.IsTruncated === "true" || response.isTruncated === "true" || false)) return; return response.NextContinuationToken || response.nextContinuationToken || response.NextMarker || response.nextMarker; } _extractVersionListToken(response) { if (!(response.IsTruncated === "true" || response.isTruncated === "true" || false)) return; const keyMarker = response.NextKeyMarker ?? response.nextKeyMarker ?? ""; const versionIdMarker = response.NextVersionIdMarker ?? response.nextVersionIdMarker ?? ""; return this._encodeVersionListToken(String(keyMarker), String(versionIdMarker)); } async _bunListAll(prefix, maxKeys, delimiter) { const unlimited = !(maxKeys && maxKeys > 0); let remaining = unlimited ? Infinity : maxKeys; let token; const all = []; try { do { const batchSize = Math.min(remaining === Infinity ? 1e3 : remaining, 1e3); const res = await this._bunFetchPage(prefix, delimiter, batchSize, token); const mapped = this._bunMapListResult(res); const prev = token; token = res.nextContinuationToken; all.push(...mapped); if (!unlimited) remaining -= mapped.length; if (res.isTruncated && remaining > 0 && (!token || token === prev)) throw new Error(ERROR_BUN_PAGINATION_STALLED); } while (token && remaining > 0); } catch (e) { if (this._isBunNotFound(e)) return null; throw this._bunError(e); } return all; } _bunFetchPage(prefix, delimiter, maxKeys, continuationToken) { return this._bun.list({ prefix: prefix || void 0, delimiter, maxKeys, ...continuationToken ? { continuationToken } : {} }); } _bunMapListResult(res) { const objects = []; if (res.contents) for (const item of res.contents) objects.push({ Key: item.key, Size: item.size, LastModified: item.lastModified instanceof Date ? item.lastModified : new Date(item.lastModified), ETag: item.eTag ?? "", StorageClass: item.storageClass ?? "" }); if (res.commonPrefixes) for (const item of res.commonPrefixes) objects.push({ Key: item.prefix, Size: 0, LastModified: /* @__PURE__ */ new Date(0), ETag: "", StorageClass: "" }); return objects; } /** * 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. */ async listMultipartUploads(delimiter = "/", prefix = "", method = "GET", opts = {}) { this._checkDelimiter(delimiter); this._checkPrefix(prefix); this._validateMethodIsGetOrHead(method); this._checkOpts(opts); const query = { uploads: "", ...opts }; const keyPath = delimiter === "/" ? delimiter : uriEscape(delimiter); const res = await this._signedRequest(method, keyPath, { query, withQuery: true }); const raw = parseXml(await res.text()); if (typeof raw !== "object" || raw === null) throw new Error(`${ERROR_PREFIX}Unexpected listMultipartUploads response shape`); if ("listMultipartUploadsResult" in raw) return raw.listMultipartUploadsResult; return raw; } /** * 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. */ async getObject(key, opts = {}, ssecHeaders) { if (this._bun && !ssecHeaders && !Object.keys(opts).length) return this._bunRead(key, (f) => f.text()); const res = await this._signedRequest("GET", key, { query: opts, tolerated: [ 200, 404, 412, 304 ], headers: ssecHeaders ? { ...ssecHeaders } : void 0 }); if (res.status === 200) return res.text(); res.body?.cancel(); return null; } /** * 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. */ async getObjectResponse(key, opts = {}, ssecHeaders) { const res = await this._signedRequest("GET", key, { query: opts, tolerated: [ 200, 404, 412, 304 ], headers: ssecHeaders ? { ...ssecHeaders } : void 0 }); if (res.status === 200) return res; res.body?.cancel(); return null; } /** * 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. */ async getObjectArrayBuffer(key, opts = {}, ssecHeaders) { if (this._bun && !ssecHeaders && !Object.keys(opts).length) return this._bunRead(key, (f) => f.arrayBuffer()); const res = await this._signedRequest("GET", key, { query: opts, tolerated: [ 200, 404, 412, 304 ], headers: ssecHeaders ? { ...ssecHeaders } : void 0 }); if (res.status === 200) return res.arrayBuffer(); res.body?.cancel(); return null; } /** * 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. */ async getObjectJSON(key, opts = {}, ssecHeaders) { if (this._bun && !ssecHeaders && !Object.keys(opts).length) return this._bunRead(key, (f) => f.json()); const res = await this._signedRequest("GET", key, { query: opts, tolerated: [ 200, 404, 412, 304 ], headers: ssecHeaders ? { ...ssecHeaders } : void 0 }); if (res.status === 200) return res.json(); res.body?.cancel(); return null; } /** * 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. */ async getObjectWithETag(key, opts = {}, ssecHeaders) { try { const res = await this._signedRequest("GET", key, { query: opts, tolerated: [ 200, 404, 412, 304 ], headers: ssecHeaders ? { ...ssecHeaders } : void 0 }); const s = res.status; if (s === 404 || s === 412 || s === 304) { res.body?.cancel(); return { etag: null, data: null }; } const etag = res.headers.get(HEADER_ETAG); if (!etag) throw new Error(`${ERROR_PREFIX}ETag not found in response headers`); return { etag: 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. */ async getObjectRaw(key, wholeFile = true, rangeFrom = 0, rangeTo, opts = {}, ssecHeaders) { let rangeHdr = {}; if (!wholeFile) rangeHdr = rangeTo === void 0 ? { range: `bytes=${rangeFrom}-` } : { range: `bytes=${rangeFrom}-${rangeTo - 1}` }; return this._signedRequest("GET", key, { query: { ...opts }, headers: { ...rangeHdr, ...ssecHeaders }, withQuery: true }); } /** * 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; 0 when the object exists but the response carries no content-length header. * @throws {Error} If the object does not exist (HTTP 404) or the request otherwise fails; the underlying S3ServiceError is attached as `.cause`. */ async getContentLength(key, ssecHeaders) { try { if (this._bun && !ssecHeaders) try { return (await this._bun.file(key).stat()).size; } catch (e) { throw this._bunError(e); } const len = (await this._signedRequest("HEAD", key, { headers: ssecHeaders ? { ...ssecHeaders } : void 0 })).headers.get(HEADER_CONTENT_LENGTH); return len ? +len : 0; } catch (err) { this._log("error", `Error getting content length for object ${key}: ${String(err)}`); throw new Error(`${ERROR_PREFIX}Error getting content length for object ${key}: ${String(err)}`, { cause: 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. */ async objectExists(key, opts = {}) { if (this._bun && !Object.keys(opts).length) try { return await this._bun.file(key).exists(); } catch (e) { throw this._bunError(e); } const res = await this._signedRequest("HEAD", key, { query: opts, tolerated: [ 200, 404, 412, 304 ] }); if (res.status === 404) return false; if (res.status === 412 || res.status === 304) return null; return true; } /** * 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}`); * } */ async getEtag(key, opts = {}, ssecHeaders) { if (this._bun && !ssecHeaders && !Object.keys(opts).length) return this._bunRead(key, async (f) => { const { etag } = await f.stat(); if (!etag) throw new Error(`${ERROR_PREFIX}ETag not found in response headers`); return sanitizeETag(etag); }); const res = await this._signedRequest("HEAD", key, { query: opts, tolerated: [ 200, 304, 404, 412 ], headers: ssecHeaders ? { ...ssecHeaders } : void 0 }); if (res.status === 404) return null; if (res.status === 412 || res.status === 304) return null; const etag = res.headers.get(HEADER_ETAG); if (!etag) throw new Error(`${ERROR_PREFIX}ETag not found in response headers`); return sanitizeETag(etag); } /** * Uploads an object to the S3-compatible service. * @param {string} key - The key/path where the object will be stored. * @param {string | IT.MaybeBuffer | ReadableStream | File | Blob} 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. * @param {IT.AWSHeaders} [additionalHeaders] - Additional x-amz-* headers specific to this request, 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.putObjec