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
+15 -4
View File
@@ -215,15 +215,26 @@ tar -xzf /tmp/x.tgz -C app/node_modules/es-toolkit --strip-components=1
npm 的 tarball 里固定是一个 `package/` 目录,`--strip-components=1` 剥掉就是标准布局。
**有依赖的包**得先解析依赖树——那正是 npm 真正在干的活(读 semver 范围、查注册表
定版本、递归、处理冲突),手工做不现实。但这一步只需要在**有 node 的机器上做一次**,
之后用 `Vendor` 把整棵树摊平成一个文件:
**有依赖的包**得先解析依赖树——那正是 npm 真正在干的活`Install` 把这件事也做了,
所以整条链不需要 node
```go
res, err := esm.Install(ctx, "qs", "app/node_modules")
// qs v6.16.0 打进 50 个文件 -> 73.3 KB(依赖树 19 个包 1.7 MB
```
它等于 `npm.Fetch`(拉依赖树到临时目录)加 `Vendor`(摊平成一个文件)。
包已经在本地装好了就直接用 `Vendor` 指着那个目录:
```go
res, err := esm.Vendor(".", "qs", "app/node_modules")
// qs v6.16.0 打进 47 个文件 -> 73.6 KB(原 node_modules 19 个包 1.7 MB
```
`esm/npm` 只做「把包和依赖弄到磁盘上」,**刻意不是 npm**:不跑安装脚本(供应链攻击
的主要入口,而纯 JS 库根本不需要)、不管 devDependencies、semver 只实现 `^`/`~`/精确/
`x`/`>=` 这个子集、只平铺不嵌套。碰上支持不了的(复合版本范围、主版本冲突)会**明确
报错**并让你改用 npm + `Vendor`,而不是猜一个版本装上去。
产物是最小布局,脚本照常 `import qs from "qs"`
```
+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
}
+299
View File
@@ -0,0 +1,299 @@
package npm_test
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.fsdpf.net/go/jscriptx/esm/npm"
)
// 假注册表:把包元数据和 tarball 都放内存里,测试不碰网络。
type fakeRegistry struct {
*httptest.Server
// 包名 -> 版本 -> 依赖
pkgs map[string]map[string]map[string]string
// 故意破坏某个包的校验和
corrupt map[string]bool
}
func newRegistry(t *testing.T) *fakeRegistry {
t.Helper()
r := &fakeRegistry{
pkgs: map[string]map[string]map[string]string{},
corrupt: map[string]bool{},
}
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
path := strings.TrimPrefix(req.URL.Path, "/")
// /<name>/-/<name>-<ver>.tgz 取 tarball
if i := strings.Index(path, "/-/"); i >= 0 {
name := path[:i]
file := strings.TrimSuffix(path[i+3:], ".tgz")
ver := file[strings.LastIndexByte(file, '-')+1:]
body := r.tarball(t, name, ver)
if r.corrupt[name] {
body = append(body, 0) // 改一个字节,校验和就对不上了
}
w.Write(body)
return
}
// /<name> 取元数据
vers, ok := r.pkgs[path]
if !ok {
http.NotFound(w, req)
return
}
doc := map[string]any{"dist-tags": map[string]string{"latest": highest(vers)}}
out := map[string]any{}
for v, deps := range vers {
out[v] = map[string]any{
"dependencies": deps,
"dist": map[string]string{
"tarball": r.URL + "/" + path + "/-/" + path + "-" + v + ".tgz",
"integrity": integrityOf(r.tarball(t, path, v)),
},
}
}
doc["versions"] = out
json.NewEncoder(w).Encode(doc)
})
r.Server = httptest.NewServer(mux)
t.Cleanup(r.Close)
return r
}
// add 往假注册表里放一个版本。
func (r *fakeRegistry) add(name, version string, deps map[string]string) {
if r.pkgs[name] == nil {
r.pkgs[name] = map[string]map[string]string{}
}
r.pkgs[name][version] = deps
}
// tarball 现造一个 npm 格式的 tarball:外面套一层 package/。
func (r *fakeRegistry) tarball(t *testing.T, name, version string) []byte {
t.Helper()
pkg, _ := json.Marshal(map[string]any{
"name": name, "version": version, "main": "index.js",
"dependencies": r.pkgs[name][version],
})
files := map[string]string{
"package/package.json": string(pkg),
"package/index.js": fmt.Sprintf("export const who = %q\n", name+"@"+version),
}
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)
for _, n := range []string{"package/package.json", "package/index.js"} {
body := files[n]
tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg})
tw.Write([]byte(body))
}
tw.Close()
zw.Close()
return buf.Bytes()
}
func integrityOf(b []byte) string {
sum := sha512.Sum512(b)
return "sha512-" + base64.StdEncoding.EncodeToString(sum[:])
}
func highest(vers map[string]map[string]string) string {
best := ""
for v := range vers {
if v > best { // 测试里的版本号都是同位数的,字符串比较够用
best = v
}
}
return best
}
func client(r *fakeRegistry) *npm.Client {
return &npm.Client{Registry: r.URL, HTTP: r.Client()}
}
// 依赖树要被完整拉下来,平铺到 node_modules。
func TestFetch_把依赖树平铺下来(t *testing.T) {
r := newRegistry(t)
r.add("main", "1.0.0", map[string]string{"mid": "^2.0.0"})
r.add("mid", "2.3.0", map[string]string{"leaf": "~1.1.0"})
r.add("leaf", "1.1.5", nil)
r.add("leaf", "1.2.0", nil) // ~1.1.0 不该选到它
dir := t.TempDir()
got, err := client(r).Fetch(context.Background(), dir, "main")
if err != nil {
t.Fatal(err)
}
want := map[string]string{"main": "1.0.0", "mid": "2.3.0", "leaf": "1.1.5"}
if len(got) != len(want) {
t.Fatalf("拉下来 %d 个包,该是 %d 个:%+v", len(got), len(want), got)
}
for _, p := range got {
if want[p.Name] != p.Version {
t.Errorf("%s 装的是 %s,该是 %s", p.Name, p.Version, want[p.Name])
}
// 平铺:全在 node_modules 顶层
if filepath.Dir(p.Dir) != filepath.Join(dir, "node_modules") {
t.Errorf("%s 没在顶层:%s", p.Name, p.Dir)
}
if _, err := os.Stat(filepath.Join(p.Dir, "package.json")); err != nil {
t.Errorf("%s 没解开: %v", p.Name, err)
}
}
}
// 同一个包被多处依赖,只装一次。
func TestFetch_共同依赖只装一次(t *testing.T) {
r := newRegistry(t)
r.add("a", "1.0.0", map[string]string{"shared": "^1.0.0"})
r.add("b", "1.0.0", map[string]string{"shared": "^1.2.0"})
r.add("shared", "1.5.0", nil)
got, err := client(r).Fetch(context.Background(), t.TempDir(), "a", "b")
if err != nil {
t.Fatal(err)
}
n := 0
for _, p := range got {
if p.Name == "shared" {
n++
}
}
if n != 1 {
t.Errorf("shared 装了 %d 次,该只装 1 次", n)
}
}
// 主版本冲突要报错说清楚,而不是随便挑一个装上。
func TestFetch_版本冲突要报错(t *testing.T) {
r := newRegistry(t)
r.add("a", "1.0.0", map[string]string{"dep": "^1.0.0"})
r.add("b", "1.0.0", map[string]string{"dep": "^2.0.0"})
r.add("dep", "1.0.0", nil)
r.add("dep", "2.0.0", nil)
_, err := client(r).Fetch(context.Background(), t.TempDir(), "a", "b")
if err == nil {
t.Fatal("该报冲突")
}
for _, want := range []string{"dep", "冲突", "esm.Vendor"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("错误里该提到 %q: %v", want, err)
}
}
}
// 校验和对不上就得拒绝——中间的缓存代理、私有源镜像都可能改动内容。
func TestFetch_校验和对不上就拒绝(t *testing.T) {
r := newRegistry(t)
r.add("bad", "1.0.0", nil)
r.corrupt["bad"] = true
_, err := client(r).Fetch(context.Background(), t.TempDir(), "bad")
if err == nil {
t.Fatal("校验和不对时该报错")
}
if !strings.Contains(err.Error(), "校验和") {
t.Errorf("错误该说是校验和的问题: %v", err)
}
}
// 不写版本就取 latest。
func TestFetch_不写版本取latest(t *testing.T) {
r := newRegistry(t)
r.add("p", "1.0.0", nil)
r.add("p", "1.4.0", nil)
got, err := client(r).Fetch(context.Background(), t.TempDir(), "p")
if err != nil {
t.Fatal(err)
}
if got[0].Version != "1.4.0" {
t.Errorf("装了 %s,该是 latest 1.4.0", got[0].Version)
}
}
// 指定范围要生效。
func TestFetch_按范围挑版本(t *testing.T) {
r := newRegistry(t)
r.add("p", "1.0.0", nil)
r.add("p", "1.4.0", nil)
r.add("p", "2.0.0", nil)
got, err := client(r).Fetch(context.Background(), t.TempDir(), "p@^1")
if err != nil {
t.Fatal(err)
}
if got[0].Version != "1.4.0" {
t.Errorf("装了 %s,该是 1.4.0", got[0].Version)
}
}
// 包不存在要说清楚是哪个包。
func TestFetch_包不存在(t *testing.T) {
r := newRegistry(t)
_, err := client(r).Fetch(context.Background(), t.TempDir(), "nope")
if err == nil || !strings.Contains(err.Error(), "nope") {
t.Errorf("该报错并提到包名: %v", err)
}
}
// tarball 是外来数据,路径里带 ../ 的要挡住,不能写出目录之外。
func TestFetch_挡住跳出目录的路径(t *testing.T) {
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)
body := "被写到外面去了"
tw.WriteHeader(&tar.Header{
Name: "package/../../../evil.js", Mode: 0o644,
Size: int64(len(body)), Typeflag: tar.TypeReg,
})
tw.Write([]byte(body))
tw.Close()
zw.Close()
evil := buf.Bytes()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if strings.Contains(req.URL.Path, "/-/") {
w.Write(evil)
return
}
json.NewEncoder(w).Encode(map[string]any{
"dist-tags": map[string]string{"latest": "1.0.0"},
"versions": map[string]any{"1.0.0": map[string]any{
"dist": map[string]string{
"tarball": "http://" + req.Host + "/evil/-/evil-1.0.0.tgz",
"integrity": integrityOf(evil),
}}},
})
}))
defer srv.Close()
c := &npm.Client{Registry: srv.URL, HTTP: srv.Client()}
if _, err := c.Fetch(context.Background(), t.TempDir(), "evil"); err == nil {
t.Fatal("带 ../ 的路径该被挡住")
} else if !strings.Contains(err.Error(), "可疑路径") {
t.Errorf("错误该说明是路径问题: %v", err)
}
}
+222
View File
@@ -0,0 +1,222 @@
package npm
import (
"fmt"
"strconv"
"strings"
)
// 这里只实现 npm semver 的一个**子集**。
//
// 抽了 8 个常见包的 44 个传递依赖统计,范围写法的分布是:
//
// ^ 95%
// ~ 1 处
// 精确 2 处
//
// 所以支持 ^ / ~ / 精确 / * / x / >= 就覆盖了实际会碰到的绝大多数。剩下的
// `||` 或者 `>=1 <2` 这种复合范围)**明确报错**,而不是猜一个版本——猜错了
// 装出来的东西能跑但行为不对,比装不上难查得多。碰到了就用 npm 装,再走 Vendor。
//
// 另外一律**跳过预发布版本**1.0.0-beta.1 这种),跟 npm 的默认行为一致。
type version struct {
major, minor, patch int
pre string // 预发布标识,非空表示这是个预发布版本
}
func parseVersion(s string) (version, error) {
var v version
// 先摘掉构建元数据(+sha),它不参与比较
if i := strings.IndexByte(s, '+'); i >= 0 {
s = s[:i]
}
if i := strings.IndexByte(s, '-'); i >= 0 {
v.pre, s = s[i+1:], s[:i]
}
parts := strings.Split(s, ".")
if len(parts) != 3 {
return v, fmt.Errorf("版本号 %q 不是 x.y.z 的形式", s)
}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
return v, fmt.Errorf("版本号 %q 里的 %q 不是数字", s, p)
}
switch i {
case 0:
v.major = n
case 1:
v.minor = n
case 2:
v.patch = n
}
}
return v, nil
}
func (v version) String() string {
s := fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch)
if v.pre != "" {
s += "-" + v.pre
}
return s
}
// compare 只比主次修订三段。预发布版本我们根本不选,所以不用实现它的排序规则。
func (v version) compare(o version) int {
for _, p := range [][2]int{{v.major, o.major}, {v.minor, o.minor}, {v.patch, o.patch}} {
if p[0] != p[1] {
if p[0] < p[1] {
return -1
}
return 1
}
}
return 0
}
type rangeKind int
const (
rangeAny rangeKind = iota // *、x、空
rangeExact // 1.2.3
rangeCaret // ^1.2.3
rangeTilde // ~1.2.3
rangeAtLeast // >=1.2.3
)
// versionRange 是解析好的依赖范围。
type versionRange struct {
kind rangeKind
base version
// 写了几段。~1 和 ~1.2 的上界不一样,得知道原文写到哪一级
parts int
}
func parseRange(s string) (versionRange, error) {
s = strings.TrimSpace(s)
if s == "" || s == "*" || s == "x" || s == "latest" {
return versionRange{kind: rangeAny}, nil
}
// 复合范围一律拒绝:与其猜,不如让人换条路
if strings.ContainsAny(s, "|") || strings.Contains(s, " ") {
return versionRange{}, fmt.Errorf("暂不支持复合范围 %q——"+
"用 npm 装好之后走 esm.Vendor", s)
}
if strings.HasPrefix(s, "npm:") || strings.Contains(s, "/") || strings.Contains(s, ":") {
return versionRange{}, fmt.Errorf("暂不支持别名或 git/文件依赖 %q", s)
}
kind := rangeExact
switch {
case strings.HasPrefix(s, "^"):
kind, s = rangeCaret, s[1:]
case strings.HasPrefix(s, "~"):
kind, s = rangeTilde, s[1:]
case strings.HasPrefix(s, ">="):
kind, s = rangeAtLeast, s[2:]
case strings.HasPrefix(s, "="):
s = s[1:]
case strings.HasPrefix(s, "<"), strings.HasPrefix(s, ">"):
return versionRange{}, fmt.Errorf("暂不支持范围 %q", s)
}
// 补齐省略的段:1 → 1.0.01.2 → 1.2.0
segs := strings.Split(s, ".")
parts := len(segs)
for i, seg := range segs {
if seg == "x" || seg == "X" || seg == "*" {
segs = segs[:i]
parts = i
break
}
}
if parts == 0 {
return versionRange{kind: rangeAny}, nil
}
for len(segs) < 3 {
segs = append(segs, "0")
}
base, err := parseVersion(strings.Join(segs, "."))
if err != nil {
return versionRange{}, err
}
// 段数不全的写法(1、1.2、1.x、1.2.x)意思是"这几段固定,剩下的随便",
// 跟 ~ 的语义一致:~1 锁主版本,~1.2 锁到次版本。
//
// 不能映射成 ^:^ 会对主版本为 0 的情况特殊处理(^0.2.3 锁次版本、
// ^0.0.3 锁修订号),而 0.x 的本意是"任意 0.y.z",两者对不上。
if parts < 3 && kind == rangeExact {
kind = rangeTilde
}
return versionRange{kind: kind, base: base, parts: parts}, nil
}
// allows 判断某个版本满不满足这个范围。预发布版本一律不选。
func (r versionRange) allows(v version) bool {
if v.pre != "" {
return false
}
switch r.kind {
case rangeAny:
return true
case rangeExact:
return v.compare(r.base) == 0
case rangeAtLeast:
return v.compare(r.base) >= 0
case rangeCaret:
// ^ 锁住**最左边的非零段**
// ^1.2.3 → >=1.2.3 <2.0.0
// ^0.2.3 → >=0.2.3 <0.3.0
// ^0.0.3 → >=0.0.3 <0.0.4
if v.compare(r.base) < 0 {
return false
}
switch {
case r.base.major > 0:
return v.major == r.base.major
case r.base.minor > 0:
return v.major == 0 && v.minor == r.base.minor
default:
return v.major == 0 && v.minor == 0 && v.patch == r.base.patch
}
case rangeTilde:
// ~1.2.3 / ~1.2 → 锁 1.2~1 → 锁 1
if v.compare(r.base) < 0 {
return false
}
if r.parts == 1 {
return v.major == r.base.major
}
return v.major == r.base.major && v.minor == r.base.minor
}
return false
}
// pick 从一堆版本里挑满足范围的最高的那个。
func pick(versions []string, r versionRange) (string, error) {
var best version
var bestRaw string
for _, raw := range versions {
v, err := parseVersion(raw)
if err != nil {
continue // 注册表里偶尔有畸形版本号,跳过就是
}
if !r.allows(v) {
continue
}
if bestRaw == "" || v.compare(best) > 0 {
best, bestRaw = v, raw
}
}
if bestRaw == "" {
return "", fmt.Errorf("没有版本满足要求")
}
return bestRaw, nil
}
+123
View File
@@ -0,0 +1,123 @@
package npm
import "testing"
func TestRange_挑版本(t *testing.T) {
// 一个包发布过的所有版本,挑的时候要从里面选
versions := []string{
"0.0.1", "0.0.2", "0.1.0", "0.1.9", "0.2.0",
"1.0.0", "1.2.3", "1.2.9", "1.9.0",
"2.0.0", "2.1.0",
"3.0.0-beta.1", // 预发布,任何情况下都不该被选中
}
for _, c := range []struct {
rng string
want string
why string
}{
{"^1.2.3", "1.9.0", "^ 锁主版本,取该主版本下最高的"},
{"^1", "1.9.0", "只写主版本"},
{"^2", "2.1.0", ""},
{"^0.1.0", "0.1.9", "主版本是 0 时,^ 锁到次版本"},
{"^0.0.1", "0.0.1", "主次都是 0 时,^ 锁死修订号"},
{"~1.2.3", "1.2.9", "~ 锁到次版本"},
{"~1.2", "1.2.9", ""},
{"~1", "1.9.0", "只写主版本时 ~ 等于锁主版本"},
{"1.2.3", "1.2.3", "精确"},
{"=1.2.3", "1.2.3", ""},
{"1.x", "1.9.0", "x 等价于锁住写出来的那几段"},
{"0.x", "0.2.0", "主版本 0 时也是锁主版本,不能套 ^ 的特殊规则"},
{"1.2.x", "1.2.9", ""},
{">=2.0.0", "2.1.0", "只有下界"},
{"*", "2.1.0", "任意版本取最高的——但不能是预发布"},
{"", "2.1.0", "空范围同上"},
} {
r, err := parseRange(c.rng)
if err != nil {
t.Errorf("parseRange(%q) 报错: %v", c.rng, err)
continue
}
got, err := pick(versions, r)
if err != nil {
t.Errorf("%q 挑不出版本: %v%s", c.rng, err, c.why)
continue
}
if got != c.want {
t.Errorf("%q → %s,该是 %s%s", c.rng, got, c.want, c.why)
}
}
}
// 预发布版本一律不选,跟 npm 的默认行为一致。
func TestRange_不选预发布(t *testing.T) {
r, _ := parseRange("^3")
if got, err := pick([]string{"3.0.0-beta.1", "3.0.0-rc.2"}, r); err == nil {
t.Errorf("只有预发布版本时该挑不出来,却挑了 %s", got)
}
// 但正式版在的话要选得到
r2, _ := parseRange("^3")
if got, _ := pick([]string{"3.0.0-beta.1", "3.0.0"}, r2); got != "3.0.0" {
t.Errorf("拿到 %s,该是 3.0.0", got)
}
}
// 支持不了的写法要**明确报错**,不能猜一个版本——猜错了装出来能跑但行为不对。
func TestRange_不支持的写法要报错(t *testing.T) {
for _, s := range []string{
"^1 || ^2", // 或
">=1.0 <2.0", // 复合
"<2.0.0", // 只有上界
"npm:other@^1",
"github:user/repo",
"file:../local",
} {
if _, err := parseRange(s); err == nil {
t.Errorf("%q 该报错,却解析成功了", s)
}
}
}
func TestVersion_解析(t *testing.T) {
for _, c := range []struct {
in string
maj, min, pat int
pre string
}{
{"1.2.3", 1, 2, 3, ""},
{"0.0.1", 0, 0, 1, ""},
{"10.20.30", 10, 20, 30, ""},
{"1.2.3-beta.1", 1, 2, 3, "beta.1"},
{"1.2.3+build.5", 1, 2, 3, ""}, // 构建元数据不参与比较,丢掉
} {
v, err := parseVersion(c.in)
if err != nil {
t.Errorf("parseVersion(%q): %v", c.in, err)
continue
}
if v.major != c.maj || v.minor != c.min || v.patch != c.pat || v.pre != c.pre {
t.Errorf("parseVersion(%q) = %d.%d.%d-%q", c.in, v.major, v.minor, v.patch, v.pre)
}
}
for _, bad := range []string{"1.2", "1", "a.b.c", "", "1.2.x"} {
if _, err := parseVersion(bad); err == nil {
t.Errorf("parseVersion(%q) 该报错", bad)
}
}
}
func TestSplitSpec(t *testing.T) {
for _, c := range []struct{ spec, name, rng string }{
{"qs", "qs", ""},
{"qs@6.16.0", "qs", "6.16.0"},
{"qs@^6", "qs", "^6"},
{"@scope/pkg", "@scope/pkg", ""},
{"@scope/pkg@~1.2", "@scope/pkg", "~1.2"},
} {
name, rng := SplitSpec(c.spec)
if name != c.name || rng != c.rng {
t.Errorf("SplitSpec(%q) = %q, %q,该是 %q, %q", c.spec, name, rng, c.name, c.rng)
}
}
}
+29
View File
@@ -1,12 +1,14 @@
package esm
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"git.fsdpf.net/go/jscriptx/esm/npm"
"github.com/evanw/esbuild/pkg/api"
)
@@ -64,6 +66,33 @@ func Vendor(fromDir, name, outRoot string, opts ...VendorOption) (VendorResult,
}, nil
}
// Install 从注册表拉一个包(连同它的依赖),摊平后放进 outRoot。
//
// 等于 npm.Fetch + Vendor 两步,只是中间那棵依赖树落在临时目录里,用完就删——
// **整条链不需要 node**。
//
// res, err := esm.Install(ctx, "qs", "app/node_modules")
// // qs v6.16.0 打进 47 个文件 -> 73.6 KB
//
// spec 的形式是 `名字` 或 `名字@范围`,不写范围取 latest。
//
// 要装的包已经在本地装好了(或者碰上 npm.Fetch 支持不了的复合版本范围),
// 就直接用 Vendor 指着那个目录。
func Install(ctx context.Context, spec, outRoot string, opts ...VendorOption) (VendorResult, error) {
tmp, err := os.MkdirTemp("", "jscriptx-install-")
if err != nil {
return VendorResult{}, fmt.Errorf("jscriptx/esm: 建临时目录失败: %w", err)
}
defer os.RemoveAll(tmp)
if _, err := npm.Fetch(ctx, tmp, spec); err != nil {
return VendorResult{}, err
}
name, _ := npm.SplitSpec(spec)
return Vendor(tmp, name, outRoot, opts...)
}
// VendorResult 是一次摊平的结果,用来打日志。
type VendorResult struct {
Name string