Files
jscriptx/esm/npm/npm.go
T
what ad722b12f9 fix(esm): Vendor 支持子路径,不然 es-toolkit/compat 这种装不了
有些包把东西放在子路径下(es-toolkit 的 toString 只在 compat 里,主入口没有),
而摊平之后原包的 exports 映射就没了,import "es-toolkit/compat" 解析不到。

现在直接写子路径就行:

    esm.Install(ctx, "es-toolkit/compat", "app/node_modules")

拉的是根包,摊平的是子路径,落到 node_modules/es-toolkit/compat/。根包和子路径
可以共存——子路径目录嵌在根包目录里,而最小 package.json 不写 exports,
所以解析器认得出来。

npm.SplitPath 负责拆名字,scoped 包名自带一个斜杠所以前两段才是包名。
2026-09-07 10:58:34 +08:00

362 lines
10 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, ""
}
// SplitPath 把带子路径的名字拆成包名和子路径。
//
// es-toolkit → es-toolkit, ""
// es-toolkit/compat → es-toolkit, "compat"
// @scope/pkg → @scope/pkg, ""
// @scope/pkg/sub/dir → @scope/pkg, "sub/dir"
//
// scoped 包名自带一个斜杠,所以前两段是包名。
func SplitPath(name string) (pkg, subpath string) {
segs := strings.Split(name, "/")
n := 1
if strings.HasPrefix(name, "@") {
n = 2
}
if len(segs) <= n {
return name, ""
}
return strings.Join(segs[:n], "/"), strings.Join(segs[n:], "/")
}
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
}