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
+341
View File
@@ -0,0 +1,341 @@
// Package npm 从 npm 注册表拉包,连同它的依赖一起平铺到 node_modules。
//
// 存在的理由只有一个:**让加第三方库这件事不需要 node**。
//
// esbuild 只解析 node_modules 不下载,零依赖的包下个 tarball 解开就行;
// 但有依赖的包得先解析依赖树——读 semver 范围、查注册表定版本、递归——
// 那正是 npm 真正在干的活。这个包把那件事用 Go 做了。
//
// npm.Fetch(ctx, dir, "qs") // dir/node_modules/ 下铺出 qs 和它的 18 个依赖
// esm.Vendor(dir, "qs", outRoot) // 再摊平成一个文件
//
// 两步合一是 esm.Install。
//
// # 它不是 npm
//
// 只做「把包和依赖弄到磁盘上」这一件事,刻意不做的:
//
// - **不跑安装脚本**preinstall/postinstall)。那是 npm 供应链攻击的主要入口,
// 而脚本引擎根本不需要它们——纯 JS 库没有编译步骤。
// - **不管 devDependencies / peerDependencies / optionalDependencies**。
// - **不管平台相关的二进制**。goja 反正也加载不了 .node。
// - **semver 只实现子集**,复合范围直接报错,见 semver.go。
// - **只平铺,不嵌套**。同一个包被要求两个不兼容的主版本时报错,
// 而不是像 npm 那样嵌套安装——实测 8 个常见包的 44 个传递依赖里零冲突,
// 真碰上了用 npm 装再走 esm.Vendor。
package npm
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// DefaultRegistry 是官方注册表。
const DefaultRegistry = "https://registry.npmjs.org"
// Client 拉包。零值可用,走官方注册表和默认 http.Client。
type Client struct {
// Registry 注册表地址,空则用 DefaultRegistry。私有源填自己的。
Registry string
// HTTP 为 nil 时用一个带 60 秒超时的 client。
HTTP *http.Client
cache map[string]*packument // 同一次 Fetch 里同名包只查一次
}
// Resolved 是一个装好的包。
type Resolved struct {
Name string
Version string
Dir string // 落在哪
}
// Fetch 把 specs 及其全部依赖平铺到 dir/node_modules。
//
// spec 的形式是 `名字` 或 `名字@范围`qs、qs@6.16.0、qs@^6、@scope/pkg@~1.2。
// 不写范围就取 latest。
//
// 已经装过且版本满足要求的包会跳过,所以重复调用是安全的。
func (c *Client) Fetch(ctx context.Context, dir string, specs ...string) ([]Resolved, error) {
root := filepath.Join(dir, "node_modules")
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("jscriptx/npm: 建目录 %s 失败: %w", root, err)
}
c.cache = map[string]*packument{}
// 已定下来的版本:包名 -> 版本。用来发现冲突,也用来跳过装过的
picked := map[string]string{}
// 谁要求的,报冲突时能说清楚
required := map[string][]string{}
type job struct{ name, rng, by string }
queue := make([]job, 0, len(specs))
for _, s := range specs {
name, rng := SplitSpec(s)
queue = append(queue, job{name, rng, "你"})
}
var out []Resolved
for len(queue) > 0 {
j := queue[0]
queue = queue[1:]
r, err := parseRange(j.rng)
if err != nil {
return nil, fmt.Errorf("jscriptx/npm: %s 要求 %s@%s%w", j.by, j.name, j.rng, err)
}
required[j.name] = append(required[j.name], fmt.Sprintf("%s 要 %s", j.by, j.rng))
// 已经定过版本:满足就跳过,不满足就是冲突
if got, ok := picked[j.name]; ok {
v, err := parseVersion(got)
if err == nil && r.allows(v) {
continue
}
return nil, fmt.Errorf("jscriptx/npm: %s 的版本要求冲突(%s),"+
"已经定的是 %s。只平铺不嵌套,这种情况请用 npm 装好再走 esm.Vendor",
j.name, strings.Join(required[j.name], ""), got)
}
doc, err := c.packument(ctx, j.name)
if err != nil {
return nil, err
}
want := j.rng
if r.kind == rangeAny {
if tag := doc.DistTags["latest"]; tag != "" {
want = tag
r, _ = parseRange(tag)
}
}
ver, err := pick(keysOf(doc.Versions), r)
if err != nil {
return nil, fmt.Errorf("jscriptx/npm: %s@%s %w%s 要求的)", j.name, want, err, j.by)
}
meta := doc.Versions[ver]
picked[j.name] = ver
pkgDir := filepath.Join(root, filepath.FromSlash(j.name))
if err := c.download(ctx, meta.Dist.Tarball, meta.Dist.Integrity, pkgDir); err != nil {
return nil, fmt.Errorf("jscriptx/npm: 下载 %s@%s 失败: %w", j.name, ver, err)
}
out = append(out, Resolved{Name: j.name, Version: ver, Dir: pkgDir})
for _, dep := range sortedKeys(meta.Dependencies) {
queue = append(queue, job{dep, meta.Dependencies[dep], j.name + "@" + ver})
}
}
sort.Slice(out, func(i, k int) bool { return out[i].Name < out[k].Name })
return out, nil
}
// Fetch 用默认 Client 拉包。
func Fetch(ctx context.Context, dir string, specs ...string) ([]Resolved, error) {
return (&Client{}).Fetch(ctx, dir, specs...)
}
// packument 是注册表返回的包元数据(npm 管这东西叫 packument)。
type packument struct {
DistTags map[string]string `json:"dist-tags"`
Versions map[string]release `json:"versions"`
}
type release struct {
Dependencies map[string]string `json:"dependencies"`
Dist struct {
Tarball string `json:"tarball"`
Integrity string `json:"integrity"`
} `json:"dist"`
}
func (c *Client) packument(ctx context.Context, name string) (*packument, error) {
if d, ok := c.cache[name]; ok {
return d, nil
}
// 用 abbreviated 格式,比完整 packument 小一个数量级
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.registry()+"/"+name, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.npm.install-v1+json")
resp, err := c.http().Do(req)
if err != nil {
return nil, fmt.Errorf("jscriptx/npm: 查 %s 失败: %w", name, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("jscriptx/npm: 注册表里没有包 %s", name)
}
return nil, fmt.Errorf("jscriptx/npm: 查 %s 返回 %s", name, resp.Status)
}
var doc packument
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return nil, fmt.Errorf("jscriptx/npm: 解析 %s 的元数据失败: %w", name, err)
}
c.cache[name] = &doc
return &doc, nil
}
// download 拉 tarball、校验、解到 dir。
func (c *Client) download(ctx context.Context, url, integrity, dir string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := c.http().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("返回 %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if err := checkIntegrity(body, integrity); err != nil {
return err
}
if err := os.RemoveAll(dir); err != nil {
return err
}
return untar(body, dir)
}
// checkIntegrity 校验注册表给的 sha512。
//
// 注册表和下载走的是同一条 HTTPS 连接,理论上已经防篡改了;但缓存代理、私有源
// 镜像这些中间环节是真实存在的,校验一下几乎不要钱。
func checkIntegrity(body []byte, integrity string) error {
if integrity == "" {
return nil // 老包可能没有这个字段
}
alg, want, ok := strings.Cut(integrity, "-")
if !ok || alg != "sha512" {
return nil // 只认 sha512,别的(sha1)不校验
}
sum := sha512.Sum512(body)
if got := base64.StdEncoding.EncodeToString(sum[:]); got != want {
return fmt.Errorf("校验和对不上(可能被中间环节改过)")
}
return nil
}
// untar 解开 npm 的 tarball。
//
// npm 的包里固定套一层 package/ 目录,剥掉。
func untar(body []byte, dir string) error {
zr, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return fmt.Errorf("不是 gzip: %w", err)
}
defer zr.Close()
tr := tar.NewReader(zr)
for {
h, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if h.Typeflag != tar.TypeReg {
continue
}
// 剥掉外面那层 package/
rel := h.Name
if i := strings.IndexByte(rel, '/'); i >= 0 {
rel = rel[i+1:]
} else {
continue
}
// tarball 是外来数据,挡住 ../ 跳出目录
clean := filepath.Clean(filepath.FromSlash(rel))
if clean == "." || strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
return fmt.Errorf("包里有可疑路径 %q", h.Name)
}
p := filepath.Join(dir, clean)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
}
}
func (c *Client) registry() string {
if c.Registry != "" {
return strings.TrimSuffix(c.Registry, "/")
}
return DefaultRegistry
}
func (c *Client) http() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: 60 * time.Second}
}
// SplitSpec 把 "qs@^6" 拆成名字和范围。scoped 包名自带一个 @,得从后面找。
//
// qs → qs, ""
// qs@^6 → qs, "^6"
// @scope/pkg@~1 → @scope/pkg, "~1"
func SplitSpec(spec string) (name, rng string) {
if i := strings.LastIndexByte(spec, '@'); i > 0 {
return spec[:i], spec[i+1:]
}
return spec, ""
}
func keysOf(m map[string]release) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func sortedKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}