UNPKG

setu.js

Version:

A lightweight HTTP client for Node.js and the browser, with smart adapter selection.

284 lines 11.7 kB
import http from 'http'; import https from 'https'; import { URL } from 'url'; import FormData from 'form-data'; import { defaults } from './defaults.js'; import { buildQuery, mergeHeaders } from './utils.js'; function delay(ms) { return new Promise((res) => setTimeout(res, ms)); } function isBinaryContentType(contentType) { return /(application|image|audio|video|octet-stream)/i.test(contentType) && !/json|text|xml/i.test(contentType); } export async function coreRequest(url, config = {}) { const attempts = config.retry ?? 0; const delayMs = config.retryDelay ?? 300; let lastError; for (let i = 0; i <= attempts; i++) { try { return await makeRequest(url, config); } catch (err) { lastError = err; if (i < attempts) await delay(delayMs); } } throw lastError; } function makeRequest(url, config) { return new Promise((resolve, reject) => { // Build URL with proper error handling let finalUrl; try { // Handle URL construction - check for absolute URLs let urlToUse = url; if (!url.startsWith('http://') && !url.startsWith('https://')) { if (!defaults.baseURL) { return reject(buildError('Invalid URL: relative URL provided but no baseURL is set', config, 'ERR_INVALID_URL', null)); } // Ensure proper joining const base = defaults.baseURL.endsWith('/') ? defaults.baseURL.slice(0, -1) : defaults.baseURL; const path = url.startsWith('/') ? url : '/' + url; urlToUse = base + path; } finalUrl = new URL(urlToUse); } catch (urlError) { return reject(buildError(`Invalid URL: ${urlError.message}`, config, 'ERR_INVALID_URL', null)); } // Handle query params - buildQuery returns '?params' or '', but URL.search expects just the query string if (config.params) { const queryString = buildQuery(config.params); // buildQuery returns '?params' or '', so remove the '?' if present finalUrl.search = queryString.startsWith('?') ? queryString.slice(1) : queryString; } const lib = finalUrl.protocol === 'https:' ? https : http; const headers = mergeHeaders(defaults.headers, config.headers); const method = config.method || 'GET'; let body = config.body; let isForm = false; if (body instanceof FormData) { isForm = true; Object.assign(headers, body.getHeaders()); } else if (body !== null && body !== undefined) { // Handle different body types if (body instanceof Buffer) { // Buffer - set Content-Length headers['Content-Length'] = body.length.toString(); } else if (typeof body === 'string') { // String - set Content-Length headers['Content-Length'] = Buffer.byteLength(body, 'utf8').toString(); } else if (typeof body === 'object') { // Object (but not FormData, Buffer, or null) - stringify as JSON try { body = JSON.stringify(body); headers['Content-Type'] = headers['Content-Type'] || 'application/json'; headers['Content-Length'] = Buffer.byteLength(body, 'utf8').toString(); } catch (stringifyError) { return reject(buildError(`Failed to stringify request body: ${stringifyError.message}`, config, 'ERR_BODY_STRINGIFY', null)); } } // For other types (number, boolean, etc.), convert to string else { body = String(body); headers['Content-Length'] = Buffer.byteLength(body, 'utf8').toString(); } } const reqOptions = { method, headers, timeout: config.timeout || defaults.timeout, }; const req = lib.request(finalUrl, reqOptions, (res) => { const total = parseInt(res.headers['content-length'] || '0'); let downloaded = 0; if (config.responseType === 'stream') { return resolve({ status: res.statusCode || 200, headers: res.headers, data: res, }); } const chunks = []; res.setEncoding(null); res.on('data', (chunk) => { const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); chunks.push(bufferChunk); downloaded += bufferChunk.length; if (config.onDownloadProgress && total > 0) { config.onDownloadProgress({ loaded: downloaded, total, percent: (downloaded / total) * 100, }); } }); res.on('end', () => { // Handle empty response body const buffer = chunks.length > 0 ? Buffer.concat(chunks) : Buffer.alloc(0); const contentType = res.headers['content-type'] || ''; const status = res.statusCode || 200; let parsed; const isBinary = config.responseType === 'blob' || config.responseType === 'arraybuffer' || isBinaryContentType(contentType); try { if (isBinary) { parsed = buffer; } else if (config.responseType === 'stream') { // Should have been handled earlier, but just in case parsed = buffer; } else if (contentType.includes('application/json') || contentType.includes('application/vnd.api+json')) { // Handle empty JSON response if (buffer.length === 0) { parsed = null; } else { parsed = JSON.parse(buffer.toString('utf-8')); } } else { parsed = buffer.toString('utf-8'); } } catch (err) { return reject(buildError(`Failed to parse response: ${err.message || 'Unknown error'}`, config, 'ERR_PARSE', req, { status, headers: res.headers, data: buffer.length > 0 ? buffer.toString('utf-8') : '', }, status)); } const response = { status, headers: res.headers, data: parsed, }; const validateStatus = config.validateStatus || ((status) => status >= 200 && status < 300); // Clean up abort listener on successful response parsing if (config.signal && abortHandler) { config.signal.removeEventListener('abort', abortHandler); } if (validateStatus(status)) { resolve(response); } else { reject(buildError(`Request failed with status code ${status}`, config, null, req, response, status)); } }); let responseErrorHandled = false; res.on('error', (err) => { if (responseErrorHandled) return; responseErrorHandled = true; // Clean up abort listener if (config.signal && abortHandler) { config.signal.removeEventListener('abort', abortHandler); } reject(buildError(`Response stream error: ${err.message || 'Unknown error'}`, config, 'ERR_RESPONSE', req, null)); }); }); let requestErrorHandled = false; req.on('error', (err) => { if (requestErrorHandled) return; requestErrorHandled = true; // Clean up abort listener if (config.signal && abortHandler) { config.signal.removeEventListener('abort', abortHandler); } reject(buildError(err.message, config, 'ERR_NETWORK', req)); }); const timeout = reqOptions.timeout || defaults.timeout; let timeoutHandled = false; req.on('timeout', () => { if (timeoutHandled) return; timeoutHandled = true; req.destroy(); reject(buildError(`Timeout of ${timeout}ms exceeded`, config, 'ECONNABORTED', req)); }); // Track abort handler for cleanup let abortHandler = null; if (config.signal) { abortHandler = () => { if (timeoutHandled) return; timeoutHandled = true; req.destroy(); reject(buildError('Request aborted', config, 'ECONNABORTED', req)); }; config.signal.addEventListener('abort', abortHandler); } // Upload body if (isForm) { let uploaded = 0; let formErrorHandled = false; body.on('data', (chunk) => { uploaded += chunk.length; try { const totalLength = body.getLengthSync(); config.onUploadProgress?.({ loaded: uploaded, total: totalLength || undefined, percent: totalLength ? (uploaded / totalLength) * 100 : 0, }); } catch (lengthError) { // getLengthSync might fail for some FormData, use undefined total config.onUploadProgress?.({ loaded: uploaded, total: undefined, percent: 0, }); } }); body.on('error', (err) => { if (formErrorHandled) return; formErrorHandled = true; // Clean up abort listener if (config.signal && abortHandler) { config.signal.removeEventListener('abort', abortHandler); } reject(buildError(err.message, config, 'ERR_UPLOAD_STREAM', req)); }); body.pipe(req); } else if (body !== null && body !== undefined) { // Write body and end request if (typeof body === 'string' || Buffer.isBuffer(body)) { req.write(body); } else { // For other types, convert to string req.write(String(body)); } req.end(); } else { req.end(); } }); } // ✅ Structured error generator function buildError(message, config, code, request, response, status) { const error = new Error(message); error.config = config; error.code = code; error.request = request; if (response) error.response = response; if (status) error.status = status; return error; } //# sourceMappingURL=nodeAdapter.js.map