arcx
Version:
A lightweight, dependency-free fetch utility for APIs and React.
151 lines (150 loc) • 5 kB
JavaScript
import { globalConfig } from "./config";
import { HTTPError, NetworkError } from "./errors";
/** A small delay utility for retries. */
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Unified parse for various response types.
* Forces the result to type `T` after parsing.
*/
async function parseResponse(response, parseAs) {
let parsed;
switch (parseAs) {
case "text":
parsed = await response.text();
break;
case "blob":
parsed = await response.blob();
break;
case "arrayBuffer":
parsed = await response.arrayBuffer();
break;
case "json":
default:
parsed = await response.json();
}
return parsed;
}
/** Convert `HeadersInit` to a `Record<string, string>` if possible. */
function toRecord(headersInit) {
if (!headersInit)
return undefined;
if (headersInit instanceof Headers) {
const result = {};
headersInit.forEach((value, key) => {
result[key] = value;
});
return result;
}
else if (Array.isArray(headersInit)) {
const result = {};
for (const [k, v] of headersInit) {
result[k] = v;
}
return result;
}
else {
// It's already a record
return headersInit;
}
}
/**
* Merges multiple header objects into one, giving precedence
* to headers from later objects in case of conflicts.
*/
function mergeHeaders(...headerObjects) {
return headerObjects.reduce((acc, obj) => {
if (!obj)
return acc;
for (const [k, v] of Object.entries(obj)) {
acc[k] = v;
}
return acc;
}, {});
}
/**
* A typed fetch request with retries, timeouts, interceptors, etc.
*
* @template T - The shape of the final parsed response.
* @param url - Endpoint path
* @param options - Additional ArcX fetch options
*/
export async function fetchRequest(url, options = {}) {
// Combine config from global + user options
const { interceptors, headers: globalHeaders, ...globalRequestInit } = globalConfig;
// Build final URL (including optional query params)
let finalUrl = globalConfig.baseUrl ? globalConfig.baseUrl + url : url;
if (options.queryParams) {
const params = new URLSearchParams(Object.entries(options.queryParams).reduce((acc, [key, val]) => {
acc[key] = String(val);
return acc;
}, {})).toString();
finalUrl += `?${params}`;
}
// Merge headers
const mergedHeaders = mergeHeaders(toRecord(globalHeaders), toRecord(options.headers));
// Construct final RequestInit
const config = {
...globalRequestInit,
...options,
headers: mergedHeaders,
};
// onRequest interceptor
if (interceptors?.onRequest) {
Object.assign(config, interceptors.onRequest(config));
}
// If body is a plain object, JSONify it
if (config.body &&
typeof config.body === "object" &&
!(config.body instanceof FormData)) {
config.body = JSON.stringify(config.body);
config.headers = {
...config.headers,
"Content-Type": "application/json",
};
}
// Timeout with AbortController
const controller = new AbortController();
config.signal = controller.signal;
if (options.timeout) {
setTimeout(() => controller.abort(), options.timeout);
}
// Handling retries
let attempt = 0;
const maxRetries = options.retries ?? 0;
const parseType = options.parseAs ?? "json";
while (attempt <= maxRetries) {
try {
const response = await fetch(finalUrl, config);
if (!response.ok) {
const responseBody = await response.text().catch(() => "Unknown error");
throw new HTTPError(`HTTP Error: ${response.status} (${response.statusText}) [URL: ${finalUrl}]`, response.status, responseBody);
}
// Parse according to parseType
let result = await parseResponse(response, parseType);
// onResponse interceptor must return T or Promise<T>
if (interceptors?.onResponse) {
result = await interceptors.onResponse(result);
}
return result;
}
catch (error) {
// onError interceptor
if (interceptors?.onError) {
interceptors.onError(error);
}
// If final attempt fails, throw
if (attempt === maxRetries) {
if (!(error instanceof Error)) {
throw new NetworkError("Unknown network error occurred.");
}
throw error;
}
attempt++;
// Exponential backoff
await delay(2 ** attempt * 100);
}
}
throw new NetworkError("Request failed after retries.");
}