UNPKG

@uploadx/client

Version:

Resumable upload client for browser and Node.js

396 lines (395 loc) 15.9 kB
import axios from 'axios'; import axiosRetry, { exponentialDelay } from 'axios-retry'; const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null; export class AbortError extends Error { constructor(message = 'Operation aborted') { super(message); this.name = 'AbortError'; } } /** * Client for handling resumable file uploads * * Supports chunked uploads with progress tracking, resuming interrupted uploads, * and works in both browser and Node.js environments. */ export class UploadxClient { /** * Creates a new UploadxClient instance * @param config - Configuration options for chunk size, retry behavior, and axios settings */ constructor(config = {}) { this.abortController = new AbortController(); this.chunkSize = config.chunkSize || 5 * 1024 * 1024; this.client = axios.create({ maxBodyLength: Number.POSITIVE_INFINITY, maxContentLength: Number.POSITIVE_INFINITY, timeout: 60000, validateStatus: (status) => (status >= 200 && status < 400) || status === 308, signal: this.abortController.signal, ...config.requestConfig, }); axiosRetry(this.client, { retries: 5, retryDelay: exponentialDelay, retryCondition: (error) => { var _a, _b; const isNetworkError = axiosRetry.isNetworkError(error) || error.code === 'ECONNRESET'; return (isNetworkError || axiosRetry.isRetryableError(error) || ((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) === 429 || (((_b = error.response) === null || _b === void 0 ? void 0 : _b.status) || 0) >= 500); }, ...config.retryConfig, }); } /** * Aborts all ongoing upload operations * Cancels any in-progress uploads initiated by this client instance */ abort() { this.abortController.abort(); // Create a new controller for future operations this.abortController = new AbortController(); } /** * Deletes an existing upload from the server */ async deleteUpload(url, signal) { try { await this.client.delete(url, { signal }); } catch (error) { throw this.handleError(error, 'Failed to delete upload'); } } /** * * Updates the metadata of an existing upload */ async updateUpload(url, metadata, signal) { try { await this.client.patch(url, metadata, { headers: { 'Content-Type': 'application/json', }, signal, }); } catch (error) { throw this.handleError(error, 'Failed to update metadata'); } } /** * Creates a new upload session on the server */ async createUpload(endpoint, metadata, signal) { var _a; try { const response = await this.client.post(endpoint, metadata, { headers: { 'X-Upload-Content-Length': metadata.size, 'X-Upload-Content-Type': metadata.mimeType || 'application/octet-stream', 'Content-Type': 'application/json', }, signal, }); if (!response.headers.location) { throw new Error('Missing Location header in response'); } const rangeHeader = (_a = response === null || response === void 0 ? void 0 : response.headers) === null || _a === void 0 ? void 0 : _a.range; return { url: new URL(response.headers.location, endpoint).toString(), uploadedBytes: this.parseRangeHeader(rangeHeader), }; } catch (error) { throw this.handleError(error, 'Session creation failed'); } } /** * Creates an upload session for a file in Node.js environment */ async createFileUpload(filePath, metadata, endpoint, signal) { if (!isNode) { throw new Error('uploadAsFile is only available in Node.js environment'); } const fsPromises = await import('node:fs/promises'); const stats = await fsPromises.stat(filePath); const fileSize = stats.size; const actualMetadata = { ...metadata, size: fileSize, lastModified: metadata.lastModified || stats.mtimeMs, }; return this.createUpload(endpoint, actualMetadata, signal); } /** * Uploads a file from disk (Node.js only) */ async fileUpload(endpoint, filePath, metadata, onProgress, signal) { try { const session = await this.createFileUpload(filePath, metadata, endpoint, signal); const sessionUrl = session.url; const start = session.uploadedBytes || 0; const totalSize = metadata.size; await this.uploadFileInChunks(sessionUrl, filePath, start, totalSize, onProgress, signal); } catch (error) { // Don't throw if the operation was aborted if (error instanceof AbortError) { return; } throw this.handleError(error, `File upload failed for ${filePath}`); } } /** * Uploads data from various sources (Blob, File, Stream, Buffer) */ async upload(endpoint, data, metadata, onProgress, signal) { try { const { url, uploadedBytes } = await this.createUpload(endpoint, metadata, signal); const start = uploadedBytes || 0; const totalSize = metadata.size; await this.uploadDataInChunks(data, url, start, totalSize, onProgress, signal); } catch (error) { // Don't throw if the operation was aborted if (error instanceof AbortError) { return; } throw this.handleError(error, 'Upload failed'); } } /** * Resumes an upload from a previously created session */ async resumeUpload(url, data, metadata, onProgress, signal) { try { const response = await this.getUploadStatus(url, metadata, signal); const start = response.uploadedBytes; const totalSize = metadata.size; await this.uploadDataInChunks(data, url, start, totalSize, onProgress, signal); } catch (error) { // Don't throw if the operation was aborted if (error instanceof AbortError) { return; } throw this.handleError(error, 'Upload failed'); } } /** * Resumes a file upload from disk (Node.js only) */ async resumeFileUpload(url, filePath, metadata, onProgress, signal) { if (!isNode) { throw new Error('uploadAsFile is only available in Node.js environment'); } try { const response = await this.getUploadStatus(url, metadata, signal); const start = response.uploadedBytes; const totalSize = metadata.size; await this.uploadFileInChunks(url, filePath, start, totalSize, onProgress, signal); } catch (error) { // Don't throw if the operation was aborted if (error instanceof AbortError) { return; } throw this.handleError(error, `File upload failed for ${filePath}`); } } /** * Gets the current upload progress from the server */ async getUploadStatus(url, metadata, signal) { var _a; const headers = { 'Content-Type': 'application/octet-stream', 'Content-Range': `bytes */${(metadata === null || metadata === void 0 ? void 0 : metadata.size) || '*'}`, }; const response = await this.client.put(url, null, { headers, signal, }); const rangeHeader = (_a = response === null || response === void 0 ? void 0 : response.headers) === null || _a === void 0 ? void 0 : _a.range; const uploadedBytes = this.parseRangeHeader(rangeHeader); return { uploadedBytes }; } async uploadChunk(url, data, start, end, totalSize, onProgress, signal) { var _a; const headers = { 'Content-Type': 'application/octet-stream', 'Content-Range': `bytes ${start}-${end - 1}/${totalSize}`, }; const response = await this.client.put(url, data, { headers, signal, onUploadProgress: onProgress ? (progressEvent) => { const chunkUploaded = progressEvent.loaded; const totalUploaded = start + chunkUploaded; const progress = totalUploaded / totalSize; onProgress(Math.min(progress, 1)); } : undefined, }); const rangeHeader = (_a = response === null || response === void 0 ? void 0 : response.headers) === null || _a === void 0 ? void 0 : _a.range; const uploadedBytes = this.parseRangeHeader(rangeHeader); return { uploadedBytes }; } async uploadDataInChunks(data, url, start, totalSize, onProgress, signal) { if (this.isBlob(data)) { await this.uploadBlobInChunks(url, data, start, totalSize, onProgress, signal); } else if (this.isStream(data)) { await this.uploadStreamInChunks(url, data, start, totalSize, onProgress, signal); } else { await this.uploadBufferInChunks(url, data, start, totalSize, onProgress, signal); } } async uploadFileInChunks(url, filePath, start, totalSize, onProgress, signal) { if (!isNode) { throw new Error('uploadAsFile is only available in Node.js environment'); } const { createReadStream } = await import('node:fs'); let position = start; while (position < totalSize) { // Check if the operation was aborted if (signal === null || signal === void 0 ? void 0 : signal.aborted) { throw new AbortError(); } const end = Math.min(position + this.chunkSize, totalSize); const fileChunk = createReadStream(filePath, { start: position, end: end, }); const result = await this.uploadChunk(url, fileChunk, position, end, totalSize, onProgress, signal); position = end; if (result.uploadedBytes !== undefined) { const newPosition = result.uploadedBytes; if (newPosition > position) { position = newPosition; } } } } async uploadBlobInChunks(url, blob, start, totalSize, onProgress, signal) { let position = start; while (position < totalSize) { // Check if the operation was aborted if (signal === null || signal === void 0 ? void 0 : signal.aborted) { throw new AbortError(); } const end = Math.min(position + this.chunkSize, totalSize); const chunk = blob.slice(position, end); await this.uploadChunk(url, chunk, position, end, totalSize, onProgress, signal); position = end; } } async uploadStreamInChunks(url, stream, start, totalSize, onProgress, signal) { if (!isNode) { throw new Error('Stream uploads are only available in Node.js environment'); } let position = start; let chunks = []; let chunksSize = 0; return new Promise((resolve, reject) => { const abortHandler = () => { reject(new AbortError()); }; if (signal) { if (signal.aborted) { abortHandler(); return; } signal.addEventListener('abort', abortHandler); } stream.on('data', (chunk) => { chunks.push(chunk); chunksSize += chunk.length; while (chunksSize >= this.chunkSize) { const end = Math.min(position + this.chunkSize, totalSize); const chunkBuffer = Buffer.concat(chunks); chunks = [chunkBuffer.slice(end - position)]; chunksSize = chunkBuffer.length - (end - position); stream.pause(); this.uploadChunk(url, chunkBuffer.slice(0, end - position), position, end, totalSize, onProgress, signal) .then(() => { position = end; stream.resume(); }) .catch((error) => { reject(error); }); } }); stream.on('end', async () => { if (signal) { signal.removeEventListener('abort', abortHandler); } if (chunks.length > 0 && position < totalSize) { if (signal === null || signal === void 0 ? void 0 : signal.aborted) { reject(new AbortError()); return; } const end = Math.min(position + chunksSize, totalSize); const chunkBuffer = Buffer.concat(chunks); try { await this.uploadChunk(url, chunkBuffer, position, end, totalSize, onProgress, signal); position = end; } catch (error) { reject(error); return; } } resolve(); }); stream.on('error', (error) => { if (signal) { signal.removeEventListener('abort', abortHandler); } reject(error); }); }); } async uploadBufferInChunks(url, buffer, start, totalSize, onProgress, signal) { const view = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer; let position = start; while (position < totalSize) { // Check if the operation was aborted if (signal === null || signal === void 0 ? void 0 : signal.aborted) { throw new AbortError(); } const end = Math.min(position + this.chunkSize, totalSize); const chunk = view.subarray(position, end); await this.uploadChunk(url, chunk, position, end, totalSize, onProgress, signal); position = end; } } isBlob(data) { return typeof Blob !== 'undefined' && data instanceof Blob; } isStream(data) { return !!data && typeof data.pipe === 'function'; } parseRangeHeader(range) { if (!range) return 0; const matches = range.match(/bytes=\d+-(\d+)/); return matches ? Number.parseInt(matches[1], 10) + 1 : 0; } handleError(error, context) { var _a, _b, _c; if (error instanceof AbortError) { return error; } if (axios.isAxiosError(error)) { return new Error(`${context}: ${error.message} ${((_c = (_b = (_a = error.response) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.error) === null || _c === void 0 ? void 0 : _c.message) || error.cause} `); } if (error instanceof Error) { return new Error(`${context}: ${error.message}`); } return new Error(context); } }