Build a Powerful, Zero‑Dependency HTTP Library with Abort, Cache, Retry, and SSE Support
This article walks through creating a lightweight, framework‑agnostic HTTP request library that extends fetch with features such as request cancellation, automatic caching, retry logic, concurrent control, progress tracking, and intelligent Server‑Sent Events parsing, complete with TypeScript interfaces, CLI scaffolding, and comprehensive testing.
Current Situation
Most front‑end projects use Axios, which is built on XHR that is no longer maintained. Compared with fetch, Axios lacks readable streams, request interruption, custom referrer, and other advanced features. fetch is Promise‑based and only reports success or failure, so it cannot directly provide request progress, although progress can be implemented separately.
Missing Features
Cache requests
Retry failed requests
Concurrent request control
SSE stream handling
Some libraries provide these features but are tightly coupled with frameworks like Vue or React, which reduces flexibility.
Implemented Features
Request interruption – cancel ongoing requests at any time.
Request caching – optional automatic caching to improve performance.
Request retry – automatic retries for failed requests.
Concurrent control – manage concurrent requests while preserving order.
Template generation – CLI tool to generate code templates.
SSE stream processing – perfect support for AI‑style streaming data, automatic JSON conversion, and handling of incomplete messages.
Progress tracking – real‑time progress feedback.
Lightweight – zero external dependencies.
Highly configurable – flexible interceptors and options.
Base Request Interface
/** Request base interface */
export interface BaseHttpReq {
get: <T, HttpResponse = Resp<T>>(url: string, config?: BaseReqMethodConfig) => Promise<HttpResponse>;
head: <T, HttpResponse = Resp<T>>(url: string, config?: BaseReqMethodConfig) => Promise<HttpResponse>;
delete: <T, HttpResponse = Resp<T>>(url: string, data?: ReqBody, config?: BaseReqMethodConfig) => Promise<HttpResponse>;
options: <T, HttpResponse = Resp<T>>(url: string, data?: ReqBody, config?: BaseReqMethodConfig) => Promise<HttpResponse>;
post: <T, HttpResponse = Resp<T>>(url: string, data?: ReqBody, config?: BaseReqMethodConfig) => Promise<HttpResponse>;
put: <T, HttpResponse = Resp<T>>(url: string, data?: ReqBody, config?: BaseReqMethodConfig) => Promise<HttpResponse>;
patch: <T, HttpResponse = Resp<T>>(url: string, data?: ReqBody, config?: BaseReqMethodConfig) => Promise<HttpResponse>;
}Cache Abstract Class
/** Cache‑controlled request base class */
export abstract class AbsCacheReq implements BaseHttpReq {
abstract http: BaseHttpReq;
/** Cache expiration time, default 1 s */
protected _cacheTimeout = 1000;
/** Symbol for cache miss */
protected static NO_MATCH_TAG = Symbol('No Match');
/** Symbol for cache timeout */
protected static CACHE_TIMEOUT_TAG = Symbol('Cache Timeout');
protected cacheMap = new Map<string, Cache>();
// ... other methods
}Core Request Implementation
export class BaseReq implements BaseHttpReq {
constructor(private config?: BaseReqConstructorConfig) {}
async request<T, HttpResponse = Resp<T>>(config: BaseReqConfig): Promise<HttpResponse> {
/** core request logic */
}
// ... other HTTP methods delegating to request()
}
export interface BaseReqConstructorConfig {
baseUrl?: string;
headers?: ReqHeaders;
timeout?: number; // default 10 s
retry?: number;
reqInterceptor?: (config: BaseReqMethodConfig) => any;
respInterceptor?: <T = any>(resp: Resp<T>) => any;
respErrInterceptor?: <T = any>(err: T) => any;
}
export interface BaseReqConfig extends Omit<FetchOptions, 'body'> {
respType?: FetchType;
url: string;
baseUrl?: string;
timeout?: number;
abort?: () => boolean;
query?: Record<string, any>;
body?: ReqBody;
retry?: number;
}Retry Task Utility
/**
* Automatically retry an async task after failure.
* @param task The async function returning a Promise.
* @param maxAttempts Maximum attempts (including the first).
*/
export async function retryTask<T>(task: () => Promise<T>, maxAttempts = 3, opts: RetryTaskOpts = {}): Promise<T> {
const { delayMs = 0 } = opts;
let attempts = 0;
let lastError: Error | undefined;
maxAttempts = Math.max(maxAttempts, 1);
while (attempts < maxAttempts) {
attempts++;
try {
const res = await task();
return res;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempts >= maxAttempts) {
throw new RetryError(`Task failed after ${attempts} attempts. Last error: ${lastError.message}`, attempts, lastError);
}
if (delayMs > 0) await wait(delayMs);
console.log(`Attempt ${attempts} failed for task. Retrying...`);
}
}
throw new RetryError(`Task failed unexpectedly after ${attempts} attempts.`, attempts, lastError!);
}Abort Request Example
const controller = new AbortController();
fetch('/test', { signal: controller.signal });
controller.abort();Concurrent Task Execution
export function concurrentTask<T>(tasks: (() => Promise<T>)[], maxConcurrency = 4): Promise<TaskResult<T>[]> {
const numTasks = tasks.length;
if (numTasks === 0) return Promise.resolve([]);
const results: TaskResult<T>[] = new Array(numTasks);
let running = 0;
let completed = 0;
let index = 0;
return new Promise(resolve => {
function runNextTask() {
while (running < maxConcurrency && index < numTasks) {
const taskIndex = index++;
running++;
tasks[taskIndex]()
.then(value => { results[taskIndex] = { status: 'fulfilled', value }; })
.catch(reason => { results[taskIndex] = { status: 'rejected', reason: reason instanceof Error ? reason : new Error(String(reason)) }; })
.finally(() => {
running--;
completed++;
if (completed === numTasks) resolve(results);
else runNextTask();
});
}
}
runNextTask();
});
}
export type TaskResult<T> =
| { status: 'fulfilled'; value: T }
| { status: 'rejected'; reason: Error };SSE Automatic Parsing
The library provides a fetchSSE helper that reads a streaming response, buffers incomplete chunks, parses the SSE protocol, automatically converts JSON payloads, and reports progress.
const { promise, cancel } = await iotHttp.fetchSSE('/ai/chat', {
method: 'POST',
body: { messages: [{ role: 'user', content: '你好' }] },
needParseData: true,
needParseJSON: true,
onMessage: ({ currentContent, allContent, currentJson, allJson }) => {
console.log('Current chunk:', currentContent);
console.log('Accumulated:', allContent);
console.log('Current JSON:', currentJson);
console.log('All JSON:', allJson);
},
onProgress: progress => {
console.log(`Progress: ${(progress * 100).toFixed(0)}%`);
},
onError: error => console.error(error)
});
const data = await promise;
console.log('Final data:', data);The parser uses a buffer to handle fragmented messages, splits on the standard \n\n separator, extracts data: fields, and optionally parses JSON. It also provides error‑tolerant handling for incomplete or malformed chunks.
CLI Scaffold
A minimal CLI written in CJS can generate code from a configuration file. Example package.json entry:
{
"bin": { "jl-http": "./cli/index.cjs" }
}Entry script ( cli/index.cjs) parses input and output paths:
#!/usr/bin/env node
import { resolve } from 'node:path';
function getSrc() {
const [_, __, input, output] = process.argv;
return {
input: resolve(process.cwd(), input || ''),
output: resolve(process.cwd(), output || '')
};
}
console.log(getSrc());ESM to CJS Conversion Helper
export function esmTocjs(path) {
const content = readFileSync(path, 'utf-8');
const reg = /import\s*\{\s*(.*?)\s*\}\s*from\s*['"](.*?)['"]/g;
return content
.replace(reg, (_, fn, p) => `const { ${fn} } = require('${p}')`)
.replace(/export default/g, 'module.exports =');
}
export function writeTempFile(cjsCode, tempPath, tempFile) {
createDir(tempPath);
writeFileSync(resolve(process.cwd(), `${tempPath}/${tempFile}`), cjsCode, 'utf-8');
}
function createDir(dir) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}Type Generation from Config
export const getType = (data: any) => (Object.prototype.toString.call(data) as string).slice(8, -1).toLowerCase();
const typeMap = {
string: 'string',
number: 'number',
boolean: 'boolean',
true: 'true',
false: 'false',
array: 'any[]',
object: 'object',
any: 'any',
null: 'null',
undefined: 'undefined',
function: 'Function',
Function: 'Function',
bigInt: 'bigInt'
};
export function genType(args?: Record<string, any>) {
if (!args) return '';
let ts = '{';
for (const k in args) {
if (!Object.hasOwnProperty.call(args, k)) continue;
const value = args[k];
const type = normalizeType(value);
ts += `
\t${k}: ${type}`;
}
ts += '
}';
return ts;
}
function normalizeType(value: string) {
const type = typeMap[value];
if (type) return type;
if (typeof value === 'string') {
const match = value.match(/.+?\[\]/g);
if (match?.[0]) return match[0];
}
const finaltype = getType(value);
return finaltype === 'array' ? 'any[]' : finaltype;
}Summary of Advantages
Zero‑dependency, small bundle size.
Full feature set: cache, retry, concurrency, SSE, progress, interceptors.
Intelligent SSE parsing handles fragmented data and automatic JSON conversion.
TypeScript‑first design with complete type definitions.
CLI scaffolding for rapid code generation.
The library is published on npm as @jl-org/http and on GitHub at beixiyo/jl-http, with 100% test coverage.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
