Files
jscriptx/esm/npm/npm.go
T
what 00d95c496f 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。
2026-09-07 10:11:51 +08:00

342 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}