@fgrzl/fetch
Version:
A modern, type-safe HTTP client with middleware support for CSRF protection and authentication
1,219 lines (1,199 loc) • 36.5 kB
JavaScript
;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
FetchClient: () => FetchClient,
FetchError: () => FetchError,
HttpError: () => HttpError,
NetworkError: () => NetworkError,
appendQueryParams: () => appendQueryParams,
buildQueryParams: () => buildQueryParams,
createAuthenticationMiddleware: () => createAuthenticationMiddleware,
createAuthorizationMiddleware: () => createAuthorizationMiddleware,
createCacheMiddleware: () => createCacheMiddleware,
createLoggingMiddleware: () => createLoggingMiddleware,
createRateLimitMiddleware: () => createRateLimitMiddleware,
createRetryMiddleware: () => createRetryMiddleware,
default: () => index_default,
useAuthentication: () => useAuthentication,
useAuthorization: () => useAuthorization,
useBasicStack: () => useBasicStack,
useCSRF: () => useCSRF,
useCache: () => useCache,
useDevelopmentStack: () => useDevelopmentStack,
useLogging: () => useLogging,
useProductionStack: () => useProductionStack,
useRateLimit: () => useRateLimit,
useRetry: () => useRetry
});
module.exports = __toCommonJS(index_exports);
// src/client/fetch-client.ts
var FetchClient = class {
constructor(config = {}) {
this.middlewares = [];
this.credentials = config.credentials ?? "same-origin";
this.baseUrl = config.baseUrl;
}
use(middleware) {
this.middlewares.push(middleware);
return this;
}
/**
* Set or update the base URL for this client instance.
*
* When a base URL is set, relative URLs will be resolved against it.
* Absolute URLs will continue to work unchanged.
*
* @param baseUrl - The base URL to set, or undefined to clear it
* @returns The client instance for method chaining
*
* @example Set base URL:
* ```typescript
* const client = new FetchClient();
* client.setBaseUrl('https://api.example.com');
*
* // Now relative URLs work
* await client.get('/users'); // → GET https://api.example.com/users
* ```
*
* @example Chain with middleware:
* ```typescript
* const client = useProductionStack(new FetchClient())
* .setBaseUrl(process.env.API_BASE_URL);
* ```
*/
setBaseUrl(baseUrl) {
this.baseUrl = baseUrl;
return this;
}
async request(url, init = {}) {
const resolvedUrl = this.resolveUrl(url);
let index = 0;
const execute = async (request) => {
const currentRequest = request || { ...init, url: resolvedUrl };
const currentUrl = currentRequest.url || resolvedUrl;
if (index >= this.middlewares.length) {
const { url: _, ...requestInit } = currentRequest;
return this.coreFetch(requestInit, currentUrl);
}
const middleware = this.middlewares[index++];
if (!middleware) {
const { url: _, ...requestInit } = currentRequest;
return this.coreFetch(requestInit, currentUrl);
}
return middleware(currentRequest, execute);
};
const result = await execute();
return result;
}
async coreFetch(request, url) {
try {
const finalInit = {
credentials: this.credentials,
...request
};
if (finalInit.headers instanceof Headers) {
const headersObj = {};
finalInit.headers.forEach((value, key) => {
headersObj[key] = value;
});
finalInit.headers = headersObj;
}
const response = await fetch(url, finalInit);
const data = await this.parseResponse(response);
return {
data: response.ok ? data : null,
status: response.status,
statusText: response.statusText,
headers: response.headers,
url: response.url,
ok: response.ok,
...response.ok ? {} : {
error: {
message: response.statusText,
body: data
}
}
};
} catch (error) {
if (error instanceof TypeError && error.message.includes("fetch")) {
return {
data: null,
status: 0,
statusText: "Network Error",
headers: new Headers(),
url,
ok: false,
error: {
message: "Failed to fetch",
body: error
}
};
}
throw error;
}
}
async parseResponse(res) {
const contentType = res.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
return res.json();
}
if (contentType.includes("text/")) {
return res.text();
}
if (contentType.includes("application/octet-stream") || contentType.includes("image/") || contentType.includes("video/") || contentType.includes("audio/")) {
return res.blob();
}
if (res.body) {
const text = await res.text();
return text || null;
}
return null;
}
// Helper method to build URL with query parameters
buildUrlWithParams(url, params) {
if (!params) {
return url;
}
const resolvedUrl = this.resolveUrl(url);
if (!resolvedUrl.startsWith("http://") && !resolvedUrl.startsWith("https://") && !resolvedUrl.startsWith("//")) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== void 0 && value !== null) {
searchParams.set(key, String(value));
}
});
const queryString = searchParams.toString();
return queryString ? `${resolvedUrl}?${queryString}` : resolvedUrl;
}
const urlObj = new URL(resolvedUrl);
Object.entries(params).forEach(([key, value]) => {
if (value !== void 0 && value !== null) {
urlObj.searchParams.set(key, String(value));
}
});
return urlObj.toString();
}
/**
* Resolves a URL with the base URL if it's relative and base URL is configured
* @param url - The URL to resolve
* @returns The resolved URL
* @private
*/
resolveUrl(url) {
if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("//")) {
return url;
}
if (!this.baseUrl) {
return url;
}
try {
const baseUrl = new URL(this.baseUrl);
const resolvedUrl = new URL(url, baseUrl);
return resolvedUrl.toString();
} catch {
throw new Error(
`Invalid URL: Unable to resolve "${url}" with baseUrl "${this.baseUrl}"`
);
}
}
// 🎯 PIT OF SUCCESS: Convenience methods with smart defaults
/**
* HEAD request with query parameter support.
*
* HEAD requests are used to retrieve metadata about a resource without downloading
* the response body. Useful for checking if a resource exists, getting content length,
* last modified date, etc.
*
* @template T - Expected response data type (will be null for HEAD requests)
* @param url - Request URL
* @param params - Query parameters to append to URL
* @returns Promise resolving to typed response (data will always be null)
*
* @example Check if resource exists:
* ```typescript
* const headResponse = await client.head('/api/large-file.zip');
* if (headResponse.ok) {
* const contentLength = headResponse.headers.get('content-length');
* const lastModified = headResponse.headers.get('last-modified');
* console.log(`File size: ${contentLength} bytes`);
* }
* ```
*
* @example Check with query parameters:
* ```typescript
* const exists = await client.head('/api/users', { id: 123 });
* if (exists.status === 404) {
* console.log('User not found');
* }
* ```
*/
head(url, params) {
const finalUrl = this.buildUrlWithParams(url, params);
return this.request(finalUrl, { method: "HEAD" });
}
/**
* HEAD request that returns useful metadata about a resource.
*
* This is a convenience method that extracts common metadata from HEAD responses
* for easier consumption.
*
* @param url - Request URL
* @param params - Query parameters to append to URL
* @returns Promise resolving to response with extracted metadata
*
* @example Get resource metadata:
* ```typescript
* const metadata = await client.headMetadata('/api/large-file.zip');
* if (metadata.ok) {
* console.log('File exists:', metadata.exists);
* console.log('Content type:', metadata.contentType);
* console.log('Size:', metadata.contentLength, 'bytes');
* console.log('Last modified:', metadata.lastModified);
* }
* ```
*/
async headMetadata(url, params) {
const response = await this.head(url, params);
const contentLengthHeader = response.headers.get("content-length");
const lastModifiedHeader = response.headers.get("last-modified");
return {
...response,
exists: response.ok,
contentType: response.headers.get("content-type") || void 0,
contentLength: contentLengthHeader ? parseInt(contentLengthHeader, 10) : void 0,
lastModified: lastModifiedHeader ? new Date(lastModifiedHeader) : void 0,
etag: response.headers.get("etag") || void 0,
cacheControl: response.headers.get("cache-control") || void 0
};
}
/**
* GET request with query parameter support.
*
* @template T - Expected response data type
* @param url - Request URL
* @param params - Query parameters to append to URL
* @returns Promise resolving to typed response
*
* @example
* ```typescript
* const users = await client.get<User[]>('/api/users');
* const filteredUsers = await client.get<User[]>('/api/users', { status: 'active', limit: 10 });
* if (users.ok) console.log(users.data);
* ```
*/
get(url, params) {
const finalUrl = this.buildUrlWithParams(url, params);
return this.request(finalUrl, { method: "GET" });
}
/**
* POST request with automatic JSON serialization.
*
* @template T - Expected response data type
* @param url - Request URL
* @param body - Request body (auto-serialized to JSON)
* @param headers - Additional headers (Content-Type: application/json is default)
* @returns Promise resolving to typed response
*
* @example
* ```typescript
* const result = await client.post<User>('/api/users', { name: 'John' });
* ```
*/
post(url, body, headers) {
const requestHeaders = {
"Content-Type": "application/json",
...headers ?? {}
};
return this.request(url, {
method: "POST",
headers: requestHeaders,
...body !== void 0 ? { body: JSON.stringify(body) } : {}
});
}
/**
* PUT request with automatic JSON serialization.
*
* @template T - Expected response data type
* @param url - Request URL
* @param body - Request body (auto-serialized to JSON)
* @param headers - Additional headers (Content-Type: application/json is default)
* @returns Promise resolving to typed response
*/
put(url, body, headers) {
const requestHeaders = {
"Content-Type": "application/json",
...headers ?? {}
};
return this.request(url, {
method: "PUT",
headers: requestHeaders,
...body !== void 0 ? { body: JSON.stringify(body) } : {}
});
}
/**
* PATCH request with automatic JSON serialization.
*
* @template T - Expected response data type
* @param url - Request URL
* @param body - Request body (auto-serialized to JSON)
* @param headers - Additional headers (Content-Type: application/json is default)
* @returns Promise resolving to typed response
*/
patch(url, body, headers) {
const requestHeaders = {
"Content-Type": "application/json",
...headers ?? {}
};
return this.request(url, {
method: "PATCH",
headers: requestHeaders,
...body !== void 0 ? { body: JSON.stringify(body) } : {}
});
}
/**
* DELETE request with query parameter support.
*
* @template T - Expected response data type
* @param url - Request URL
* @param params - Query parameters to append to URL
* @returns Promise resolving to typed response
*
* @example
* ```typescript
* const result = await client.del('/api/users/123');
* const bulkResult = await client.del('/api/users', { status: 'inactive', force: true });
* if (result.ok) console.log('Deleted successfully');
* ```
*/
del(url, params) {
const finalUrl = this.buildUrlWithParams(url, params);
return this.request(finalUrl, { method: "DELETE" });
}
};
// src/middleware/authentication/authentication.ts
function shouldSkipAuth(url, skipPatterns = []) {
return skipPatterns.some((pattern) => {
if (typeof pattern === "string") {
return url.includes(pattern);
}
return pattern.test(url);
});
}
function shouldIncludeAuth(url, includePatterns) {
if (!includePatterns || includePatterns.length === 0) {
return true;
}
return includePatterns.some((pattern) => {
if (typeof pattern === "string") {
return url.includes(pattern);
}
return pattern.test(url);
});
}
function createAuthenticationMiddleware(options) {
const {
tokenProvider,
headerName = "Authorization",
tokenType = "Bearer",
skipPatterns = [],
includePatterns
} = options;
return async (request, next) => {
const url = request.url || "";
const parsedUrl = new URL(url);
const pathname = parsedUrl.pathname;
if (shouldSkipAuth(pathname, skipPatterns) || !shouldIncludeAuth(pathname, includePatterns)) {
return next(request);
}
try {
const token = await tokenProvider();
if (!token) {
return next(request);
}
const headers = new Headers(request.headers);
headers.set(headerName, `${tokenType} ${token}`);
const modifiedRequest = {
...request,
headers
};
return next(modifiedRequest);
} catch {
return next(request);
}
};
}
// src/middleware/authentication/index.ts
function useAuthentication(client, options) {
return client.use(createAuthenticationMiddleware(options));
}
// src/middleware/authorization/authorization.ts
function createRedirectHandler(config = {}) {
const {
redirectPath = "/login",
returnUrlParam = "return_url",
includeReturnUrl = true
} = config;
return () => {
let redirectUrl = redirectPath;
if (includeReturnUrl && typeof window !== "undefined") {
const currentUrl = encodeURIComponent(window.location.href);
const separator = redirectPath.includes("?") ? "&" : "?";
redirectUrl = `${redirectPath}${separator}${returnUrlParam}=${currentUrl}`;
}
if (typeof window !== "undefined") {
window.location.href = redirectUrl;
}
};
}
function selectUnauthorizedHandler(providedHandler, redirectConfig) {
if (providedHandler) {
return providedHandler;
}
return createRedirectHandler(redirectConfig);
}
function shouldSkipAuth2(url, skipPatterns = []) {
let pathname;
try {
pathname = new URL(url).pathname;
} catch {
pathname = url;
}
return skipPatterns.some((pattern) => {
if (typeof pattern === "string") {
return pathname.includes(pattern);
}
return pattern.test(pathname);
});
}
function createAuthorizationMiddleware(options = {}) {
const {
onUnauthorized: providedOnUnauthorized,
redirectConfig,
onForbidden,
skipPatterns = [],
statusCodes = [401]
} = options;
const onUnauthorized = selectUnauthorizedHandler(
providedOnUnauthorized,
redirectConfig
);
return async (request, next) => {
const url = request.url || "";
if (shouldSkipAuth2(url, skipPatterns)) {
return next(request);
}
const response = await next(request);
if (statusCodes.includes(response.status)) {
try {
if (response.status === 401 && onUnauthorized) {
await onUnauthorized(response, request);
} else if (response.status === 403 && onForbidden) {
await onForbidden(response, request);
} else if (onUnauthorized) {
await onUnauthorized(response, request);
}
} catch (error) {
console.warn("Authorization handler failed:", error);
}
}
return response;
};
}
// src/middleware/authorization/index.ts
function useAuthorization(client, options = {}) {
return client.use(createAuthorizationMiddleware(options));
}
// src/middleware/cache/cache.ts
var MemoryStorage = class {
constructor() {
this.cache = /* @__PURE__ */ new Map();
}
async get(key) {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry;
}
async getWithExpiry(key) {
const entry = this.cache.get(key);
if (!entry) {
return { entry: null, isExpired: false };
}
const isExpired = Date.now() > entry.expiresAt;
return { entry, isExpired };
}
async set(key, entry) {
this.cache.set(key, entry);
}
async delete(key) {
this.cache.delete(key);
}
async clear() {
this.cache.clear();
}
};
var defaultKeyGenerator = (request) => {
const url = request.url || "";
const method = request.method || "GET";
const headers = request.headers ? JSON.stringify(request.headers) : "";
return `${method}:${url}:${headers}`;
};
function shouldSkipCache(url, skipPatterns = []) {
return skipPatterns.some((pattern) => {
if (typeof pattern === "string") {
return url.includes(pattern);
}
return pattern.test(url);
});
}
function createCacheMiddleware(options = {}) {
const {
ttl = 5 * 60 * 1e3,
// 5 minutes
methods = ["GET"],
storage = new MemoryStorage(),
keyGenerator = defaultKeyGenerator,
skipPatterns = [],
staleWhileRevalidate = false
} = options;
return async (request, next) => {
const method = (request.method || "GET").toUpperCase();
const url = request.url || "";
if (!methods.includes(method) || shouldSkipCache(url, skipPatterns)) {
return next(request);
}
const cacheKey = keyGenerator(request);
try {
const { entry: cached, isExpired } = storage.getWithExpiry ? await storage.getWithExpiry(cacheKey) : await (async () => {
const entry = await storage.get(cacheKey);
return { entry, isExpired: false };
})();
if (cached && !isExpired) {
return {
...cached.response,
headers: new Headers(cached.response.headers),
data: cached.response.data
};
}
if (cached && staleWhileRevalidate) {
const cachedResponse = {
...cached.response,
headers: new Headers(cached.response.headers),
data: cached.response.data
};
if (isExpired) {
next(request).then(async (freshResponse) => {
const headersObj = {};
freshResponse.headers.forEach((value, key) => {
headersObj[key] = value;
});
const cacheEntry = {
response: {
status: freshResponse.status,
statusText: freshResponse.statusText,
headers: headersObj,
data: freshResponse.data
},
timestamp: Date.now(),
expiresAt: Date.now() + ttl
};
await storage.set(cacheKey, cacheEntry);
}).catch(() => {
});
}
return cachedResponse;
}
const response = await next(request);
if (response.ok) {
try {
const headersObj = {};
response.headers.forEach((value, key) => {
headersObj[key] = value;
});
const cacheEntry = {
response: {
status: response.status,
statusText: response.statusText,
headers: headersObj,
data: response.data
},
timestamp: Date.now(),
expiresAt: Date.now() + ttl
};
await storage.set(cacheKey, cacheEntry);
} catch {
}
}
return response;
} catch (error) {
if (error && typeof error === "object" && "message" in error) {
const errorMessage = error.message;
if (errorMessage.includes("Network") || errorMessage.includes("fetch")) {
throw error;
}
}
return next(request);
}
};
}
// src/middleware/cache/index.ts
function useCache(client, options = {}) {
return client.use(createCacheMiddleware(options));
}
// src/middleware/csrf/csrf.ts
function getTokenFromCookie(cookieName = "XSRF-TOKEN") {
if (typeof document === "undefined") {
return "";
}
const name = `${cookieName}=`;
const decodedCookie = decodeURIComponent(document.cookie);
const cookies = decodedCookie.split(";");
for (const cookie of cookies) {
const c = cookie.trim();
if (c.indexOf(name) === 0) {
return c.substring(name.length);
}
}
return "";
}
function shouldSkipCSRF(url, skipPatterns = []) {
return skipPatterns.some((pattern) => {
if (typeof pattern === "string") {
return url.includes(pattern);
}
return pattern.test(url);
});
}
function createCSRFMiddleware(options = {}) {
const {
headerName = "X-XSRF-TOKEN",
cookieName = "XSRF-TOKEN",
protectedMethods = ["POST", "PUT", "PATCH", "DELETE"],
skipPatterns = [],
tokenProvider = () => getTokenFromCookie(cookieName)
} = options;
return async (request, next) => {
const method = (request.method || "GET").toUpperCase();
const url = request.url || "";
if (!protectedMethods.includes(method) || shouldSkipCSRF(url, skipPatterns)) {
return next(request);
}
const token = tokenProvider();
if (!token) {
return next(request);
}
const headers = new Headers(request.headers);
headers.set(headerName, token);
const modifiedRequest = {
...request,
headers
};
return next(modifiedRequest);
};
}
// src/middleware/csrf/index.ts
function useCSRF(client, options = {}) {
return client.use(createCSRFMiddleware(options));
}
// src/middleware/logging/logging.ts
var defaultLogger = {
// eslint-disable-next-line no-console -- allow console.debug in logger implementation
debug: (message, data) => console.debug(message, data),
// eslint-disable-next-line no-console -- allow console.info in logger implementation
info: (message, data) => console.info(message, data),
// eslint-disable-next-line no-console -- allow console.warn in logger implementation
warn: (message, data) => console.warn(message, data),
// eslint-disable-next-line no-console -- allow console.error in logger implementation
error: (message, data) => console.error(message, data)
};
var LOG_LEVELS = {
debug: 0,
info: 1,
warn: 2,
error: 3
};
var defaultFormatter = (entry) => {
const { method, url, status, duration } = entry;
let message = `${method} ${url}`;
if (status) {
message += ` \u2192 ${status}`;
}
if (duration) {
message += ` (${duration}ms)`;
}
return message;
};
function shouldSkipLogging(url, skipPatterns = []) {
return skipPatterns.some((pattern) => {
if (typeof pattern === "string") {
return url.includes(pattern);
}
return pattern.test(url);
});
}
function createLoggingMiddleware(options = {}) {
const {
level = "info",
logger = defaultLogger,
includeRequestHeaders = false,
includeResponseHeaders = false,
includeRequestBody = false,
includeResponseBody = false,
skipPatterns = [],
formatter = defaultFormatter
} = options;
const minLevel = LOG_LEVELS[level];
return async (request, next) => {
const url = request.url || "";
const method = (request.method || "GET").toUpperCase();
if (shouldSkipLogging(url, skipPatterns)) {
return next(request);
}
const startTime = Date.now();
if (LOG_LEVELS.debug >= minLevel) {
const requestHeaders = includeRequestHeaders ? getHeadersObject(
request.headers
) : void 0;
const requestBody = includeRequestBody ? request.body : void 0;
const requestEntry = {
level: "debug",
timestamp: startTime,
method,
url,
...requestHeaders && { requestHeaders },
...requestBody && { requestBody }
};
logger.debug(`\u2192 ${formatter(requestEntry)}`, requestEntry);
}
try {
const response = await next(request);
const duration = Date.now() - startTime;
const logLevel = response.status >= 400 ? "error" : "info";
if (LOG_LEVELS[logLevel] >= minLevel) {
const responseHeaders = includeResponseHeaders ? getHeadersObject(response.headers) : void 0;
const responseBody = includeResponseBody ? response.data : void 0;
const responseEntry = {
level: logLevel,
timestamp: Date.now(),
method,
url,
status: response.status,
duration,
...responseHeaders ? { responseHeaders } : {},
...responseBody !== void 0 ? { responseBody } : {}
};
const logMessage = `\u2190 ${formatter(responseEntry)}`;
if (logLevel === "error") {
logger.error(logMessage, responseEntry);
} else {
logger.info(logMessage, responseEntry);
}
}
return response;
} catch (error) {
const duration = Date.now() - startTime;
if (LOG_LEVELS.error >= minLevel) {
const errorEntry = {
level: "error",
timestamp: Date.now(),
method,
url,
duration,
error: error instanceof Error ? error : new Error(String(error))
};
logger.error(`\u2717 ${formatter(errorEntry)}`, errorEntry);
}
throw error;
}
};
}
function getHeadersObject(headers) {
if (!headers) {
return void 0;
}
const obj = {};
if (headers instanceof Headers) {
headers.forEach((value, key) => {
obj[key] = value;
});
return obj;
} else {
return headers;
}
}
// src/middleware/logging/index.ts
function useLogging(client, options = {}) {
return client.use(createLoggingMiddleware(options));
}
// src/middleware/rate-limit/rate-limit.ts
var TokenBucket = class {
constructor(maxTokens, refillRate, timeProvider = () => Date.now()) {
this.maxTokens = maxTokens;
this.refillRate = refillRate;
this.timeProvider = timeProvider;
this.tokens = maxTokens;
this.lastRefill = this.timeProvider();
}
tryConsume() {
this.refill();
if (this.tokens >= 1) {
this.tokens--;
return { allowed: true };
}
const retryAfter = (1 - this.tokens) / this.refillRate;
return { allowed: false, retryAfter: Math.ceil(retryAfter) };
}
refill() {
const now = this.timeProvider();
const timePassed = now - this.lastRefill;
const tokensToAdd = timePassed * this.refillRate;
this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd);
this.lastRefill = now;
}
};
function createRateLimitMiddleware(options = {}) {
const {
maxRequests = 60,
windowMs = 6e4,
keyGenerator = () => "default",
skipPatterns = [],
onRateLimitExceeded
} = options;
const buckets = /* @__PURE__ */ new Map();
const refillRate = maxRequests / windowMs;
return async (request, next) => {
const url = request.url || "";
if (skipPatterns.some(
(pattern) => typeof pattern === "string" ? url.includes(pattern) : pattern.test(url)
)) {
return next(request);
}
const key = keyGenerator(request);
if (!buckets.has(key)) {
buckets.set(key, new TokenBucket(maxRequests, refillRate));
}
const bucket = buckets.get(key);
const result = bucket.tryConsume();
if (!result.allowed) {
if (onRateLimitExceeded) {
const customResponse = await onRateLimitExceeded(
result.retryAfter || 0,
request
);
if (customResponse) {
return customResponse;
}
}
return {
data: null,
status: 429,
statusText: "Too Many Requests",
headers: new Headers({
"Retry-After": Math.ceil((result.retryAfter || 0) / 1e3).toString()
}),
url: request.url || "",
ok: false,
error: {
message: `Rate limit exceeded. Retry after ${result.retryAfter}ms`,
body: { retryAfter: result.retryAfter }
}
};
}
return next(request);
};
}
// src/middleware/rate-limit/index.ts
function useRateLimit(client, options = {}) {
return client.use(createRateLimitMiddleware(options));
}
// src/middleware/retry/retry.ts
var defaultShouldRetry = (response) => {
return response.status === 0 || response.status >= 500 && response.status < 600;
};
var calculateDelay = (attempt, baseDelay, backoff, maxDelay) => {
let delay;
switch (backoff) {
case "exponential":
delay = baseDelay * Math.pow(2, attempt - 1);
break;
case "linear":
delay = baseDelay * attempt;
break;
case "fixed":
default:
delay = baseDelay;
break;
}
return Math.min(delay, maxDelay);
};
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function createRetryMiddleware(options = {}) {
const {
maxRetries = 3,
delay = 1e3,
backoff = "exponential",
maxDelay = 3e4,
shouldRetry = defaultShouldRetry,
onRetry
} = options;
return async (request, next) => {
let lastResponse;
let attempt = 0;
while (attempt <= maxRetries) {
try {
const response = await next(request);
if (response.ok) {
return response;
}
if (!shouldRetry(
{ status: response.status, ok: response.ok },
attempt + 1
)) {
return response;
}
if (attempt >= maxRetries) {
return response;
}
lastResponse = response;
attempt++;
const retryDelay = calculateDelay(attempt, delay, backoff, maxDelay);
if (onRetry) {
onRetry(attempt, retryDelay, {
status: response.status,
statusText: response.statusText
});
}
await sleep(retryDelay);
} catch (error) {
const errorResponse = {
data: null,
status: 0,
statusText: "Network Error",
headers: new Headers(),
url: request.url || "",
ok: false,
error: {
message: error instanceof Error ? error.message : "Unknown error",
body: error
}
};
if (!shouldRetry(errorResponse, attempt + 1)) {
return errorResponse;
}
if (attempt >= maxRetries) {
return errorResponse;
}
lastResponse = errorResponse;
attempt++;
const retryDelay = calculateDelay(attempt, delay, backoff, maxDelay);
if (onRetry) {
onRetry(attempt, retryDelay, {
status: errorResponse.status,
statusText: errorResponse.statusText
});
}
await sleep(retryDelay);
}
}
return lastResponse;
};
}
// src/middleware/retry/index.ts
function useRetry(client, options = {}) {
return client.use(createRetryMiddleware(options));
}
// src/middleware/index.ts
function useProductionStack(client, config = {}) {
let enhanced = client;
if (config.auth) {
enhanced = useAuthentication(enhanced, config.auth);
}
if (config.cache !== void 0) {
enhanced = useCache(enhanced, config.cache);
}
if (config.retry !== void 0) {
enhanced = useRetry(enhanced, config.retry);
}
if (config.rateLimit !== void 0) {
enhanced = useRateLimit(enhanced, config.rateLimit);
}
if (config.logging !== void 0) {
enhanced = useLogging(enhanced, config.logging);
}
return enhanced;
}
function useDevelopmentStack(client, config = {}) {
let enhanced = client;
if (config.auth) {
enhanced = useAuthentication(enhanced, config.auth);
}
enhanced = useRetry(enhanced, {
maxRetries: 1,
delay: 100
});
enhanced = useLogging(enhanced, {
level: "debug",
includeRequestHeaders: true,
includeResponseHeaders: true,
includeRequestBody: true,
includeResponseBody: true
});
return enhanced;
}
function useBasicStack(client, config) {
return useRetry(useAuthentication(client, config.auth), { maxRetries: 2 });
}
// src/errors/index.ts
var FetchError = class extends Error {
/**
* Creates a new FetchError.
* @param message - Error message
* @param cause - Optional underlying cause
*/
constructor(message, cause) {
super(message);
this.name = "FetchError";
if (cause !== void 0) {
this.cause = cause;
}
}
};
var HttpError = class extends FetchError {
/**
* Creates a new HttpError.
* @param status - HTTP status code
* @param statusText - HTTP status text
* @param body - Response body
* @param url - The request URL
*/
constructor(status, statusText, body, url) {
super(`HTTP ${status} ${statusText} at ${url}`);
this.name = "HttpError";
this.status = status;
this.statusText = statusText;
this.body = body;
}
};
var NetworkError = class extends FetchError {
/**
* Creates a new NetworkError.
* @param message - Error message
* @param url - The request URL
* @param cause - The underlying network error
*/
constructor(message, url, cause) {
super(`Network error for ${url}: ${message}`, cause);
this.name = "NetworkError";
}
};
// src/client/query.ts
function buildQueryParams(query) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== void 0) {
if (Array.isArray(value)) {
value.forEach((item) => {
if (item !== void 0) {
params.append(key, String(item));
}
});
} else {
params.set(key, String(value));
}
}
}
return params.toString();
}
function appendQueryParams(baseUrl, query) {
const queryString = buildQueryParams(query);
if (!queryString) {
return baseUrl;
}
const fragmentIndex = baseUrl.indexOf("#");
if (fragmentIndex !== -1) {
const urlPart = baseUrl.substring(0, fragmentIndex);
const fragmentPart = baseUrl.substring(fragmentIndex);
const separator2 = urlPart.includes("?") ? "&" : "?";
return `${urlPart}${separator2}${queryString}${fragmentPart}`;
}
const separator = baseUrl.includes("?") ? "&" : "?";
return `${baseUrl}${separator}${queryString}`;
}
// src/index.ts
var api = useProductionStack(
new FetchClient({
// Smart default: include cookies for session-based auth
// Can be overridden by creating a custom FetchClient
credentials: "same-origin"
}),
{
// Smart defaults - users can override as needed
retry: {
maxRetries: 2,
delay: 1e3
},
cache: {
ttl: 5 * 60 * 1e3,
// 5 minutes
methods: ["GET"]
},
logging: {
level: "info"
},
rateLimit: {
maxRequests: 100,
windowMs: 60 * 1e3
// 100 requests per minute
}
}
);
var index_default = api;
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
FetchClient,
FetchError,
HttpError,
NetworkError,
appendQueryParams,
buildQueryParams,
createAuthenticationMiddleware,
createAuthorizationMiddleware,
createCacheMiddleware,
createLoggingMiddleware,
createRateLimitMiddleware,
createRetryMiddleware,
useAuthentication,
useAuthorization,
useBasicStack,
useCSRF,
useCache,
useDevelopmentStack,
useLogging,
useProductionStack,
useRateLimit,
useRetry
});
//# sourceMappingURL=index.js.map