From ad722b12f9dbee50be286fb0c248a98ee7c334f5 Mon Sep 17 00:00:00 2001 From: what Date: Mon, 7 Sep 2026 10:58:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(esm):=20Vendor=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=AD=90=E8=B7=AF=E5=BE=84=EF=BC=8C=E4=B8=8D=E7=84=B6=20es-too?= =?UTF-8?q?lkit/compat=20=E8=BF=99=E7=A7=8D=E8=A3=85=E4=B8=8D=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 有些包把东西放在子路径下(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 包名自带一个斜杠所以前两段才是包名。 --- README.md | 10 +++++++ esm/npm/npm.go | 20 +++++++++++++ esm/npm/semver.go | 19 ++++++++++++ esm/npm/semver_test.go | 43 ++++++++++++++++++++++++++ esm/vendor.go | 23 +++++++++++--- esm/vendor_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 179 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3dc7d02..6666cea 100644 --- a/README.md +++ b/README.md @@ -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`/`>=` 这个子集、只平铺不嵌套。碰上支持不了的(复合版本范围、主版本冲突)会**明确 diff --git a/esm/npm/npm.go b/esm/npm/npm.go index e677319..b9853fb 100644 --- a/esm/npm/npm.go +++ b/esm/npm/npm.go @@ -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 { diff --git a/esm/npm/semver.go b/esm/npm/semver.go index 9e026b5..02acdc4 100644 --- a/esm/npm/semver.go +++ b/esm/npm/semver.go @@ -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 +} diff --git a/esm/npm/semver_test.go b/esm/npm/semver_test.go index fcfd981..86a73dd 100644 --- a/esm/npm/semver_test.go +++ b/esm/npm/semver_test.go @@ -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) + } + } +} diff --git a/esm/vendor.go b/esm/vendor.go index 96617b7..6305030 100644 --- a/esm/vendor.go +++ b/esm/vendor.go @@ -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...) } diff --git a/esm/vendor_test.go b/esm/vendor_test.go index ab0b732..07f9813 100644 --- a/esm/vendor_test.go +++ b/esm/vendor_test.go @@ -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) + } +}