@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
JavaScript
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