Files
jscriptx/esm/npm/semver.go
T
what 00d95c496f 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。
2026-09-07 10:11:51 +08:00

223 lines
5.7 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
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
}