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

@skyroc/service

v1.1.0

Published

Platform-agnostic request & query infrastructure with adapter pattern

Readme

@skyroc/service

平台无关的请求 & 查询基础设施 — 通过 Adapter 模式注入平台差异,使错误处理、token 刷新、QueryClient 等逻辑可跨端复用。

解决什么问题

典型中后台项目的请求层困境:

❌ 错误处理硬编码了 antd message / modal,React Native 无法复用
❌ token 刷新逻辑绑定了 jotai store + localStorage,换个状态库就得重写
❌ 登出跳转依赖 tanstack-router,换框架意味着改所有 catch 分支
❌ 独立测试需要 mock 大量平台 API,测试成本高

@skyroc/service 的答案:一个 Adapter 接口 + 两个工厂函数

✅ 核心逻辑零平台依赖,纯函数可独立测试
✅ 各平台实现一个 Adapter 即可接入
✅ QueryClient 配置统一收口,跨项目一致

核心概念

架构总览

┌─────────────────────────────────────────────────────┐
│                   @skyroc/service                    │
│                                                     │
│  ┌──────────────────┐   ┌────────────────────────┐  │
│  │  createAppRequest │   │  createQueryClient     │  │
│  │                  │   │                        │  │
│  │  error-handler   │   │  默认 query / mutation  │  │
│  │  token refresh   │   │  配置                   │  │
│  │  错误消息去重     │   │  统一错误回调           │  │
│  └───────┬──────────┘   └────────────────────────┘  │
│          │                                          │
│          │  依赖                                     │
│          ▼                                          │
│  ┌──────────────┐                                   │
│  │ RequestAdapter│ ◄── 各平台实现                     │
│  └──────────────┘                                   │
└─────────────────────────────────────────────────────┘
          ▲                          ▲
          │                          │
    ┌─────┴──────┐            ┌──────┴───────┐
    │ antdAdapter │            │  rnAdapter   │
    │ (Web/antd)  │            │ (React Native)│
    └────────────┘            └──────────────┘

RequestAdapter 接口

每个平台需要实现的 5 类能力:

| 能力 | 方法 | 说明 | | ---------- | -------------------------------------------------------- | -------------------------------- | | UI 反馈 | showErrorMessage / showErrorModal | 错误提示的展示方式 | | Auth | getToken / getRefreshToken / setAuth / resetAuth | 认证信息的存取,全部同步 | | Token 刷新 | fetchRefreshToken / refreshTokenUrl | 换取新 token,以及它走的是哪个 url | | 导航 | getCurrentPath / redirectToLogin | 路由跳转 | | i18n | t | 国际化翻译 |

refreshTokenUrl 用来识别「拿到过期码的是续签请求自己」。这种请求绝不能再去续签,否则它会 await 自己那次还没完成的刷新,把自己和所有等着刷新的请求一起永久挂起——不是报错,是转圈不动。 续签走的 url 跟它对不上时(网关重写、换域名),在续签请求上补 isRefreshToken: true

getToken / getRefreshToken 必须是同步的。存储层是异步的平台(RN 的 AsyncStorage)要在 外面先读进内存,adapter 里只做取值。

ServiceCodes 配置

声明后端业务状态码,不同环境可配置不同值:

interface ServiceCodes {
  success: string; // 成功码,如 '0000'
  logout: string[]; // 直接登出码
  modalLogout: string[]; // 弹窗确认后登出码
  expiredToken: string[]; // token 过期码(触发刷新)
}

安装

包已在 monorepo 内,直接引用:

import { createAppRequest } from '@skyroc/service';
import { createQueryClient } from '@skyroc/service/query';

快速上手

1. 实现平台 Adapter

以 antd + tanstack-router 为例:

// apps/admin/src/service/adapter.ts
import type { RequestAdapter } from '@skyroc/service';
import { router } from '@/features/router';
import { localStg } from '@/utils/storage';
import { setAuth } from '@/features/auth/use-auth';
import { $t } from '@/locales';
import { fetchRefreshToken } from './api';
import { AUTH_URLS } from './api/auth/urls';

export const antdAdapter: RequestAdapter = {
  // ---- UI 反馈 ----
  showErrorMessage(msg, onClose) {
    if (onClose) {
      showErrorMessage({ content: msg, onClose });
    } else {
      showErrorMessage(msg);
    }
  },
  showErrorModal(options) {
    showErrorModal({
      content: options.content,
      maskClosable: options.maskClosable ?? false,
      onOk: () => options.onConfirm(),
      onCancel: () => options.onConfirm(),
      title: options.title
    });
  },

  // ---- Auth ----
  getToken: () => localStg.get('token') || null,
  getRefreshToken: () => localStg.get('refreshToken') || null,
  setAuth: tokens => setAuth(tokens),
  resetAuth() {
    localStg.remove('token');
    localStg.remove('refreshToken');
  },
  async fetchRefreshToken(refreshToken) {
    const data = await fetchRefreshToken(refreshToken);
    return { token: data.token, refreshToken: data.refreshToken };
  },
  // 和上面这个函数请求的 url 必须是同一个,否则续签接口自己过期时会死等自己
  refreshTokenUrl: AUTH_URLS.REFRESH_TOKEN,

  // ---- 导航 ----
  getCurrentPath: () => router.state.location.href,
  redirectToLogin: path => router.navigate({ to: '/login-out', search: { redirect: path } }),

  // ---- i18n ----
  t: key => $t(key)
};

2. 创建请求实例

// apps/admin/src/service/request/index.ts
import { createAppRequest } from '@skyroc/service';
import { antdAdapter } from '../adapter';

export const request = createAppRequest({
  adapter: antdAdapter,
  codes: {
    success: '0000',
    logout: ['8888', '8889'],
    modalLogout: ['7777', '7778'],
    expiredToken: ['9999', '9998', '3333']
  },
  axiosConfig: {
    baseURL: '/api',
    headers: { 'X-Custom': 'value' }
  }
});

3. 创建 QueryClient

// apps/admin/src/service/queryClient.ts
import { createQueryClient } from '@skyroc/service/query';

export const queryClient = createQueryClient({
  queryCache: {
    onError: error => {
      if (import.meta.env.DEV) {
        console.error('Query error:', error);
      }
    }
  },
  mutationCache: {
    onError: error => {
      if (import.meta.env.DEV) {
        console.error('Mutation error:', error);
      }
    }
  }
});

4. 像往常一样使用

// 业务代码无需改变
const userInfo = await request<Api.Auth.UserInfo>({ url: '/user/info' });

API

createAppRequest(options)

创建平台无关的请求实例。

interface CreateRequestOptions {
  adapter: RequestAdapter; // 平台适配器
  codes: ServiceCodes; // 后端业务状态码
  axiosConfig?: CreateAxiosDefaults; // axios 基础配置
  crypto?: ApiCryptoOptions; // 接口传输加密
  isBackendSuccess?: (response) => boolean; // 自定义成功判断
  transform?: (response) => any; // 自定义响应转换
}

返回值与 @skyroc/axioscreateRequest 一致,是一个可直接调用的函数:

const request = createAppRequest({ ... });

// 直接调用
const data = await request<UserInfo>({ url: '/user/info' });

// 取消所有请求
request.cancelAllRequest();

// 访问内部状态(调试用)
request.state.errMsgStack;

默认行为

| 行为 | 默认实现 | 可覆盖 | | -------- | ----------------------------------------------- | ------------------ | | 成功判断 | response.data.code === codes.success | isBackendSuccess | | 数据转换 | response.data.data | transform | | 请求拦截 | 自动注入 Bearer {token} | — | | 错误处理 | 根据 codes 分流(登出 / 弹窗 / 刷新 / toast) | — |

错误处理流程

后端返回非成功码
    │
    ├─ logout codes     → showErrorMessage + resetAuth + 跳转登录页
    ├─ modalLogout codes → showErrorModal + 确认后 resetAuth + 登出
    ├─ expiredToken codes → 自动刷新 token + 重试原请求(重试的响应交回调用方)
    └─ 其他              → 抛出 AxiosError(由 onError 兜底展示 toast)

登出和续签失败都由本包调 adapter.resetAuth() 清凭据,redirectToLogin 只负责「跳到哪」—— 平台不必在路由里再清一遍(清了也无妨,resetAuth 是幂等的)。

接口传输加密

给个别接口(登录、改密码、实名信息提交)加一层传输加密,让密码这类内容不以明文出现在 浏览器 devtools、网关访问日志和抓包里。

装配一次:

export const request = createAppRequest({
  adapter,
  codes,
  crypto: {
    header: import.meta.env.VITE_API_CRYPTO_HEADER,
    publicKey: import.meta.env.VITE_API_CRYPTO_PUBLIC_KEY
  }
});

之后每个要加密的接口加一行 encrypt: true,没加的接口不进这段逻辑:

export function fetchLogin(params: Api.Auth.LoginParams) {
  return request<Api.Auth.LoginResponse>({
    data: params,
    encrypt: true,
    method: 'post',
    url: '/auth/login'
  });
}

报文格式

两层信封,RSA 只用来传一次性 AES 密钥:

密钥头  base64( RSA-OAEP-SHA256(服务端公钥, aesKey) )        ← 头的名字由 crypto.header 配
请求体  base64( nonce(12B) ‖ AES-256-GCM(aesKey, 明文 JSON) ‖ tag(16B) )

和后端 app/core/crypto/envelope.py 是同一份契约。请求体会被声明成 text/plain——它现在是 一段 base64,标成 application/json 会让网关和 WAF 按 JSON 去解析它。

公钥从 PEM 载入,换行可以写成字面量 \n(环境变量装不下多行)。

为什么是 node-forge 而不是 WebCrypto

crypto.subtle 只在安全上下文里存在。用 http://192.168.x.x:5173 访问开发服务器时浏览器不提供 它,整条加密链路直接不可用——手机和同事的电脑都进不来。forge 是纯 JS 实现,不挑上下文,代价是 约 90KB gzip 和慢一些的 RSA(一次登录几十毫秒)。

边界

  • 先想清楚它挡的是什么。 公钥在前端 JS 里,AES 密钥也是前端生成的,攻击者照样构造得出 合法密文。它挡的是明文落到日志和抓包里,不是主动攻击,别把它当成认证或授权的替代。
  • 只加密请求,不解密响应。 单纯是还没做:forge 是同步的,接 isBackendSuccess 这种同步钩子 没有障碍,缺的是实现和后端对应的响应信封格式。
  • 没配 publicKey 时不会退化成明文:标了 encrypt: true 的请求直接抛错。没有加密需求的 部署不用配,但配了一半的部署不会安静地把密码明文发出去。
  • FormData / 二进制请求体不支持,上传文件的接口去掉 encrypt: true
  • forge 目前是静态依赖,没开加密的部署也会把它打进包里。

createQueryClient(options?)

创建带统一默认配置的 QueryClient 实例。

interface CreateQueryClientOptions {
  /** 覆盖默认 defaultOptions(会与内置默认值浅合并) */
  defaultOptions?: DefaultOptions;
  /** MutationCache 配置(onError / onSuccess / onSettled / onMutate) */
  mutationCache?: MutationCacheConfig;
  /** QueryCache 配置(onError / onSuccess / onSettled) */
  queryCache?: QueryCacheConfig;
}

可通过 defaultOptions 覆盖任意配置项:

const queryClient = createQueryClient({
  defaultOptions: {
    queries: { staleTime: 60_000, retry: 3 },
    mutations: { retry: 2 },
  },
  queryCache: {
    onError: (error) => console.error('Query error:', error),
  },
});

默认 Query 配置

| 配置项 | 默认值 | 说明 | | ---------------------- | ------ | ---- | | gcTime | 600000(10 分钟) | 垃圾回收时间 | | staleTime | 30000(30 秒) | 数据过期时间 | | retry | 2 | 失败重试次数 | | retryDelay | 指数退避,上限 30 秒 | min(1000 × 2^n, 30000) | | refetchOnMount | true | 组件挂载时重新获取 | | refetchOnReconnect | true | 网络恢复时重新获取 | | refetchOnWindowFocus | false | 窗口聚焦时不重新获取 | | throwOnError | false | 不向上抛出错误 | | networkMode | 'online' | 仅在线时发起请求 |

默认 Mutation 配置

| 配置项 | 默认值 | 说明 | | ------------ | ------ | ---- | | gcTime | 60000(1 分钟) | 垃圾回收时间 | | retry | 1 | 失败重试次数 | | retryDelay | 指数退避,上限 10 秒 | min(1000 × 2^n, 10000) | | throwOnError | false | 不向上抛出错误 | | networkMode | 'online' | 仅在线时发起请求 |

公开导出

import {
  createAppRequest, // 创建请求实例
  createQueryClient, // 创建 QueryClient(也可从 '@skyroc/service/query' 引)
  importPublicKey, // 载入加密用的 RSA 公钥
  refreshToken, // 并发安全的 token 刷新,各传输共用一次
  resetTokenRefresh, // 清掉在途刷新状态,测试用
  seal // 加密一段明文,返回密钥头和 body 两段密文
} from '@skyroc/service';

refreshToken 是给 HTTP 之外的传输用的:WebSocket、SSE 拿到「令牌过期」都该调它,不要自己去调 adapter.fetchRefreshToken——那样各传输之间没有去重,第二个刷新会拿着已经轮换掉的 refresh token 去换,必定失败。

backEndFail / handleError / showErrorMsg / getAuthorization / isRefreshTokenRequest 只从 src/request 内部导出,没有挂到包入口上。它们全部接收 adapter 参数、不依赖任何全局状态,包内 的测试直接引源码路径调用。

这些函数全部接收 adapter 参数,不依赖任何全局状态,可独立调用和测试。

Token 刷新机制

并发的 Token 过期请求只触发一次 fetchRefreshToken,其余请求等待同一个 Promise 的结果后重试:

请求 A ─┐                      ┌─ 带新 Token 重试 A
请求 B ─┤  共享同一个            ├─ 带新 Token 重试 B
请求 C ─┤  refreshTokenPromise  ├─ 带新 Token 重试 C
        └──────────────────────┘
                  │
     1 秒后清除 promise,下次过期重新刷新

刷新成功后 1 秒清除缓存的 Promise,避免长时间持有过期引用。

错误消息去重

showErrorMsg 维护一个消息栈,同一条消息在展示期间不会重复弹出:

showErrorMsg("网络异常")  → 展示 ✅
showErrorMsg("网络异常")  → 跳过(栈中已存在)
       ↓ 用户关闭消息(onClose)
showErrorMsg("网络异常")  → 展示 ✅(已从栈移除)

每条消息只管自己那一个去重位,关掉一条不影响其他还在展示的消息。

onClose 是可选的,平台可以不回调(RN 的 Alert.alert 就没有)。这种情况下 5 秒后自动释放去重位—— 否则那条消息会永远占着栈,此后再也弹不出来。

适用场景

场景一:Web 中后台(antd)

当前项目的主要使用方式。adapter 对接 antd message/modal + jotai + tanstack-router。

场景二:React Native App

// adapters/rn-adapter.ts
import { Alert } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';

export const rnAdapter: RequestAdapter = {
  // 把 onClose 接到按钮上,去重位关掉消息就释放,不用等 5 秒的兜底
  showErrorMessage: (msg, onClose) => Alert.alert('Error', msg, [{ text: 'OK', onPress: onClose }]),
  showErrorModal: opts => Alert.alert(opts.title, opts.content, [{ text: 'OK', onPress: opts.onConfirm }]),
  // AsyncStorage 是异步的,getToken 必须同步返回 —— 启动时读进内存,这里只取值
  getToken: () => tokenCache.token,
  getRefreshToken: () => tokenCache.refreshToken,
  refreshTokenUrl: '/auth/refreshToken',
  redirectToLogin: () => navigation.navigate('Login'),
  t: key => i18n.t(key)
  // ...
};

场景三:Next.js SSR/SSG

// adapters/next-adapter.ts
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';

export const nextAdapter: RequestAdapter = {
  getToken: () => cookies().get('token')?.value ?? null,
  redirectToLogin: path => redirect(`/login?redirect=${path}`),
  showErrorMessage: msg => toast.error(msg) // sonner / react-hot-toast
  // ...
};

场景四:独立测试

核心逻辑不依赖任何平台 API,mock adapter 即可测试所有分支:

const mockAdapter: RequestAdapter = {
  showErrorMessage: vi.fn(),
  showErrorModal: vi.fn(),
  getToken: vi.fn(() => 'test-token'),
  refreshTokenUrl: '/auth/refreshToken',
  redirectToLogin: vi.fn(),
  t: vi.fn(key => key)
  // ...
};

// 直接测试错误处理逻辑
backEndFail(response, instance, request, mockAdapter, codes);
expect(mockAdapter.redirectToLogin).toHaveBeenCalled();

与改造前对比

| 改造前 | 改造后 | | --------------------------------------------------- | ---------------------------------------------------- | | error.ts 硬编码 showErrorMessage(antd 全局方法) | adapter.showErrorMessage — 平台自己决定怎么展示 | | shared.ts 硬编码 localStg.get('token') | adapter.getToken — 存储方式由平台决定 | | shared.ts 硬编码 router.navigate | adapter.redirectToLogin — 路由框架由平台决定 | | error.ts 从 import.meta.env 读取 codes | 显式传入 ServiceCodes — 可来自 env、配置文件或远程 | | queryClient.ts 直接 new QueryClient | createQueryClient 工厂 — 默认配置统一收口 | | 测试需要 mock antd + jotai + router + localStorage | mock 一个 adapter 对象即可 |

设计原则

  • 零平台依赖 — 只依赖 @skyroc/axios@tanstack/react-querynode-forge,不依赖任何 UI 库 / 路由 / 状态管理
  • Adapter 模式 — 平台差异通过接口注入,而非条件分支
  • 纯函数优先 — error-handler、shared 中的每个函数都接收全部依赖作为参数
  • 约定优于配置 — 默认假设 { code, data, msg } 响应格式,可通过 isBackendSuccess / transform 覆盖

测试

# 从 monorepo 根目录
npx vitest run packages/@core/service/__tests__

# 或在包目录内
cd packages/@core/service && pnpm test

# 含覆盖率报告
pnpm test --coverage

81 个测试用例,覆盖率:Statements 98% / Branches 93% / Functions 100% / Lines 98%,覆盖:请求实例创建与默认回调、业务状态码(登出 / 弹窗登出 / Token 过期)处理、续签请求的自我识别、Token 刷新与并发共享、续签后重试结果的回传、错误消息去重、请求体加密、QueryClient 配置合并、指数退避重试延迟。