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:
@@ -230,6 +230,16 @@ res, err := esm.Install(ctx, "qs", "app/node_modules")
|
|||||||
res, err := esm.Vendor(".", "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**:不跑安装脚本(供应链攻击
|
`esm/npm` 只做「把包和依赖弄到磁盘上」,**刻意不是 npm**:不跑安装脚本(供应链攻击
|
||||||
的主要入口,而纯 JS 库根本不需要)、不管 devDependencies、semver 只实现 `^`/`~`/精确/
|
的主要入口,而纯 JS 库根本不需要)、不管 devDependencies、semver 只实现 `^`/`~`/精确/
|
||||||
`x`/`>=` 这个子集、只平铺不嵌套。碰上支持不了的(复合版本范围、主版本冲突)会**明确
|
`x`/`>=` 这个子集、只平铺不嵌套。碰上支持不了的(复合版本范围、主版本冲突)会**明确
|
||||||
|
|||||||
@@ -323,6 +323,26 @@ func SplitSpec(spec string) (name, rng string) {
|
|||||||
return spec, ""
|
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 {
|
func keysOf(m map[string]release) []string {
|
||||||
out := make([]string, 0, len(m))
|
out := make([]string, 0, len(m))
|
||||||
for k := range m {
|
for k := range m {
|
||||||
|
|||||||
@@ -220,3 +220,22 @@ func pick(versions []string, r versionRange) (string, error) {
|
|||||||
}
|
}
|
||||||
return bestRaw, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -30,6 +30,11 @@ import (
|
|||||||
// fromDir 是**装好依赖的目录**(npm i 跑过的那个,它下面有 node_modules);
|
// fromDir 是**装好依赖的目录**(npm i 跑过的那个,它下面有 node_modules);
|
||||||
// outRoot 一般传脚本目录旁边的 node_modules。
|
// outRoot 一般传脚本目录旁边的 node_modules。
|
||||||
//
|
//
|
||||||
|
// name 可以带子路径,比如 es-toolkit/compat——有些包把东西放在子路径下,
|
||||||
|
// 而摊平之后原包的 exports 映射就没了,子路径解析不到。这时把子路径本身
|
||||||
|
// 当成一个包摊平,落到 node_modules/es-toolkit/compat/,脚本照常
|
||||||
|
// `import { toString } from "es-toolkit/compat"`。
|
||||||
|
//
|
||||||
// target 要跟加载脚本的 Loader 一致,否则可能打出脚本引擎跑不了的语法——
|
// target 要跟加载脚本的 Loader 一致,否则可能打出脚本引擎跑不了的语法——
|
||||||
// 用 WithTarget 改过 Loader 的话这里也要改。
|
// 用 WithTarget 改过 Loader 的话这里也要改。
|
||||||
func Vendor(fromDir, name, outRoot string, opts ...VendorOption) (VendorResult, error) {
|
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
|
var res VendorResult
|
||||||
version, err := installedVersion(fromDir, name)
|
|
||||||
|
// name 可以带子路径(es-toolkit/compat)。版本从**根包**读,
|
||||||
|
// 但打包入口和输出目录都用完整的名字。
|
||||||
|
pkg, _ := npm.SplitPath(name)
|
||||||
|
version, err := installedVersion(fromDir, pkg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
@@ -85,11 +94,17 @@ func Install(ctx context.Context, spec, outRoot string, opts ...VendorOption) (V
|
|||||||
}
|
}
|
||||||
defer os.RemoveAll(tmp)
|
defer os.RemoveAll(tmp)
|
||||||
|
|
||||||
if _, err := npm.Fetch(ctx, tmp, spec); err != nil {
|
// spec 可能带子路径(es-toolkit/compat@^1):拉的是根包,摊的是子路径
|
||||||
return VendorResult{}, err
|
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...)
|
return Vendor(tmp, name, outRoot, opts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,3 +191,71 @@ func TestVendor_写出最小的package_json(t *testing.T) {
|
|||||||
t.Error("不该写 exports 字段")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user