UNPKG

s3mini

Version:

πŸ‘Ά Tiny & fast S3 client for node and edge computing platforms

267 lines (238 loc) β€’ 8.03 kB
'use strict'; import type { XmlValue, XmlMap, ListBucketResponse, ErrorWithCode } from './types.js'; const ENCODR = new TextEncoder(); const chunkSize = 0x8000; // 32KB chunks const HEXS = '0123456789abcdef'; export const getByteSize = (data: unknown): number => { if (typeof data === 'string') { return ENCODR.encode(data).byteLength; } if (data instanceof ArrayBuffer || data instanceof Uint8Array) { return data.byteLength; } if (data instanceof Blob) { return data.size; } throw new Error('Unsupported data type'); }; /** * Turn a raw ArrayBuffer into its hexadecimal representation. * @param {ArrayBuffer} buffer The raw bytes. * @returns {string} Hexadecimal string */ export const hexFromBuffer = (buffer: ArrayBuffer): string => { const bytes = new Uint8Array(buffer); let hex = ''; for (const byte of bytes) { hex += HEXS[byte >> 4]! + HEXS[byte & 0x0f]!; } return hex; }; /** * Turn a raw ArrayBuffer into its base64 representation. * @param {ArrayBuffer} buffer The raw bytes. * @returns {string} Base64 string */ export const base64FromBuffer = (buffer: ArrayBuffer): string => { 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.fromCharCode.apply(null, chunk as unknown as number[])); } return result; }; /** * Compute SHA-256 hash of arbitrary string data. * @param {string} content The content to be hashed. * @returns {ArrayBuffer} The raw hash */ export const sha256 = async (content: string): Promise<ArrayBuffer> => { 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 */ export const hmac = async (key: string | ArrayBuffer, content: string): Promise<ArrayBuffer> => { 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 */ export const sanitizeETag = (etag: string): string => { const replaceChars: Record<string, string> = { '"': '', '&quot;': '', '&#34;': '', '&QUOT;': '', '&#x00022': '', }; return etag.replace(/(^("|&quot;|&#34;))|(("|&quot;|&#34;)$)/g, m => replaceChars[m] as string); }; const entityMap = { '&quot;': '"', '&apos;': "'", '&lt;': '<', '&gt;': '>', '&amp;': '&', } 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, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&apos;'); }; 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)); } } } };