diff --git a/README.md b/README.md index a1bb2ea..3dc7d02 100644 --- a/README.md +++ b/README.md @@ -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"`: ``` diff --git a/esm/npm/npm.go b/esm/npm/npm.go new file mode 100644 index 0000000..e677319 --- /dev/null +++ b/esm/npm/npm.go @@ -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 +} diff --git a/esm/npm/npm_test.go b/esm/npm/npm_test.go new file mode 100644 index 0000000..9eb69ba --- /dev/null +++ b/esm/npm/npm_test.go @@ -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, "/") + + // //-/-.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 + } + + // / 取元数据 + 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) + } +} diff --git a/esm/npm/semver.go b/esm/npm/semver.go new file mode 100644 index 0000000..9e026b5 --- /dev/null +++ b/esm/npm/semver.go @@ -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.0,1.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 +} diff --git a/esm/npm/semver_test.go b/esm/npm/semver_test.go new file mode 100644 index 0000000..fcfd981 --- /dev/null +++ b/esm/npm/semver_test.go @@ -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) + } + } +} diff --git a/esm/vendor.go b/esm/vendor.go index d9d36b5..96617b7 100644 --- a/esm/vendor.go +++ b/esm/vendor.go @@ -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