s3mini
Version:
đź‘¶ Tiny & fast S3 client for node and edge computing platforms
1,283 lines • 84.1 kB
JavaScript
//#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 = {
"\"": "",
""": "",
""": ""
};
return etag.replaceAll(/(^("|"|"))|(("|"|")$)/g, (m) => replaceChars[m] || "");
};
const entityMap = {
""": "\"",
"'": "'",
"<": "<",
">": ">",
"&": "&"
};
/**
* Escape special characters for XML
* @param value String to escape
* @returns XML-escaped string
*/
const escapeXml = (value) => {
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
};
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