s3mini
Version:
πΆ Tiny & fast S3 client for node and edge computing platforms
222 lines (197 loc) β’ 7.14 kB
text/typescript
;
import type { Crypto, XmlValue, XmlMap, ListBucketResponse, ErrorWithCode } from './types.js';
declare const crypto: Crypto;
// Initialize crypto functions - this is needed for environments where `crypto` is not available globally
// e.g., in Cloudflare Workers or other non-Node.js environments with nodejs_flags enabled.
const _createHmac: Crypto['createHmac'] = crypto.createHmac || (await import('node:crypto')).createHmac;
const _createHash: Crypto['createHash'] = crypto.createHash || (await import('node:crypto')).createHash;
/**
* Hash content using SHA-256
* @param {string|Buffer} content β data to hash
* @returns {string} Hex encoded hash
*/
export const hash = (content: string | Buffer): string => {
return _createHash('sha256').update(content).digest('hex') as string;
};
export const md5base64 = (data: string | Buffer): string => {
return _createHash('md5').update(data).digest('base64') as string;
};
/**
* Compute HMAC-SHA-256 of arbitrary data and return a hex string.
* @param {string|Buffer} key β secret key
* @param {string|Buffer} content β data to authenticate
* @param {BufferEncoding} [encoding='hex'] β hex | base64 | β¦
* @returns {string | Buffer} hex encoded HMAC
*/
export const hmac = (key: string | Buffer, content: string | Buffer, encoding?: 'hex' | 'base64'): string | Buffer => {
const mac = _createHmac('sha256', key).update(content);
return encoding ? mac.digest(encoding) : mac.digest();
};
/**
* Sanitize ETag value by removing quotes and XML entities
* @param etag ETag value to sanitize
* @returns Sanitized ETag
*/
export const sanitizeETag = (etag: string): string => {
const replaceChars: Record<string, string> = {
'"': '',
'"': '',
'"': '',
'"': '',
'"': '',
};
return etag.replace(/^("|"|")|("|"|")$/g, m => replaceChars[m] as string);
};
const entityMap = {
'"': '"',
''': "'",
'<': '<',
'>': '>',
'&': '&',
} as const;
/**
* Escape special characters for XML
* @param value String to escape
* @returns XML-escaped string
*/
export const escapeXml = (value: string): string => {
return value
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
const unescapeXml = (value: string): string =>
value.replace(/&(quot|apos|lt|gt|amp);/g, m => entityMap[m as keyof typeof entityMap] ?? 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
*/
export const parseXml = (input: string): XmlValue => {
const xmlContent = input.replace(/<\?xml[^?]*\?>\s*/, '');
const RE_TAG = /<([A-Za-z_][\w\-.]*)[^>]*>([\s\S]*?)<\/\1>/gm;
const result: XmlMap = {}; // strong type, no `any`
let match: RegExpExecArray | null;
while ((match = RE_TAG.exec(xmlContent)) !== null) {
const tagName = match[1];
const innerContent = match[2];
const node: XmlValue = innerContent ? parseXml(innerContent) : unescapeXml(innerContent?.trim() || '');
if (!tagName) {
continue;
}
const current = result[tagName];
if (current === undefined) {
// First occurrence
result[tagName] = node;
} else if (Array.isArray(current)) {
// Already an array
current.push(node);
} else {
// Promote to array on the second occurrence
result[tagName] = [current, node];
}
}
// No child tags? β return the text, after entity decode
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: string): string => `%${c.charCodeAt(0).toString(16).toUpperCase()}`;
/**
* Escape a URI string using percent encoding
* @param uriStr URI string to escape
* @returns Escaped URI string
*/
export const uriEscape = (uriStr: string): string => {
return encodeURIComponent(uriStr).replace(/[!'()*]/g, encodeAsHex);
};
/**
* Escape a URI resource path while preserving forward slashes
* @param string URI path to escape
* @returns Escaped URI path
*/
export const uriResourceEscape = (string: string): string => {
return uriEscape(string).replace(/%2F/g, '/');
};
export const isListBucketResponse = (value: unknown): value is ListBucketResponse => {
return typeof value === 'object' && value !== null && ('listBucketResult' in value || 'error' in value);
};
export const extractErrCode = (e: unknown): string | undefined => {
if (typeof e !== 'object' || e === null) {
return undefined;
}
const err = e as ErrorWithCode;
if (typeof err.code === 'string') {
return err.code;
}
return typeof err.cause?.code === 'string' ? err.cause.code : undefined;
};
export class S3Error extends Error {
readonly code?: string;
constructor(msg: string, code?: string, cause?: unknown) {
super(msg);
this.name = new.target.name; // keeps instanceof usable
this.code = code;
this.cause = cause;
}
}
export class S3NetworkError extends S3Error {}
export class S3ServiceError extends S3Error {
readonly status: number;
readonly serviceCode?: string;
body: string | undefined;
constructor(msg: string, status: number, serviceCode?: string, body?: string) {
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>>>}
*/
export const runInBatches = async <T = unknown>(
tasks: Iterable<() => Promise<T>>,
batchSize: number = 30,
minIntervalMs: number = 0,
): Promise<Array<PromiseSettledResult<T>>> => {
const allResults: PromiseSettledResult<T>[] = [];
let batch: Array<() => Promise<T>> = [];
for (const task of tasks) {
batch.push(task);
if (batch.length === batchSize) {
await executeBatch(batch);
batch = [];
}
}
if (batch.length) {
await executeBatch(batch);
}
return allResults;
// βββββββββ helpers ββββββββββ
async function executeBatch(batchFns: ReadonlyArray<() => Promise<T>>): Promise<void> {
const start: number = Date.now();
const settled: Array<PromiseSettledResult<T>> = await Promise.allSettled(
batchFns.map((fn: () => Promise<T>) => fn()),
);
allResults.push(...settled);
if (minIntervalMs > 0) {
const wait: number = minIntervalMs - (Date.now() - start);
if (wait > 0) {
await new Promise<void>((resolve: () => void) => setTimeout(resolve, wait));
}
}
}
};