npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

fzkit

v0.2.1

Published

Axios HTTP client factory with token injection, refresh, retry and in-flight dedupe

Readme

fzkit / HTTP Client

基于 axios 的 HTTP 客户端工厂。帮你统一处理:

  • access token 注入
  • 401 / 业务失效时的自动刷新
  • 并发刷新合并与冷却
  • 通用重试(网络错误 / 5xx)
  • in-flight GET 请求合并
  • 业务响应与错误钩子

安装

# npm
npm install fzkit axios

# yarn
yarn add fzkit axios

# pnpm
pnpm add fzkit axios

axios 是 peer dependency,需要由业务项目自行安装(^1.7.0)。

快速开始

import axios from 'axios';
import { createHttpClient } from 'fzkit';

const http = createHttpClient({
  axiosConfig: {
    baseURL: '/api',
    timeout: 15_000,
  },
  getAccessToken: () => localStorage.getItem('access_token'),
  refreshAccessToken: async () => {
    const refreshToken = localStorage.getItem('refresh_token');
    if (!refreshToken) {
      throw new Error('refreshToken missing');
    }

    const { data } = await axios.post('/auth/refresh', { refreshToken });
    if (!data?.accessToken) {
      throw new Error('refresh response missing accessToken');
    }

    localStorage.setItem('access_token', data.accessToken);
    if (data.refreshToken) {
      localStorage.setItem('refresh_token', data.refreshToken);
    }

    return data.accessToken;
  },
  onAuthFailure: async () => {
    localStorage.removeItem('access_token');
    localStorage.removeItem('refresh_token');
    // 跳转登录页
  },
});

const profile = await http.get('/account/profile');
console.log(profile.data);

返回值是标准 AxiosInstance,可继续使用 http.get/post/request 等 axios API。

设计原则

  1. token 存储完全交给业务侧
    库不内置 storage,只通过 getAccessToken / refreshAccessToken 读写。
    refreshAccessToken 返回新 token / expiresAt 时,业务必须写回 getAccessToken 使用的数据源。 配置对象里的显式 undefined 字段会被忽略,不会覆盖默认值。

  2. 尽量保留 axios 语义
    成功返回 AxiosResponse;失败默认透传 AxiosError

  3. 鉴权失败与瞬时失败分离
    以下情况会走 onAuthFailure

    • 主会话路径且未启用刷新时,业务请求命中 unauthorizedStatusCode
    • 主会话路径 refresh 鉴权失败(isRefreshFailure
    • refreshAccessToken 返回空 token
    • 冷却期内拿不到可用 access token 以下情况不会onAuthFailure
    • 请求级显式鉴权(Authorization: "" 或非空第三方 token)
    • 命中 skipRefreshUrls
    • 已用最新主会话 token 仍 401
    • 网络错误 / 5xx

Public API

import {
  createHttpClient,
  TokenRefreshManager,
  type HttpClientOptions,
  type AccessTokenResult,
  type AccessTokenDetail,
  type RetryPolicy,
  type DedupePolicy,
  type RequestDedupePolicy,
  type BusinessResponseResult,
  type ErrorContext,
  type ErrorMessages,
} from 'fzkit/http-client';

| 导出 | 说明 | | --------------------------- | ------------------------------------------ | | createHttpClient(options) | 创建带鉴权/刷新/重试/合并能力的 axios 实例 | | TokenRefreshManager | 可选,多客户端共享同一套刷新与冷却状态 | | 相关类型 | 配置与回调类型定义 |

配置项

最小可用

| 字段 | 必填 | 说明 | | -------------------- | ---- | -------------------------------------------------------------- | | axiosConfig | 是 | 透传给 axios.create | | getAccessToken | 是 | 读取当前 access token | | refreshAccessToken | 否 | 自定义刷新逻辑;不传则 401 直接走 onAuthFailure | | onAuthFailure | 否 | 登录失效收尾(清 token、跳登录) | | skipRefreshUrls | 否 | 会话无关路径:不 refresh、不 onAuthFailure(exact / prefix) |

Token 与 Headers

| 字段 | 默认 | 说明 | | ----------------------- | ----------------- | -------------------------------------------------------------- | | accessTokenHeaderName | "Authorization" | token 注入 / 显式鉴权判定使用的 header 名 | | accessTokenPrefix | "Bearer" | token 前缀 | | headersProvider | - | 每次请求动态注入 headers(可 async);不得提供鉴权 header | | refreshBufferMs | 0 | 提前刷新窗口;getAccessToken 返回 AccessTokenDetail 时生效 |

说明:

  • getAccessToken 返回空值 / 空字符串 / { token: null|undefined } 时,不注入鉴权 header
  • getAccessToken 返回非 string 脏类型(number/boolean 等)时,安全归一为空 token,不抛 TypeError
  • expiresAt 仅接受有效 Date / string / finite number;其余脏类型安全归一为 null
  • 调用方显式传入 accessTokenHeaderName 对应 header(默认 Authorization,含空字符串)时,工厂不覆盖
  • 请求级显式鉴权(空串匿名 / 非空第三方 token):
    • 遇 401:不 refresh、不注入 store token、不 onAuthFailure
    • 即使 shouldRefreshByResponse 返回 true:也不 refresh、不注入 store token
  • 未写鉴权 header 的主会话路径:可注入 store token;启用 refresh 时 401 先 refresh,未启用则 onAuthFailure
  • 自定义 accessTokenHeaderName(如 X-Access-Token)后,显式鉴权 / 注入 / provider 忽略都改看该字段(大小写不敏感)
  • headersProvider 不得提供 accessTokenHeaderName(大小写不敏感);若返回同名 header 会被静默忽略
  • 鉴权 header 只来自:请求级 headers、getAccessToken、refresh / store 直重试写回
const http = createHttpClient({
  axiosConfig: { baseURL: '/api' },
  getAccessToken: () => ({
    token: localStorage.getItem('access_token') ?? '',
    expiresAt: localStorage.getItem('expires_at'),
  }),
  refreshBufferMs: 60_000,
  headersProvider: () => ({
    'x-request-id': crypto.randomUUID(),
  }),
});

刷新控制

| 字段 | 默认 | 说明 | | ------------------------- | ------------- | ------------------------------------------------------------------------------------- | | refreshAccessToken | - | 刷新逻辑,返回类型与 getAccessToken 一致 | | shouldRefreshByResponse | () => false | 业务响应触发刷新;仅主会话路径生效,显式鉴权 / skipRefreshUrls 会忽略 | | refreshCooldownMs | 15000 | 刷新成功后的冷却期;与 refreshManager 互斥 | | refreshManager | 内部实例 | 多客户端共享刷新状态;与 refreshCooldownMs 互斥,传入时必须同时传 refreshScopeKey | | refreshScopeKey | - | 外部共享 manager 的鉴权域标识;同一 manager 的所有 client 必须严格相等 | | unauthorizedStatusCode | 401 | 触发刷新流程的 HTTP 状态码 | | refreshFailureCodes | [] | 仅用于 refresh 失败判定的业务 code | | isRefreshFailure | 内置默认 | 判断 refresh 是否已到需要登出的程度 |

默认 isRefreshFailure

  • AxiosError → 不视为鉴权失败
  • response(网络错误)或 status >= 500 → 不视为鉴权失败
  • status === unauthorizedStatusCode 或命中 refreshFailureCodes → 鉴权失败
  • refreshAccessToken 返回空 token → 按鉴权失败处理(不进冷却、不重试原请求)
import { createHttpClient, TokenRefreshManager } from 'fzkit/http-client';

const sharedManager = new TokenRefreshManager(15_000);
const refreshScopeKey = Symbol('primary-auth');

const http1 = createHttpClient({
  axiosConfig: { baseURL: '/api1' },
  getAccessToken: () => tokenStore.accessToken,
  refreshAccessToken: () => tokenStore.refresh(),
  refreshManager: sharedManager,
  refreshScopeKey,
  onAuthFailure: () => tokenStore.logout(),
});

const http2 = createHttpClient({
  axiosConfig: { baseURL: '/api2' },
  getAccessToken: () => tokenStore.accessToken,
  refreshAccessToken: () => tokenStore.refresh(),
  refreshManager: sharedManager,
  refreshScopeKey,
  onAuthFailure: () => tokenStore.logout(),
});

重试与请求合并

const http = createHttpClient({
  axiosConfig: { baseURL: '/api' },
  getAccessToken: async () => '',
  retryPolicy: {
    maxRetries: 2,
    // shouldRetry / retryDelay 可自定义
  },
  dedupePolicy: {
    enabled: true,
    // generateKey 可自定义
  },
});

| 字段 | 默认 | 说明 | | -------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------- | | retryPolicy.maxRetries | 0 | 最大重试次数(不含首次;向下取整;硬上限 10) | | retryPolicy.shouldRetry | 网络错误或 5xx(取消不重试) | 是否重试 | | retryPolicy.retryDelay | 指数退避,上限 30s | 重试延迟;非有限/负数按 0ms;回调抛错停止重试并走 onError | | dedupePolicy.enabled | false | 是否启用 in-flight GET 合并 | | dedupePolicy.generateKey | method:baseURL:url:stableParams:headersProvider:authFingerprint | 合并 key |

补充:

  • 鉴权失败(unauthorizedStatusCode)优先于通用 retryPolicy
  • dedupe 仅对 GET 生效
  • 请求级可用 { dedupePolicy: { enabled: false } } 覆盖客户端级开关
  • 请求级只能覆盖 enabled,不能改 generateKey
  • dedupe 在 adapter 层生效,覆盖 http(url) / http.get / http.request 等入口
  • 内部 refresh / retry 在失败后 pending 已清理,可正常重放,不会自死锁

dedupe 语义边界

  • 合并粒度:仅 in-flight;请求结束后相同 key 会重新发起
  • 默认 keymethod + baseURL + url + stableParams + headersProvider 快照 + 鉴权指纹
    • headersProvider 快照:只纳入 provider 返回值(已剔除鉴权 header),不含调用方 config.headers
    • 鉴权指纹:主会话 bare token / 显式 token / __empty__(显式空)/ __none__(无 Authorization)
    • 未配置 headersProvider 时 provider 段为空
    • 自定义 generateKey 若不隔离身份,不同 token 的同 URL GET 可能被合并
  • 取消
    • 每个消费者的 signal / cancelToken 只取消自己
    • 使用引用计数;全部消费者都离开后,才 abort 底层 shared 请求
    • 已取消的请求不会创建 shared,避免孤儿请求
  • 超时
    • 每个消费者按自己的 timeout 做本地超时
    • shared 物理超时取当前参与者中的 max(timeout)
    • 任一参与者 timeout === 0(显式关闭)时,shared 不强制超时
    • 客户端默认 timeout: 15_000,未单独配置时会按该值参与 max 计算
  • 响应隔离
    • 成功响应深拷贝 data
    • 失败时 error.response.data 也会深拷贝隔离
    • onBusinessResponse 返回完整响应替换时,也会深拷贝 data
    • 共享 refresh 失败时,每个 waiter 拿到独立错误实例
    • headers/status/request 不保证引用共享(axios 管线可能重建);仅保证 data 隔离
  • params 序列化:默认 key 稳定支持 plain object / array / URLSearchParams / Date / Map / Set;循环引用会抛出可控错误
  • 网络侧 config:底层请求以首个创建 shared 的 leader config 为准(除 cancel/timeout 外)

业务钩子

| 字段 | 说明 | | -------------------- | ------------------------------------------------------------------------------ | | onBusinessResponse | 成功响应拦截:void 继续,Error / throw 失败,完整 AxiosResponse 替换响应 | | onError | 请求失败 / 刷新失败钩子,可替换错误;return Errorthrow 都会进入该钩子 | | errorMessages | 覆盖内部英文默认文案(refreshTokenExpired / loginExpired) |

const http = createHttpClient({
  axiosConfig: { baseURL: '/api' },
  getAccessToken: () => localStorage.getItem('access_token'),
  onBusinessResponse: (response) => {
    const data = response.data as { code?: number; message?: string };
    if (data.code !== 0) {
      return new Error(data.message ?? 'business error');
    }
  },
  onError: (error, context) => {
    console.error(`[${context.type}]`, error.message);
  },
  errorMessages: {
    refreshTokenExpired: '登录已过期,请重新登录',
    loginExpired: '登录已失效,请重新登录',
  },
});

默认请求流程

  1. axios.create(axiosConfig)
  2. 请求拦截:注入 token、合并 headersProvider
  3. 成功响应:
    • 主会话路径下 shouldRefreshByResponse 判断是否刷新
    • 显式鉴权 / skipRefreshUrls 即使返回 true 也不 refresh
    • onBusinessResponse 处理业务响应
    • 返回原始 AxiosResponse
  4. 失败响应:
    • 命中 unauthorizedStatusCode
      • 显式鉴权(空 / 非空)或 skipRefreshUrls → 只失败该请求,不 refresh、不 onAuthFailure
      • 主会话 + 启用刷新:
        • store token 已更新且与实际发出 token 不同 → 直接用最新 token 重试一次
        • 否则 refresh 后重放一次
        • 已用最新 token 仍 401 → 只失败该请求,不 onAuthFailure
      • 主会话 + 未启用刷新 → onAuthFailure
    • 其他错误再走 retryPolicy
  5. refresh 失败:
    • isRefreshFailure === trueonAuthFailure
    • 否则透传原始错误,并触发 onError({ type: "refresh" })

FAQ

1. 和直接用 axios 有什么区别?

createHttpClient 仍然返回 axios 实例,只是预装了鉴权、刷新、重试、合并等拦截逻辑。你继续用 get/post/request,不必换请求写法。

2. refresh 返回 400 会自动登出吗?

默认不会。只有这些情况会走 onAuthFailure

  • 主会话路径且未启用刷新(未传 refreshAccessToken)时,业务请求命中 unauthorizedStatusCode
  • 主会话 refresh 返回 unauthorizedStatusCode(默认 401)
  • 或命中 refreshFailureCodes
  • refreshAccessToken 返回空 token
  • 或你自定义的 isRefreshFailure 返回 true
  • 或冷却期内拿不到可用 access token

以下不会登出:

  • 请求级显式 Authorization(空串或非空)
  • 命中 skipRefreshUrls
  • 已启用刷新且业务请求在“最新 token”下仍 401
  • 显式鉴权请求即使 shouldRefreshByResponse 返回 true

如果你们后端用业务 code 表示 refresh token 失效,推荐:

refreshFailureCodes: [1001002],

3. 为什么网络错误 / 5xx 不登出?

因为这通常不代表 refresh token 失效。默认策略是“瞬时失败可重试,鉴权失败才登出”。

4. skipRefreshUrls 怎么匹配?

只做 exact / prefix 路径匹配,不是子串 includes:

  • /auth 匹配 /auth/auth/login
  • 不匹配 /user/auth-history/authorization/api/auth/login

语义:命中后 401 不 refresh、不 onAuthFailure禁止首次主会话 token 注入。 完全无鉴权请求请显式传 Authorization: ""

5. 多个 http 客户端如何共享一次刷新?

创建共享的 TokenRefreshManager,通过 refreshManager 注入给多个 createHttpClient 实例。

共享 manager 表示同一鉴权域,不支持跨 tenant / 跨 token 源共享:

  • 传入外部 refreshManager 时必须同时传 refreshScopeKey
  • 同一 manager 的所有 client 必须使用严格相等的 refreshScopeKey;缺失或不一致会在创建 client 时立即抛错
  • 不同 tenant / token 源必须使用各自的 TokenRefreshManager
  • 一次合并 refresh 事务的鉴权失败只会触发一次 onAuthFailure
  • 使用发起该次 refresh 的 client 回调
  • 所有共享 client 必须使用同一 token 数据源与等价 logout 行为
  • join 他 client 的 in-flight refresh 后,waiter 通过自己的 getAccessToken() 取最新 token(依赖 store 已由 leader 更新)
  • join 后 store token 未变化时直接失败,不会用旧 token 重试或执行另一个 client 的 refresh
  • TokenRefreshManager 构造参数非法(NaN / Infinity / 负数)会归一为默认 15000
  • refreshManagerrefreshCooldownMs 互斥;共享时冷却只能在 new TokenRefreshManager(ms) 配置
  • 某个服务在最新 token 下仍持续 401 时,只会失败该服务请求,不会拖垮其他服务,也不会无限 refresh

refreshAccessToken 回调内若再用 client 发请求:

  • 仅保证同步启动的嵌套请求不会死锁(401 / shouldRefreshByResponse 短路)
  • 回调内先 await 再发请求(同 client 或共享 manager 的另一 client)可能自等待;浏览器无法安全区分「嵌套再入」与「并发 waiter」
  • 外部并发业务请求仍可 join 同一次 in-flight refresh
  • 推荐:refresh 接口走 skipRefreshUrls 或裸 axios,不要在 refresh 回调里用同一套 client 打需鉴权接口

6. 请求级如何临时关闭 dedupe?

await http.get('/profile', {
  dedupePolicy: { enabled: false },
});

7. 不需要自动刷新怎么办?

不传 refreshAccessToken 即可。此时主会话请求遇到 unauthorizedStatusCode 会直接触发 onAuthFailure;显式鉴权 / skipRefreshUrls 仍不会登出。

8. 某些接口完全不想走鉴权/刷新?

可以继续用同一个 createHttpClient

// 完全无凭证
await http.get('/public/config', {
  headers: { Authorization: '' },
});

// 会话无关(可带主会话 token,但 401 不登出)
// 配置 skipRefreshUrls: ["/auth/login"]
await http.post('/auth/login', body);

9. 请求级 dedupePolicy 的类型从哪来?

请求级 dedupePolicy 只存在于 createHttpClient 返回实例的请求配置类型 (FzkitAxiosRequestConfig,从 fzkit/http-client 导出)中:实例方法经类型交叉 提供该字段,不再通过 declare module "axios" 全局扩展,因此普通 axios.create() 实例的请求配置不接受它。该字段只对 createHttpClient 创建的实例有运行时效果。

SSE

createHttpClient() 返回的实例提供 http.sse()。它使用 Fetch 与 ReadableStream,不经过 Axios 的 adapter、interceptor、retry、dedupe 或响应转换链路。

最常用:会话感知的流式请求

const http = createHttpClient({
  axiosConfig: { baseURL: 'https://api.example.com/v1' },
  getAccessToken: () => store.token,
  refreshAccessToken: () => refreshToken(), // 401 自动恢复后用新 token 重连
  headersProvider: () => ({ 'x-device-id': deviceId }),
  onAuthFailure: () => logout(),
  onError: (error, { type }) => console.error(type, error), // 鉴权终态错误走客户端级 onError
});

const sub = http.sse('/chat/stream', {
  method: 'POST',
  json: { model, messages }, // 自动 JSON.stringify 并补 application/json
  params: { conversationId }, // → /chat/stream?conversationId=…(axios 序列化语义)

  lastEventId: savedCursor, // 断点续传:每次(重)连都带 Last-Event-ID
  connectTimeoutMs: 10_000, // 建连超时 → 可恢复重连
  idleTimeoutMs: 60_000, // open 后 60s 无字节 → 判定断线重连
  maxRetries: 10, // 收到消息会归零重试计数

  onOpen: (response) => {
    console.log(response.status, sub.state); // sub.state === 'open'
  },
  onMessage({ event, data }) {
    if (event === 'delta') render(data);
    else if (event === 'done') sub.close();
  },
  onRetry({ attempt, delayMs, reason }) {
    console.log(`${reason},${delayMs}ms 后第 ${attempt} 次重连`);
    if (attempt > 5) sub.close(); // 在 onRetry 内调用 close() 可中止本次重连
  },
  onError(error) {
    console.error(error.message, error.status, error.url); // SseError 带 url/status
  },
  onClose(reason) {
    if (reason === 'error') showToast('流已断开');
  },
});

sub.state; // 'connecting' | 'open' | 'retrying' | 'closed'(连接状态指示)
sub.lastEventId; // 最新确认游标,可持久化
sub.close(); // 手动关闭:onClose 收到 'manual',只触发一次
  • 支持 GETPOSTparams(axios 序列化语义)和受控的 Fetch 字段;现代浏览器可直接使用,Node.js 18+ 内置 fetch 可直接使用,更早版本需注入兼容的 fetch
  • body 走 fetch 原生语义(字符串 / Blob 等,不经过 axios 转换链);对象用 json 快捷选项自动序列化,显式 bodycontent-type 优先。
  • 订阅对象提供 state,可用于连接状态指示;在 onRetry 回调内调用 close() 可中止本次重连。
  • 每次连接都会重新读取主会话 token 与 headersProvider;显式 AuthorizationskipRefreshUrls 与 HTTP 请求一样隔离 refresh / onAuthFailure
  • 默认在网络异常、EOF、429、5xx 后指数退避重连(1s ~ 30s);reconnect: false 关闭传输重连,maxRetries 限制重试次数,retryDelay 可完全定制延迟(抛错视为终态失败)。
  • 服务端 retry: 提示约束 EOF 与空闲超时后的重连时机(缺失时退回指数退避);网络错误类重连不受提示抬升延迟。
  • connectTimeoutMs 限制"发起请求到收到响应头"的时长,idleTimeoutMs 限制 open 后无字节到达的时长;两者超时都按可恢复错误触发重连。
  • POST 自动重连只支持可重放 body;ReadableStream body 必须设置 reconnect: false
  • onMessage 接收所有事件;未声明 event: 时事件名为 message,data 保留原始字符串。默认异步处理器不保证按序完成;sequentialMessages: true 可串行处理并暂停读取形成背压。
  • onOpen / onMessage 处理器出错(同步 throw 或异步 rejection)会通过 onError 上报;onOpen 失败会终态关闭订阅,onMessage 失败不会中断流。onRetry / onError / onClose 为通知型回调,内部异常被忽略。
  • 鉴权终态错误(401 无法恢复)与 HTTP 请求一致走客户端级 onError(带 type 上下文),订阅级 onError 不会重复收到;订阅关闭仍会触发 onClose('error')
  • SseError 携带 status 与最终请求 url,便于多订阅场景排障。

断点续传

const subscription = http.sse('/events', {
  lastEventId: savedCursor, // 从持久化游标恢复
  onMessage(event) {
    if (event.id) saveCursor(event.id); // 或直接读 subscription.lastEventId
    console.log(event.event, event.data);
  },
});

subscription.lastEventId; // 当前已确认的事件 id(服务端 id 或播种值)

每次(重)连都会带上 Last-Event-ID,服务端按游标续传。

AI 对话流(OpenAI 兼容)

http.sse 天然匹配 OpenAI Chat Completions 的流式形态(POST + text/event-stream + 逐条 data: <JSON>):

const openai = createHttpClient({
  axiosConfig: { baseURL: 'https://api.openai.com/v1' },
  getAccessToken: () => apiKey, // 自动注入 Authorization: Bearer <apiKey>
});

function streamChat(messages: Array<{ role: string; content: string }>): Promise<string> {
  return new Promise((resolve, reject) => {
    let full = '';
    const sub = openai.sse('/chat/completions', {
      method: 'POST',
      json: {
        model: 'gpt-4o',
        messages,
        stream: true,
        stream_options: { include_usage: true },
      },
      // LLM 流重放成本高(重复计费 + 重复输出):关闭自动重连,由调用方决定何时重试
      reconnect: false,
      idleTimeoutMs: 60_000,
      onOpen(response) {
        console.log(response.headers.get('x-request-id')); // 排障用
      },
      onMessage({ data }) {
        if (data === '[DONE]') return sub.close(); // 流结束哨兵
        const chunk = JSON.parse(data); // data 是原始 JSON 字符串
        const delta = chunk.choices?.[0]?.delta?.content;
        if (delta) full += delta;
        if (chunk.choices?.[0]?.finish_reason === 'stop') sub.close();
        // include_usage 时结尾还有一条 choices 为空的 usage chunk,可选读 chunk.usage
      },
      onError(error) {
        reject(error);
      },
      onClose(reason) {
        resolve(full); // 可结合 reason 判断流是否完整结束(manual/stop 为正常)
      },
    });
  });
}
  • 静态 key 用 getAccessToken 注入即可,无需手写 header;自建网关的短期 token 可配 refreshAccessToken,401 自动恢复后重连。
  • 同形态的 OpenAI 兼容服务(DeepSeek / Ollama / LM Studio 等)复用同一段代码,换 baseURLmodel 即可。
  • data 保持原始字符串、JSON.parse 由调用方完成:不同服务的 chunk 形状不同,自动解析容易误导;需要时自己包 3 行即可。

独立使用(零 axios 依赖)

不需要 token 注入 / refresh / dedupe 时可直接用 fzkit/sse

import { createSseClient } from 'fzkit/sse';

const sse = createSseClient({ fetch }); // Node.js 需注入 fetch
const subscription = sse('/events', { onMessage(event) {} });

prepareHeaders / createAuthSession 依赖可供自行装配鉴权恢复;resolveUrl 依赖可注入自定义 URL 解析(含 params 处理)。独立路径下 params 不会被自动序列化。

测试

yarn workspace fzkit test

测试分层与覆盖说明见 tests/README.md

License

MIT