feat(esm/npm): 用 Go 拉依赖树,加库不再需要 node

零依赖的包下个 tarball 就能用,有依赖的得先解析依赖树——读 semver 范围、
查注册表定版本、递归。这个包把那件事用 Go 做了,于是整条链没有 node:

    esm.Install(ctx, "qs", "app/node_modules")
    // qs v6.16.0  打进 50 个文件 -> 73.3 KB(依赖树 19 个包 1.7 MB)

= npm.Fetch(拉依赖树到临时目录)+ Vendor(摊平成一个文件)。

刻意不做的(它不是 npm):

  - 不跑安装脚本。那是供应链攻击的主要入口,而纯 JS 库没有编译步骤
  - 不管 devDependencies / peerDependencies / 平台二进制
  - semver 只实现 ^ / ~ / 精确 / x / >= 这个子集
  - 只平铺不嵌套

碰上支持不了的(复合范围、主版本冲突)明确报错并指向 npm + Vendor,
不猜版本——猜错了装出来能跑但行为不对,比装不上难查。

子集划得这么小是有依据的:抽 8 个常见包的 44 个传递依赖统计,^ 占 95%,
~ 和精确各一两处,主版本冲突 0 个。

安全上做了两件事:校验注册表给的 sha512(中间的缓存代理、私有源镜像
是真实存在的),以及挡住 tarball 里带 ../ 的路径。

测试全部走内存假注册表,不碰网络:依赖树平铺、共同依赖只装一次、
版本冲突报错、校验和不符、目录穿越。semver 那组表驱动——写这组时抓到
一个真 bug:1.2.x 被映射成了 ^1.2.0,只锁主版本,实际该锁到 1.2。
This commit is contained in:
2026-09-07 10:11:51 +08:00
parent efb3141734
commit 00d95c496f
6 changed files with 1029 additions and 4 deletions
+15 -4
View File
@@ -215,15 +215,26 @@ tar -xzf /tmp/x.tgz -C app/node_modules/es-toolkit --strip-components=1
npm 的 tarball 里固定是一个 `package/` 目录,`--strip-components=1` 剥掉就是标准布局。
**有依赖的包**得先解析依赖树——那正是 npm 真正在干的活(读 semver 范围、查注册表
定版本、递归、处理冲突),手工做不现实。但这一步只需要在**有 node 的机器上做一次**,
之后用 `Vendor` 把整棵树摊平成一个文件:
**有依赖的包**得先解析依赖树——那正是 npm 真正在干的活`Install` 把这件事也做了,
所以整条链不需要 node
```go
res, err := esm.Install(ctx, "qs", "app/node_modules")
// qs v6.16.0 打进 50 个文件 -> 73.3 KB(依赖树 19 个包 1.7 MB
```
它等于 `npm.Fetch`(拉依赖树到临时目录)加 `Vendor`(摊平成一个文件)。
包已经在本地装好了就直接用 `Vendor` 指着那个目录:
```go
res, err := esm.Vendor(".", "qs", "app/node_modules")
// qs v6.16.0 打进 47 个文件 -> 73.6 KB(原 node_modules 19 个包 1.7 MB
```
`esm/npm` 只做「把包和依赖弄到磁盘上」,**刻意不是 npm**:不跑安装脚本(供应链攻击
的主要入口,而纯 JS 库根本不需要)、不管 devDependencies、semver 只实现 `^`/`~`/精确/
`x`/`>=` 这个子集、只平铺不嵌套。碰上支持不了的(复合版本范围、主版本冲突)会**明确
报错**并让你改用 npm + `Vendor`,而不是猜一个版本装上去。
产物是最小布局,脚本照常 `import qs from "qs"`
```