UNPKG

@happy-ts/fetch-t

Version:

Type-safe Fetch API wrapper with abortable requests, timeout support, progress tracking, automatic retry, and Rust-like Result error handling.

317 lines (312 loc) 8.77 kB
import { Err, Ok } from 'happy-rusty'; const ABORT_ERROR = "AbortError"; const TIMEOUT_ERROR = "TimeoutError"; class FetchError extends Error { /** * The error name, always `'FetchError'`. */ name = "FetchError"; /** * The HTTP status code of the response (e.g., 404, 500). */ status; /** * Creates a new FetchError instance. * * @param message - The status text from the HTTP response (e.g., "Not Found"). * @param status - The HTTP status code (e.g., 404). */ constructor(message, status) { super(message); this.status = status; } } function fetchT(url, init) { const parsedUrl = validateUrl(url); const fetchInit = init ?? {}; const { retries, delay: retryDelay, when: retryWhen, onRetry } = validateOptions(fetchInit); const { // default not abortable abortable = false, responseType, timeout, onProgress, onChunk, ...rest } = fetchInit; const userSignal = rest.signal; let userController; if (abortable) { userController = new AbortController(); } const shouldRetry = (error, attempt) => { if (error.name === ABORT_ERROR) { return false; } if (!retryWhen) { return !(error instanceof FetchError); } if (Array.isArray(retryWhen)) { return error instanceof FetchError && retryWhen.includes(error.status); } return retryWhen(error, attempt); }; const getRetryDelay = (attempt) => { return typeof retryDelay === "function" ? retryDelay(attempt) : retryDelay; }; const configureSignal = () => { const signals = []; if (userSignal) { signals.push(userSignal); } if (userController) { signals.push(userController.signal); } if (typeof timeout === "number") { signals.push(AbortSignal.timeout(timeout)); } if (signals.length > 0) { rest.signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals); } }; const doFetch = async () => { configureSignal(); try { const response = await fetch(parsedUrl, rest); if (!response.ok) { response.body?.cancel().catch(() => { }); return Err(new FetchError(response.statusText, response.status)); } return await processResponse(response); } catch (err) { return Err( err instanceof Error ? err : wrapAbortReason(err) ); } }; const setupProgressCallbacks = async (response) => { let totalByteLength; let completedByteLength = 0; if (onProgress) { const contentLength = response.headers.get("content-length"); if (contentLength == null) { try { onProgress(Err(new Error("No content-length in response headers"))); } catch { } } else { totalByteLength = Number.parseInt(contentLength, 10); } } const body = response.clone().body; try { for await (const chunk of body) { if (onChunk) { try { onChunk(chunk); } catch { } } if (onProgress && totalByteLength != null) { completedByteLength += chunk.byteLength; try { onProgress(Ok({ totalByteLength, completedByteLength })); } catch { } } } } catch { } }; const processResponse = async (response) => { if (response.body && (onProgress || onChunk)) { setupProgressCallbacks(response); } switch (responseType) { case "json": { if (response.body == null) { return Ok(null); } try { return Ok(await response.json()); } catch { return Err(new Error("Response is invalid json while responseType is json")); } } case "text": { return Ok(await response.text()); } case "bytes": { if (typeof response.bytes === "function") { return Ok(await response.bytes()); } return Ok(new Uint8Array(await response.arrayBuffer())); } case "arraybuffer": { return Ok(await response.arrayBuffer()); } case "blob": { return Ok(await response.blob()); } case "stream": { return Ok(response.body); } default: { return Ok(response); } } }; const fetchWithRetry = async () => { let lastError; let attempt = 0; do { if (attempt > 0) { if (userController?.signal.aborted) { return Err(userController.signal.reason); } const delayMs = getRetryDelay(attempt); if (delayMs > 0) { await delay(delayMs); if (userController?.signal.aborted) { return Err(userController.signal.reason); } } try { onRetry?.(lastError, attempt); } catch { } } const result2 = await doFetch(); if (result2.isOk()) { return result2; } lastError = result2.unwrapErr(); attempt++; } while (attempt <= retries && shouldRetry(lastError, attempt)); return Err(lastError); }; const result = fetchWithRetry(); if (abortable && userController) { return { // eslint-disable-next-line @typescript-eslint/no-explicit-any abort(reason) { if (reason instanceof Error) { userController.abort(reason); } else if (reason != null) { userController.abort(wrapAbortReason(reason)); } else { userController.abort(); } }, get aborted() { return userController.signal.aborted; }, get result() { return result; } }; } return result; } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function wrapAbortReason(reason) { const error = new Error(typeof reason === "string" ? reason : String(reason)); error.name = ABORT_ERROR; error.cause = reason; return error; } function validateOptions(init) { const { responseType, timeout, retry: retryOptions = 0, onProgress, onChunk } = init; if (responseType != null) { const validTypes = ["text", "arraybuffer", "blob", "json", "bytes", "stream"]; if (!validTypes.includes(responseType)) { throw new TypeError(`responseType must be one of ${validTypes.join(", ")} but received ${responseType}`); } } if (timeout != null) { if (typeof timeout !== "number") { throw new TypeError(`timeout must be a number but received ${typeof timeout}`); } if (timeout <= 0) { throw new Error(`timeout must be a number greater than 0 but received ${timeout}`); } } if (onProgress != null) { if (typeof onProgress !== "function") { throw new TypeError(`onProgress callback must be a function but received ${typeof onProgress}`); } } if (onChunk != null) { if (typeof onChunk !== "function") { throw new TypeError(`onChunk callback must be a function but received ${typeof onChunk}`); } } let retries = 0; let delay2 = 0; let when; let onRetry; if (typeof retryOptions === "number") { retries = retryOptions; } else if (retryOptions && typeof retryOptions === "object") { retries = retryOptions.retries ?? 0; delay2 = retryOptions.delay ?? 0; when = retryOptions.when; onRetry = retryOptions.onRetry; } if (!Number.isInteger(retries)) { throw new TypeError(`Retry count must be an integer but received ${retries}`); } if (retries < 0) { throw new Error(`Retry count must be non-negative but received ${retries}`); } if (typeof delay2 === "number") { if (delay2 < 0) { throw new Error(`Retry delay must be a non-negative number but received ${delay2}`); } } else { if (typeof delay2 !== "function") { throw new TypeError(`Retry delay must be a number or a function but received ${typeof delay2}`); } } if (when != null) { if (!Array.isArray(when) && typeof when !== "function") { throw new TypeError(`Retry when condition must be an array of status codes or a function but received ${typeof when}`); } } if (onRetry != null) { if (typeof onRetry !== "function") { throw new TypeError(`Retry onRetry callback must be a function but received ${typeof onRetry}`); } } return { retries, delay: delay2, when, onRetry }; } function validateUrl(url) { if (url instanceof URL) { return url; } try { const base = typeof location !== "undefined" ? location.href : void 0; return new URL(url, base); } catch { throw new TypeError(`Invalid URL: ${url}`); } } export { ABORT_ERROR, FetchError, TIMEOUT_ERROR, fetchT }; //# sourceMappingURL=main.mjs.map