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 包名自带一个斜杠所以前两段才是包名。
This commit is contained in:
2026-09-07 10:58:34 +08:00
parent 00d95c496f
commit ad722b12f9
6 changed files with 179 additions and 4 deletions
+10
View File
@@ -230,6 +230,16 @@ res, err := esm.Install(ctx, "qs", "app/node_modules")
res, err := esm.Vendor(".", "qs", "app/node_modules")
```
包把东西放在**子路径**下的(`es-toolkit/compat``lodash/isString`),直接写子路径:
```go
esm.Install(ctx, "es-toolkit/compat", "app/node_modules")
```
拉的是根包,摊平的是那个子路径,落到 `node_modules/es-toolkit/compat/`
**必须这么写**——摊平之后原包的 `exports` 映射就没了,`import "es-toolkit/compat"`
在只有根包的情况下解析不到。
`esm/npm` 只做「把包和依赖弄到磁盘上」,**刻意不是 npm**:不跑安装脚本(供应链攻击
的主要入口,而纯 JS 库根本不需要)、不管 devDependencies、semver 只实现 `^`/`~`/精确/
`x`/`>=` 这个子集、只平铺不嵌套。碰上支持不了的(复合版本范围、主版本冲突)会**明确
+20
View File
@@ -323,6 +323,26 @@ func SplitSpec(spec string) (name, rng string) {
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 {
+19
View File
@@ -220,3 +220,22 @@ func pick(versions []string, r versionRange) (string, error) {
}
return bestRaw, nil
}
// Satisfies 判断某个版本满不满足一个范围。
//
// 给「已经装过了,还用不用重装」这类判断用:配置里写 qs@^6,装着的是 6.16.0
// 就跳过,是 5.x 就得换。
//
// 范围的写法支持范围见本文件开头;解析不了会返回错误,调用方可以选择当作
// 「需要重装」还是直接报错。
func Satisfies(version, rangeSpec string) (bool, error) {
v, err := parseVersion(version)
if err != nil {
return false, err
}
r, err := parseRange(rangeSpec)
if err != nil {
return false, err
}
return r.allows(v), nil
}
+43
View File
@@ -121,3 +121,46 @@ func TestSplitSpec(t *testing.T) {
}
}
}
func TestSatisfies(t *testing.T) {
for _, c := range []struct {
ver, rng string
want bool
}{
{"6.16.0", "^6", true},
{"5.9.0", "^6", false},
{"6.16.0", "", true}, // 空范围=任意
{"6.16.0", "*", true},
{"1.2.9", "~1.2", true},
{"1.3.0", "~1.2", false},
{"1.2.3", "1.2.3", true},
} {
got, err := Satisfies(c.ver, c.rng)
if err != nil {
t.Errorf("Satisfies(%q, %q): %v", c.ver, c.rng, err)
continue
}
if got != c.want {
t.Errorf("Satisfies(%q, %q) = %v", c.ver, c.rng, got)
}
}
if _, err := Satisfies("1.0.0", "^1 || ^2"); err == nil {
t.Error("支持不了的范围该报错")
}
}
func TestSplitPath(t *testing.T) {
for _, c := range []struct{ in, pkg, sub string }{
{"es-toolkit", "es-toolkit", ""},
{"es-toolkit/compat", "es-toolkit", "compat"},
{"lodash/isString", "lodash", "isString"},
{"@scope/pkg", "@scope/pkg", ""},
{"@scope/pkg/sub", "@scope/pkg", "sub"},
{"@scope/pkg/a/b", "@scope/pkg", "a/b"},
} {
pkg, sub := SplitPath(c.in)
if pkg != c.pkg || sub != c.sub {
t.Errorf("SplitPath(%q) = %q, %q,该是 %q, %q", c.in, pkg, sub, c.pkg, c.sub)
}
}
}
+19 -4
View File
@@ -30,6 +30,11 @@ import (
// fromDir 是**装好依赖的目录**(npm i 跑过的那个,它下面有 node_modules);
// outRoot 一般传脚本目录旁边的 node_modules。
//
// name 可以带子路径,比如 es-toolkit/compat——有些包把东西放在子路径下,
// 而摊平之后原包的 exports 映射就没了,子路径解析不到。这时把子路径本身
// 当成一个包摊平,落到 node_modules/es-toolkit/compat/,脚本照常
// `import { toString } from "es-toolkit/compat"`。
//
// target 要跟加载脚本的 Loader 一致,否则可能打出脚本引擎跑不了的语法——
// 用 WithTarget 改过 Loader 的话这里也要改。
func Vendor(fromDir, name, outRoot string, opts ...VendorOption) (VendorResult, error) {
@@ -39,7 +44,11 @@ func Vendor(fromDir, name, outRoot string, opts ...VendorOption) (VendorResult,
}
var res VendorResult
version, err := installedVersion(fromDir, name)
// name 可以带子路径(es-toolkit/compat)。版本从**根包**读,
// 但打包入口和输出目录都用完整的名字。
pkg, _ := npm.SplitPath(name)
version, err := installedVersion(fromDir, pkg)
if err != nil {
return res, err
}
@@ -85,11 +94,17 @@ func Install(ctx context.Context, spec, outRoot string, opts ...VendorOption) (V
}
defer os.RemoveAll(tmp)
if _, err := npm.Fetch(ctx, tmp, spec); err != nil {
return VendorResult{}, err
// spec 可能带子路径(es-toolkit/compat@^1):拉的是根包,摊的是子路径
name, rng := npm.SplitSpec(spec)
pkg, _ := npm.SplitPath(name)
fetchSpec := pkg
if rng != "" {
fetchSpec = pkg + "@" + rng
}
name, _ := npm.SplitSpec(spec)
if _, err := npm.Fetch(ctx, tmp, fetchSpec); err != nil {
return VendorResult{}, err
}
return Vendor(tmp, name, outRoot, opts...)
}
+68
View File
@@ -191,3 +191,71 @@ func TestVendor_写出最小的package_json(t *testing.T) {
t.Error("不该写 exports 字段")
}
}
// 带子路径的名字要能摊平:有些包把东西放在子路径下(es-toolkit/compat),
// 而摊平之后原包的 exports 映射就没了,子路径本来解析不到。
func TestVendor_子路径当成独立的包摊平(t *testing.T) {
dir := t.TempDir()
// 摆一个带子目录的包:根入口和 sub 入口导出不同的东西
writePkg(t, dir, "multi", "1.0.0", "index.js", `export const root = "根"`, nil)
subDir := filepath.Join(dir, "node_modules", "multi", "sub")
if err := os.MkdirAll(subDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(subDir, "index.js"),
[]byte(`export const only = "只在子路径里"`), 0o644); err != nil {
t.Fatal(err)
}
app := t.TempDir()
out := filepath.Join(app, "node_modules")
if _, err := esm.Vendor(dir, "multi/sub", out); err != nil {
t.Fatalf("子路径摊不平: %v", err)
}
// 根包也摊一份,验证两者能共存(子路径目录嵌在根包目录里)
if _, err := esm.Vendor(dir, "multi", out); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(app, "Http"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(app, "Http", "C.ts"), []byte(`
import { root } from "multi"
import { only } from "multi/sub"
export default class C {
Run(): string { return root + "/" + only }
}`), 0o644); err != nil {
t.Fatal(err)
}
loader, err := esm.NewLoader(app)
if err != nil {
t.Fatalf("打包失败: %v", err)
}
e, err := jscriptx.New(jscriptx.WithLoader(loader), jscriptx.WithLogger(nil))
if err != nil {
t.Fatal(err)
}
defer e.Close()
s, err := e.Script("Http/C")
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
obj, err := s.New(ctx)
if err != nil {
t.Fatal(err)
}
defer obj.Close()
got, err := obj.Call(ctx, "Run")
if err != nil {
t.Fatal(err)
}
if got != "根/只在子路径里" {
t.Errorf("Run = %#v", got)
}
}