ByteNoteByteNote
把上游包源码钉进仓库:vendor 实操清单
字

字节笔记本

2026年10月6日 · 约 8 分钟读完

把上游包源码钉进仓库:vendor 实操清单

API中转
¥120

维护多包 TypeScript 仓库时,总有一些上游依赖不适合走普通的 npm 版本管理:团队希望把某段源码完整锁住,改动可审查,必要时还能在本地打补丁。这类做法一般叫 vendoring,思路是把上游包的源码原样收进仓库的 vendor 目录,作为钉住版本的源码维护,而不是写进根 package.json 的依赖列表。DeepSeek Harness 在引入 Cordis 生态的包(比如 @cordisjs/plugin-http)时采用的就是这种方案。

已经 vendor 过的包怎么更新,是另一条流程;本文整理的是新增一个 vendored 包的逐文件清单,分四步走:拷贝源码、登记根配置、通过 manifest 守卫、验证构建。

vendor 包新增流程:从上游源码到构建验证

一、第一步:把源码拷进来

先在 vendor 下为这个包建一个目录,结构如下:

text
vendor/<dir>/
  package.json      # 来自上游;设 private: true,改写 name,保留 exports 与 type
  tsconfig.json     # 继承 ../../tsconfig.base.json
  src/              # 上游 src/ 原样拷贝
  README.md LICENSE # 上游自带就一并保留

src 目录必须逐字保留上游内容,这是 vendor 的意义所在:之后对照上游提交时才能逐行核对差异。tsconfig.json 则要和其他 vendored 包保持同一套写法:rootDir 指向 src,outDir 指向 lib/types,按需放宽上游代码需要的严格性选项,并为它导入的每个其他 vendored 包补一条 references,例如:

jsonc
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "src", "outDir": "lib/types",
    "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false,
    "noImplicitOverride": false, "noUnusedLocals": false, "noUnusedParameters": false
  },
  "include": ["src"],
  "references": [{ "path": "../cordis" }, { "path": "../cosmokit" }]
}

package.json 有几条不变式要同时满足。第一,"private": true 必须写上,vendored 包永远不会发布到 npm。第二,name 按仓库内部的映射规则改写 scope,但上游的 version、exports、type 原样保留。第三,类型声明相关的元数据指向 lib/types,构建产物里要包含 .d.ts 与 .d.ts.map。第四,它依赖的 Cordis 包列进 peerDependencies,与上游清单保持一致。

有一个容易漏掉的点:传递依赖。vendor 一个包,往往意味着 vendor 它的整条依赖树。比如 @cordisjs/plugin-http 会拉入 @cordisjs/fetch-file,这些传递依赖要么本身也被 vendor 进来,要么已经在仓库里存在,否则构建图接不上。

还有一处仓库本地与上游刻意保持的差异:vendored 的 TypeScript 源码里,本地相对导入和导出要写成显式的 .ts 后缀。这依赖 rewriteRelativeImportExtensions 编译选项:运行时导入会被输出成 .js,声明文件则保留显式 .ts 后缀,NodeNext 与 Node16 模式下的 TypeScript 消费方都能正确解析。

二、第二步:在根配置里登记

源码拷完只是第一步,仓库还不知道这个目录的存在,需要在根配置里登记。要动手的文件有四个:

文件要做的修改
tsconfig.base.json在 paths 里加一条 "": ["./vendor//src"]
tsconfig.host.json在 references 里加 { "path": "./vendor/" },位置放在 packages/* 条目之前,vendored 代码只通过 host 聚合进引用图
vendor/README.md加一行 manifest 表格行:目录名、npm 名、版本、上游仓库、commit SHA,并记录任何本地修改
scripts/publint-all.ts仅当这个包本身要从本仓库发布时才需要改,vendored 依赖通常不发布,跳过即可

另一批文件不用碰,它们靠 glob 自动覆盖:根 package.json 的 workspaces 已经写了 vendor/*,tsdown.config.ts、vitest.config.ts 和 .oxlintrc.json 也都覆盖到了。只有当构建配置与根默认值不同,比如需要双 ESM/CJS 输出或者多个入口时,才需要给这个包单独写一个 tsdown.config.ts,可以参考仓库里的 schemastery 与 logger-console 两个先例;这种独立配置的入口应该读取 lib/types 下输出的 JS。

根配置登记对照:手动编辑与 glob 自动覆盖

三、第三步:留意 manifest 守卫

仓库有一个 pre-commit 钩子脚本 check-vendor-manifest.sh,规则很直接:只要暂存区里出现了 vendor/*/src 下的改动,而 vendor/README.md 没有一起暂存,提交就会失败。这个设计在流程上保证每次源码变更都伴随 manifest 更新,vendor 目录因此始终可追溯。实操上只需要记住一件事:改了 vendored 源码,就把 vendor/README.md 的对应改动放进同一个提交。

四、第四步:验证

四条命令依次跑完,链路基本就通了:

sh
pnpm install        # 把新目录注册进 workspace
pnpm run typecheck
pnpm run build && pnpm run constraints

命令之外,还要按仓库的测试策略跑一轮行为检查,确认这个包在真实调用路径下没有问题。这里有两点值得展开。其一,源码的 paths 映射只在 tsconfig.base.json 里定义一份,所有编译图共享这份映射,不需要在别处重复维护。其二,隔离边界在 project-reference 图上:vendored 源码必须通过它自己的 vendor//tsconfig.json 被引用,不能被拉进某个聚合项目开启严格检查的 TypeScript 编译程序,否则上游代码会在过严的规则下报出一堆与本次改动无关的错误。

写在最后

回顾整个链路:拷贝源码并改好 package.json 与 tsconfig.json,在 tsconfig.base.json、tsconfig.host.json 和 vendor/README.md 三处登记,最后靠 install、typecheck、build、constraints 加行为检查收尾。清单看着琐碎,但每一条都对应明确的工程目的:private 与 rescope 划清发布边界,manifest 守卫保证可追溯,project-reference 隔离让上游代码不受本地严格规则的影响。如果你的仓库也需要钉住某个上游包,照这份清单走一遍即可。

相关文章

分享: