有些包把东西放在子路径下(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 包名自带一个斜杠所以前两段才是包名。
362 lines
10 KiB
Go
362 lines
10 KiB
Go
// 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
|
||
}
|