@cxyi7/request-manager
v0.7.0
Published
Framework-agnostic HTTP request lifecycle, transport adapters, and WebSocket connections.
Maintainers
Readme
@cxyi7/request-manager
与业务框架无关的 HTTP 请求生命周期、策略管线、Transport Adapter 与独立 WebSocket 连接管理。
特性
- 多服务
RequestClient,实例之间配置和状态隔离。 - 核心入口不依赖具体 HTTP 实现;原生 Fetch 与 Axios Transport 均通过独立入口按需加载。
- 可配置响应协议,不绑定任何后端
code、ok或message结构。 - Token 注入、并发 single-flight 刷新、单次失效通知和最多一次认证重放。
- 标准
AbortSignal、超时、总 Deadline 和请求 Scope。 share请求共享与latest只保留最新请求。- 显式 TTL 内存缓存、LRU 容量限制和 key/tag/用户分区失效。
- 幂等感知的有上限重试、Full Jitter 和
Retry-After。 - 稳定的
RequestError错误模型。 - 只读生命周期与策略观测事件、依赖排序和关键插件失败策略。
- 独立 WebSocket 入口、有限重连、有界消息队列、显式 Codec 和一次性 Ticket 集成。
- Fake Transport、Fake WebSocket、Fetch/Axios Transport 契约和浏览器下载工具。
安装
npm install @cxyi7/request-manager axios只有使用 Axios Transport 时才需要安装 Axios。浏览器和 Node.js 20+ 可以直接使用内置 Fetch Transport:
import { createRequestClient } from '@cxyi7/request-manager';
import { createFetchTransport } from '@cxyi7/request-manager/fetch';
const client = createRequestClient({
name: 'main',
baseURL: 'https://api.example.com/v1/',
transport: createFetchTransport(),
});Client 创建时会克隆、归一化并冻结配置容器;之后修改调用方持有的 Header、重试规则、认证设置以及已注册的协议、插件或 Transport 方法,不会改变已有 Client。单次请求的策略和普通对象/数组参数也会在进入异步生命周期前建立内部快照。AbortSignal、Transport 内部状态、回调函数闭包和请求 body 等运行时协作者仍保留其预期的动态行为,库不会冻结调用方拥有的原对象。
Fetch Transport 支持 JSON、text、blob、arrayBuffer 和 stream 响应,并自动序列化查询参数和普通对象 JSON 请求体。数值查询参数必须是有限值;NaN 和正负 Infinity 会在创建缓存或去重标识前被拒绝。普通对象只能搭配默认 Content-Type、application/json 或 application/*+json;其他编码请先转换成对应的字符串、URLSearchParams 或二进制请求体。FormData、Blob、字符串、URLSearchParams、ArrayBuffer 与 ReadableStream 请求体会原样传递。原生 Fetch 没有标准上传进度 API,因此需要 onUploadProgress 时请使用 Axios Transport。
快速开始
import { createRequestClient, type ResponseProtocol } from '@cxyi7/request-manager';
import { createAxiosTransport } from '@cxyi7/request-manager/axios';
interface ApiEnvelope<T> {
code: number;
message?: string;
data: T;
}
const responseProtocol: ResponseProtocol<ApiEnvelope<unknown>> = {
isSuccess: (payload) => payload.code === 0,
isAuthExpired: (payload) => payload.code === 30007,
getData: (payload) => payload.data,
getCode: (payload) => payload.code,
getMessage: (payload) => payload.message,
};
const client = createRequestClient({
name: 'main',
baseURL: 'https://api.example.com/v1/',
transport: createAxiosTransport(),
responseProtocol,
auth: {
getAccessToken: () => session.getAccessToken(),
refreshAccessToken: () => session.refreshAccessToken(),
onAuthExpired: () => session.notifyExpired(),
},
});
const user = await client.get<{ id: number; name: string }>('/users/1');WebSocket
WebSocket 通过独立入口加载,不会进入 HTTP RequestClient、Transport、缓存、去重或请求重试管线:
import { createWebSocketClient } from '@cxyi7/request-manager/websocket';
interface IncomingMessage {
type: string;
payload?: unknown;
}
interface OutgoingMessage {
type: string;
topic: string;
}
const websocket = createWebSocketClient({
name: 'realtime',
baseURL: 'https://api.example.com',
});
const connection = websocket.connect<IncomingMessage, OutgoingMessage>('/realtime', {
protocols: ['app.v1'],
ticket: async ({ signal }) => {
const result = await client.post<{ ticket: string }>('/websocket/ticket', undefined, { signal });
return { query: { ticket: result.ticket } };
},
encode: JSON.stringify,
decode: (data) => {
if (typeof data !== 'string') throw new TypeError('Expected a text message');
return JSON.parse(data) as IncomingMessage;
},
});
await connection.opened;
await connection.send({ type: 'subscribe', topic: 'orders' });
for await (const message of connection.messages) {
console.log(message);
}浏览器默认使用原生 WebSocket。Node.js 20+ 通过 webSocketFactory 注入所选实现,例如 ws。异常关闭可按有限次数和累计时间重连,但不会自动重放已经发送的业务消息;可靠投递、ack、幂等和重订阅仍由应用协议负责。
messages 是单消费者异步迭代流。库因本地策略、子协议、解码或缓冲区超限主动关闭时,分别使用私有关闭码 4000、4002、4007、4009;这些错误仍通过 WebSocketConnectionError 返回。
浏览器握手不能设置任意 Authorization Header。同源场景优先使用安全 Cookie;其他场景建议先通过 RequestClient 获取短时、单次使用的 ticket。目标地址会在 ticket 获取前通过 allowedOrigins 校验,不应把长期 access token 放进 WebSocket URL。原生 WebSocket API 无法让客户端审核握手重定向,因此携带 ticket 的端点不应重定向到其他 origin,服务端仍必须验证握手 Origin。
完整响应信息
普通的 request、get 和 post 等方法继续只返回协议解析后的业务数据。需要读取分页、限流、缓存协商或链路追踪响应头时,使用 requestDetailed():
const result = await client.requestDetailed<{ id: number }>({
method: 'POST',
url: '/users',
body: { name: 'Ada' },
});
console.log(result.data.id);
console.log(result.status, result.headers.location, result.url);RequestResult<T> 包含协议解析后的 data、最终成功响应的 status、statusText、只读 headers 和可选 url。认证重放或普通重试发生时只返回最后一次成功响应;共享请求的普通调用方与详细调用方复用同一次 Transport 请求。缓存会连同响应信息一起保存,命中时不会重新触发网络生命周期插件。
多服务实例
每个后端服务建立独立 Client。不要通过单请求覆盖 baseURL,也不要让主系统 Token 被发送到第三方服务。
const publicClient = createRequestClient({
name: 'public',
baseURL: 'https://public.example.com',
transport: createAxiosTransport(),
});
const internalClient = createRequestClient({
name: 'internal',
baseURL: 'https://internal.example.com',
transport: createAxiosTransport(),
auth: { getAccessToken: () => session.getAccessToken() },
});allowedOrigins 只允许发送请求,不会自动授权携带 Token。确需在额外 Origin 发送相同认证信息时,必须单独配置 auth.allowedOrigins。配置托管 auth 后,匿名请求和未授权的额外 Origin 会移除对应认证 Header;未配置 auth 时,业务主动提供的静态认证 Header 保持不变。
取消与请求 Scope
const scope = client.createScope();
const profile = client.get('/profile', { signal: scope.signal });
const messages = client.get('/messages', { signal: scope.signal });
scope.abort('page unmounted');client.abortAll() 只取消当前正在进行的请求,不会永久关闭 Client。总 Deadline 包含请求尝试和重试等待;无论在哪个阶段到期,都会返回不可重试的 timeout 错误。
去重模式
// 相同查询共享一个进行中的 Promise。
client.get('/dictionary', {
anonymous: true,
deduplicate: { mode: 'share' },
});
// 新搜索会取消旧搜索。
client.get('/search', {
anonymous: true,
params: { keyword },
deduplicate: { mode: 'latest', key: 'global-search' },
});读取请求的默认 Key 使用 method、URL、稳定序列化后的 params、认证模式、响应类型和最终静态请求头。非匿名请求或显式携带凭证头的请求还必须提供稳定的 deduplicate.partitionKey,防止登录用户或租户切换时共享旧身份的响应。凭证本身不会进入 Key:
client.get('/profile', {
deduplicate: {
mode: 'share',
partitionKey: `user:${userId}`,
},
});share 只共享底层请求,每个调用者仍获得独立 Promise 和取消语义。单个调用者取消不会影响其他调用者;所有调用者均取消后才会取消底层请求。写请求必须提供显式 deduplicate.key;自定义 Key 时,调用方负责保证被合并请求的安全上下文和返回契约一致。上传和下载不自动启用去重。
TTL 内存缓存
缓存默认关闭,只在请求明确配置 cache 时启用。公共字典接口应标记为匿名请求:
const statusDictionary = await client.get('/dictionary/status', {
anonymous: true,
cache: {
ttlMs: 5 * 60_000,
key: 'dictionary:status',
tags: ['dictionary'],
},
deduplicate: { mode: 'share' },
});ttlMs 从成功响应写入缓存时开始计算。缓存命中直接返回协议解析后的最终结果,不访问 Transport,也不触发网络生命周期插件。相同缓存未命中只有在显式配置 share 时才合并;不同请求仍会并发发送。
每个 Client 都有独立的 LRU 内存缓存,默认最多保存 100 条,也可以限制容量:
const client = createRequestClient({
name: 'main',
baseURL: 'https://api.example.com',
transport: createAxiosTransport(),
cache: { maxEntries: 200 },
});非匿名请求或显式携带认证、Cookie、Token、API Key 等凭证头的请求,必须提供稳定的用户分区,且凭证本身不会进入缓存 Key。anonymous: true 只应用于不依赖用户 Cookie 或其他身份信息的公共接口:
await client.get('/profile/preferences', {
cache: {
ttlMs: 60_000,
partitionKey: `user:${userId}`,
tags: ['preferences'],
},
});可以按业务 Key、任一 Tag、用户分区或全量主动失效:
client.invalidateCache({ key: 'dictionary:status' });
client.invalidateCache({ tags: ['dictionary'] });
client.invalidateCache({ partitionKey: `user:${userId}` });
client.clearCache();失效操作会阻止失效前已启动的请求重新写回旧数据。第一版只缓存成功的 GET/HEAD JSON 或文本结果;HTTP、业务和认证失败不会缓存,写请求、Blob、Stream、ArrayBuffer、上传与下载不支持缓存。缓存只存在于内存中,页面刷新或进程退出后自动清空。
重试
GET、HEAD 默认允许重试。POST、PATCH 必须提供幂等键才能重试:
await client.post('/orders', order, {
retry: {
idempotencyKey: crypto.randomUUID(),
maxAttempts: 2,
},
});幂等键只能在单次请求的 retry 中配置,不能作为 Client 默认值复用于多个写请求。客户端幂等键只有在服务端识别并持久化该键时才真正有效。不得仅依靠前端配置保证写操作幂等。
错误处理
import { isRequestError } from '@cxyi7/request-manager';
try {
await client.get('/users/1');
} catch (error) {
if (isRequestError(error)) {
console.log(error.kind, error.status, error.code, error.requestId);
}
}错误种类:network、timeout、http、business、auth、abort、policy、parse。
成功 HTTP 响应中的非法 JSON 会返回不可重试的 parse 错误,不会按网络故障自动重试。
核心包不显示 Toast、不打开 Modal、不操作路由。UI 行为由项目层根据 RequestError 决定。
插件
插件只消费只读生命周期事件,适合日志和可观测性。认证、重试、Origin 等影响安全和正确性的能力属于内置策略,不通过万能 Hook 暴露。
const telemetryPlugin = {
name: 'telemetry',
onRequestEnd(event) {
metrics.observe(event.clientName, event.durationMs, event.status);
},
onRetryScheduled(event) {
metrics.retry(event.clientName, event.delayMs, event.error.kind);
},
onCacheHit(event) {
metrics.cacheHit(event.clientName, event.url);
},
};除请求开始、尝试结束、请求结束和错误生命周期外,插件还可以观测以下内置策略:
| Hook | 事件内容 |
|---|---|
| onCacheHit / onCacheMiss | 显式缓存查询的命中或未命中 |
| onDeduplicate | share 的 leader / subscriber,或 latest 的 start / supersede |
| onRetryScheduled | 当前尝试、重试序号、下一次尝试、等待时间和只读错误 |
| onAuthRefresh | start、join、success、failure 或 unavailable |
策略事件包含调用方自己的 requestId。share 订阅者还会收到 leaderRequestId,用于关联实际执行 Transport 的主请求。事件不会暴露缓存 Key、去重 Key、分区、Token 或认证 Header。
策略观测 Hook 只用于诊断:按照插件顺序同步投递,但不会等待其返回的 Promise;同步异常和异步拒绝始终被隔离,即使插件声明了 critical: true 也不会改变请求结果。普通生命周期 Hook 继续遵循原有关键插件失败语义。缓存命中只触发专用 onCacheHit,不会伪造网络请求生命周期。
- 插件名称必须唯一。
requires中的依赖必须存在且不能成环。- 普通生命周期中的非关键插件异常会被隔离。
- 普通生命周期中的
critical: true插件异常会产生policy错误。 coreRange会在创建 Client 时校验,支持精确版本、带完整版本的^/~、x通配、带完整版本的空格分隔比较器以及||备选范围。
浏览器下载
import { saveDownload } from '@cxyi7/request-manager/browser';
const response = await client.download('/reports/export', { month: '2026-08' });
saveDownload(response, { filename: 'report.xlsx' });工具会解析 Content-Disposition、清理文件名、移除临时 DOM 节点并释放 Object URL。
上传使用标准 FormData,不要手工设置 multipart boundary:
const form = new FormData();
form.append('file', file);
await client.upload('/files', form, {
onUploadProgress: ({ loaded, total, progress }) => {
console.log({ loaded, total, progress });
},
});测试工具
import { createFakeTransport, jsonResponse } from '@cxyi7/request-manager/testing';
const transport = createFakeTransport((request) =>
jsonResponse({ method: request.method, url: request.url }),
);Fake Transport 不访问真实网络,并保留只读请求记录,适合单元测试和契约测试。
WebSocket 测试可使用同一入口的 createFakeWebSocketFactory(),主动控制 open、receive、异常关闭和发送缓冲区,不需要访问真实网络。
公共入口
| 入口 | 内容 |
|---|---|
| @cxyi7/request-manager | Client、错误、类型和重试工具 |
| @cxyi7/request-manager/axios | Axios Transport |
| @cxyi7/request-manager/fetch | 原生 Fetch Transport |
| @cxyi7/request-manager/browser | 浏览器下载工具 |
| @cxyi7/request-manager/websocket | 独立 WebSocket Client、连接、错误和类型 |
| @cxyi7/request-manager/testing | Fake Transport 与 Fake WebSocket |
未在 package.json#exports 声明的内部路径不属于公共 API。
开发与发布验证
npm install
npm run typecheck
npm test
npm run build
npm pack --dry-runnpm run smoke:package 会构建真实 npm tarball,在独立临时项目中验证可选 Axios 依赖、全部公开运行时入口和 TypeScript 类型入口,并在结束后自动清理。npm run check 会依次执行版本一致性检查、Lint、类型检查、测试及这项真实安装验证。
V1 非目标
- SSE、HTTP Streaming、长轮询和 WebTransport。
- 默认缓存、SWR 和离线写队列。
- Service Worker 或全局
fetch猴子补丁。 - Vue、React、Pinia 或 UI 组件。
- 浏览器端服务密钥管理。
版本策略
- Patch:不改变公共行为的内部修复。
- Minor:向后兼容的新配置、事件或公共入口。
- Major:默认重试、错误语义、导出或插件契约的不兼容变更。
