// 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 }