Skip to main content

双格式 SDK 发布自检

把接口定义转成 SDK,是这件事里容易的那一半。难的一半在发布:用户 npm install 之后,ESM 和 CJS 都能解析、类型都对、浏览器和 Node 都能跑、且没有把内部文件一起打进去。

大部分 SDK 是在这一半出问题的,而且问题只在使用者的解析环境里暴露——你自己测的那台机器上永远是对的。

src/*.ts单一源码tsupesm + cjsindex.mjs + index.d.tsindex.cjs + index.d.ctsexports 条件import / require / types有状态的包要小心两份产物同时被加载 = 模块级单例变成两份(dual package hazard)发布前自检publint 查导出配置 · arethetypeswrong 查各解析模式下的类型浏览器端另出一份 ESM,别把 Node 产物直接给浏览器
图:一份源码出两套产物,由 exports 条件决定消费者拿到哪一套

1. 产物布局:一份源码,两套输出​

不要维护两份源码。用构建工具从同一份 TypeScript 源码出两套产物:

// tsup.config.ts
import { defineConfig } from 'tsup'

export default defineConfig([
{
entry: ['src/index.ts', 'src/skills/index.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
sourcemap: true,
splitting: false,
target: 'node18',
external: ['zod'], // peer 依赖绝不能打进去
outDir: 'dist/node',
},
{
entry: ['src/index.ts'],
format: ['esm'],
platform: 'browser',
dts: true,
minify: true,
target: 'es2020',
outDir: 'dist/browser',
},
])

第二项是给浏览器的,后面会讲为什么不能省。

1.1 exports 是唯一真相​

main / module / types 留着给老解析器做兜底,但真正的路由规则写在 exports 里:

{
"type": "module",
"main": "./dist/node/index.cjs",
"module": "./dist/node/index.js",
"types": "./dist/node/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/node/index.d.ts",
"default": "./dist/node/index.js"
},
"require": {
"types": "./dist/node/index.d.cts",
"default": "./dist/node/index.cjs"
}
},
"./package.json": "./package.json"
}
}

几个要点:

  • types 必须排在每个条件分支的最前面,类型检查器按顺序匹配
  • CJS 分支指向 .d.cts,ESM 分支指向 .d.ts——TypeScript 4.7+ 对两种导入方式解析不同的声明文件,写错一个就会有一半用户拿不到类型
  • "./package.json": "./package.json" 别漏,有些工具(版本检查、import.meta.resolve)会直接读它

1.2 新增子路径要同步改两处​

往 src/ 加一个文件,不等于它对外可用。每一个新的顶层导出或子路径,都要同时加进 exports(如果要兼容老解析器,还要加 typesVersions)。

这类遗漏在单测里发现不了,因为测试通常从源码导入而不是从产物导入。

2. dual package hazard​

当 CJS 和 ESM 两份产物同时被加载,模块级状态就变成了两份:单例变两个、事件总线互不相通、全局配置只在一半生效。

典型触发方式:应用自身是 ESM,但某个依赖是 CJS,那个依赖又 require 了你的包——于是两份都在内存里。

两种应对:

无状态包:照常双格式发布,风险很低。大部分 SDK 属于这类。

有状态的包(插件注册表、全局客户端、连接池):要么把状态挂到 globalThis 上做去重,要么干脆只发 ESM。规范层的建议是——维护状态的库考虑 ESM-only,接受部分 CJS 用户需要用动态 import。

决定之前想清楚:你的用户里有多少还在 CJS。如果他们主要在 Next.js / Vite 里用,ESM-only 的阻力比想象中小。

3. 浏览器和 Node 不是一回事​

这是"双端"里比模块格式更容易出问题的一半。

3.1 别给浏览器喂 Node 产物​

Node 产物里可能有 node:fs、node:stream、process.env 的假设。打包器遇到这些要么报错、要么塞一个巨大的 polyfill。

浏览器单独出一份 ESM,构建时 platform: 'browser',让它按浏览器解析条件取代码:

"exports": {
".": {
"browser": {
"types": "./dist/browser/index.d.ts",
"default": "./dist/browser/index.js"
},
"node": {
"import": { "types": "./dist/node/index.d.ts", "default": "./dist/node/index.js" },
"require": { "types": "./dist/node/index.d.cts", "default": "./dist/node/index.cjs" }
}
}
}

注意 browser 条件要放在 node 前面——打包器按顺序匹配。

3.2 流式响应在两个环境里行为不同​

这一点对大模型 SDK 尤其关键。Node 里从 provider 拿到的是 ReadableStream(Web 标准)或 Node 的 Readable,浏览器里是 ReadableStream。两者的读取 API 不一样,而且浏览器里没有 stream.Readable.from。

如果 SDK 内部有一步"把上游流转成统一接口",要分别实现:

// 浏览器:直接消费 Web ReadableStream
async function* fromWeb(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
yield value
}
} finally {
reader.releaseLock()
}
}

切帧逻辑本身是共享的(见《SSE 流式解析与切帧处理》),但取字节的那一层要分。

3.3 凭证在浏览器里没有藏的地方​

SDK 如果允许在浏览器里直接用 API Key,那这个 key 就在 DevTools 里。正确姿势是浏览器端只跟自己的后端通信,由后端持有凭证(见《密钥不能进浏览器》)。

SDK 可以做的是:检测到运行在浏览器环境且传入了密钥时,明确报错,而不是"能跑就行"。这个报错能挡住相当一部分误用。

4. package.json 里容易被漏的几项​

{
"files": ["dist", "README.md"],
"sideEffects": false,
"engines": { "node": ">=18" },
"publishConfig": { "access": "public", "provenance": true }
}
  • files 是白名单。不写就会把整个仓库发上去——包括 .env、测试 fixture、内部脚本。发过一次就知道有多难撤回(npm 上撤包有时间窗口和限制)。
  • sideEffects: false 让打包器敢做 tree-shaking。不写的话打包器假设每个模块都有副作用,全量包含。
  • engines 给出实际测过的最低版本,别写你没测过的。
  • provenance: true 用 CI 身份给包签名,供应链合规场景下越来越常被要求。前提是发布走 CI 且配置了 OIDC。

5. 发布前自检​

四步,都能自动化,且应该放进 CI 而不是靠人记:

第一,publint。 检查 exports 配置、文件是否真的存在、条件顺序是否可疑。这类问题肉眼看不出来。

第二,arethetypeswrong。 它对打好的 tarball(不是源码)在多种解析模式下检查类型解析:Node10、Node16(分别按 import 和 require)、bundler 模式。双格式的类型解析 bug 出了名地容易静默引入——比如某个子路径忘了写 require 条件。

{
"scripts": {
"check:types": "npm pack --silent --pack-destination .tmp && attw --pack .tmp/*.tgz",
"prepublishOnly": "npm run check:types && publint"
}
}

关键是对 tarball 检查,因为发布出去的是 tarball 不是目录。

第三,看一眼包里有什么。 npm pack --dry-run 会列出文件清单。找有没有 .env、内部文档、超过预期的体积。

第四,两个入口各冒烟一次。 装到本地测试项目里,分别用 import 和 require 跑一次真实调用:

node -e "import('./dist/node/index.js').then(m => console.log('esm ok', typeof m.createClient))"
node -e "const m = require('./dist/node/index.cjs'); console.log('cjs ok', typeof m.createClient)"

只跑 require 是最常见的疏漏——ESM 分支可能根本没被验证过。

6. 什么算破坏性变更​

语义化版本在 SDK 场景里有几条容易判断错:

改动版本说明
给请求类型加必填字段major用户代码编译不过
从响应类型删属性major同上
收窄联合类型major同上
错误类改名、改属性名majorcatch 里的判断会失效
新增可选字段minor
新增方法、新增错误码minor
内部实现重写,公开签名不变patch这是 patch,不要因为是"大改"就升 minor
修正文档patch

最常见的错误是最后两行的反方向——把"改了很多代码但接口没变"当成 minor。接口没变就是 patch。

反过来,把"只是改了点内部实现但公开类型形状其实动了"当成 patch,用户升级后编译失败,这更难被发现。

如果拿不准,用工具对比两次产物导出的类型签名,让它告诉你。

7. README 该写什么​

npm 页面渲染的就是 README,别把它当文档站。五节就够,按这个顺序:

  1. 安装命令(就一行)
  2. 十行以内的 quickstart:鉴权 + 一次真实调用
  3. 鉴权方式: bearer / API key,以及环境变量约定
  4. 错误:抛什么、形状是什么、怎么 catch
  5. 版本策略 + 变更日志链接

完整参数说明放开发者文档站,不要塞进 README。让人在 npm 页面十秒内知道怎么用,就够了。

8. 发布清单​

  • 双格式产物都存在,且各自能加载
  • publint 通过
  • attw 对 tarball 在全部解析模式下通过
  • files 白名单确认,无敏感文件
  • 浏览器产物不含 Node 内建模块
  • peer 依赖未打进产物
  • sideEffects 已声明
  • 语义化版本判断有依据
  • 发布走 CI,provenance 开启
  • 发完立刻在一个干净项目里安装验证

最后一项是唯一无法在发布前完成的验证,所以别跳过它——前面九项全过而线上装不出来的情况,是真实存在的。