diff --git a/README.md b/README.md index 14be815..a1bb2ea 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,43 @@ stat 一遍太贵。装完或换版本后调 `loader.Rebuild()`。 > esbuild 只**解析** `node_modules`,不下载。包怎么进去(npm/pnpm 安装、直接提交进仓库)由你决定。 +### 目标机器上没有 node 怎么办 + +`node_modules` 对 esbuild 来说就是一个约定好的目录布局,谁摆进去的不重要。所以: + +**零依赖的包**直接下 tarball 就行,全程只用 curl 和 tar: + +```sh +curl -sL https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz -o /tmp/x.tgz +mkdir -p app/node_modules/es-toolkit +tar -xzf /tmp/x.tgz -C app/node_modules/es-toolkit --strip-components=1 +``` + +npm 的 tarball 里固定是一个 `package/` 目录,`--strip-components=1` 剥掉就是标准布局。 + +**有依赖的包**得先解析依赖树——那正是 npm 真正在干的活(读 semver 范围、查注册表 +定版本、递归、处理冲突),手工做不现实。但这一步只需要在**有 node 的机器上做一次**, +之后用 `Vendor` 把整棵树摊平成一个文件: + +```go +res, err := esm.Vendor(".", "qs", "app/node_modules") +// qs v6.16.0 打进 47 个文件 -> 73.6 KB(原 node_modules 19 个包 1.7 MB) +``` + +产物是最小布局,脚本照常 `import qs from "qs"`: + +``` +app/node_modules/qs/ +├── index.mjs 依赖全内联 +└── package.json 只为让解析器找到入口 +``` + +**发布物里带上这两个文件,目标机器不需要 node、不需要 npm、不需要联网。** + +两个 `package.json` 别混:**脚本根目录那个不需要**(esbuild 不看它,`type: module` +也不影响);**包自己那个必需**——解析器靠 `main`/`module` 定位入口,拿掉就打包失败。 +零依赖的包那个跟着 tarball 自带,`Vendor` 摊平的那个由它生成。 + ### 挑第三方库要看两件事 **一、有没有用 goja 没有的全局。** esbuild 的 `target` 只降级**语法**,不补**全局对象**。 diff --git a/esm/loader.go b/esm/loader.go index 9ae049d..6256bb3 100644 --- a/esm/loader.go +++ b/esm/loader.go @@ -11,6 +11,11 @@ import ( // outDir 只是给 esbuild 算相对路径用的虚拟目录,不落盘。 const outDir = "__jsx_out" +// defaultTarget 是打包输出的默认 ECMAScript 版本。goja 对更新的语法覆盖不全, +// 所以不跟着 esnext 走。Loader 和 Vendor 共用它——摊平出来的库要跟脚本同一档, +// 否则库能打出脚本引擎跑不了的语法。 +const defaultTarget = api.ES2017 + // DefaultGlobs 是默认的入口规则:根目录第一层子目录下的 js/ts 文件。 // 对应 app/PkgVersion/PkgImportController.ts 这样的结构。 var DefaultGlobs = []string{"*/*.js", "*/*.ts", "*/*.mjs"} @@ -121,7 +126,7 @@ func NewLoader(dir string, opts ...Option) (*Loader, error) { l := &Loader{ dir: dir, globs: DefaultGlobs, - target: api.ES2017, + target: defaultTarget, } for _, opt := range opts { opt(l) diff --git a/esm/vendor.go b/esm/vendor.go new file mode 100644 index 0000000..d9d36b5 --- /dev/null +++ b/esm/vendor.go @@ -0,0 +1,184 @@ +package esm + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/evanw/esbuild/pkg/api" +) + +// Vendor 把一个装好的 npm 包连同它的依赖树,摊平成一个文件放进 outRoot。 +// +// 解决的是这个问题:脚本要用第三方库,但**跑脚本的机器上没有 node**。 +// +// npm 真正干的活是解析依赖树——读 semver 范围、查注册表定版本、递归、处理冲突。 +// 这一步绕不开,得在有 node 的机器上做一次。但做完之后依赖树就是死数据了, +// 用 esbuild 摊平成单文件,发布物里只带那一个文件就够: +// +// 开发机(有 node) 发布物 / 目标机器(不需要 node) +// npm i qs app/node_modules/qs/ +// → 19 个包 1.7 MB ├── index.mjs 73 KB,依赖全内联 +// esm.Vendor(".", "qs", …) └── package.json 最小的,只为让解析器找到入口 +// +// 摊平之后脚本照常 `import qs from "qs"`,写法完全不变。 +// +// fromDir 是**装好依赖的目录**(npm i 跑过的那个,它下面有 node_modules); +// outRoot 一般传脚本目录旁边的 node_modules。 +// +// target 要跟加载脚本的 Loader 一致,否则可能打出脚本引擎跑不了的语法—— +// 用 WithTarget 改过 Loader 的话这里也要改。 +func Vendor(fromDir, name, outRoot string, opts ...VendorOption) (VendorResult, error) { + v := &vendorOpts{target: defaultTarget} + for _, o := range opts { + o(v) + } + + var res VendorResult + version, err := installedVersion(fromDir, name) + if err != nil { + return res, err + } + + out, modules, err := vendorBundle(fromDir, name, v.target) + if err != nil { + return res, err + } + + dir := filepath.Join(outRoot, filepath.FromSlash(name)) + if err := os.MkdirAll(dir, 0o755); err != nil { + return res, fmt.Errorf("jscriptx/esm: 建目录 %s 失败: %w", dir, err) + } + if err := os.WriteFile(filepath.Join(dir, vendorEntry), out, 0o644); err != nil { + return res, fmt.Errorf("jscriptx/esm: 写 %s 失败: %w", vendorEntry, err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), minimalPkg(name, version), 0o644); err != nil { + return res, fmt.Errorf("jscriptx/esm: 写 package.json 失败: %w", err) + } + + return VendorResult{ + Name: name, Version: version, + Modules: modules, Bytes: len(out), Dir: dir, + }, nil +} + +// VendorResult 是一次摊平的结果,用来打日志。 +type VendorResult struct { + Name string + Version string + Modules int // 一共把多少个文件打了进去(含所有依赖) + Bytes int // 摊平后多大 + Dir string // 落到哪 +} + +// VendorOption 调整摊平的行为。 +type VendorOption func(*vendorOpts) + +type vendorOpts struct{ target api.Target } + +// WithVendorTarget 设置输出的 ECMAScript 版本,默认跟 Loader 一样是 ES2017。 +// +// 单独一个选项而不是复用 WithTarget:那个改的是 Loader,这个改的是摊平产物, +// 两者恰好该保持一致,但不是同一件事。 +func WithVendorTarget(t api.Target) VendorOption { + return func(o *vendorOpts) { o.target = t } +} + +const vendorEntry = "index.mjs" + +// vendorBundle 用 esbuild 把包和它的依赖打成一个 ESM 文件。 +// +// 入口不能直接写包名——esbuild 的 EntryPoints 是**文件路径**。所以造一段转发源码 +// 当 stdin 入口,包名放在 import 里,才会走正常的 node_modules 解析。 +func vendorBundle(fromDir, name string, target api.Target) ([]byte, int, error) { + // 先按"有默认导出"打。ESM 原生的包往往只有具名导出,那样 esbuild 会报 + // "No matching export ... for import default",退回去用只带具名导出的版本重打。 + for _, withDefault := range []bool{true, false} { + src := fmt.Sprintf("export * from %q\n", name) + if withDefault { + src += fmt.Sprintf("export { default } from %q\n", name) + } + + r := api.Build(api.BuildOptions{ + Stdin: &api.StdinOptions{ + Contents: src, + ResolveDir: fromDir, + Loader: api.LoaderJS, + }, + Bundle: true, + Format: api.FormatESModule, + Target: target, + Write: false, + Metafile: true, + MinifyWhitespace: true, + LogLevel: api.LogLevelSilent, + }) + + if len(r.Errors) == 0 { + return r.OutputFiles[0].Contents, countInputs(r.Metafile), nil + } + if withDefault && onlyDefaultMissing(r.Errors) { + continue + } + return nil, 0, fmt.Errorf("jscriptx/esm: 摊平 %s 失败: %s\n(依赖没装全的话,"+ + "先在 %s 跑一次 npm i)", name, r.Errors[0].Text, fromDir) + } + return nil, 0, fmt.Errorf("jscriptx/esm: 摊平 %s 失败", name) +} + +// onlyDefaultMissing 判断这批错误是不是只因为包没有默认导出。 +func onlyDefaultMissing(errs []api.Message) bool { + for _, e := range errs { + if !strings.Contains(e.Text, "default") { + return false + } + } + return true +} + +func countInputs(metafile string) int { + var m struct { + Inputs map[string]json.RawMessage `json:"inputs"` + } + if err := json.Unmarshal([]byte(metafile), &m); err != nil { + return 0 + } + return len(m.Inputs) +} + +// installedVersion 读已装好的那份 package.json 拿版本号,顺便确认包真的在。 +func installedVersion(fromDir, name string) (string, error) { + p := filepath.Join(fromDir, "node_modules", filepath.FromSlash(name), "package.json") + b, err := os.ReadFile(p) + if err != nil { + return "", fmt.Errorf("jscriptx/esm: %s 里没装 %s——先在那个目录跑 `npm i %s`: %w", + fromDir, name, name, err) + } + + var pkg struct { + Version string `json:"version"` + } + if err := json.Unmarshal(b, &pkg); err != nil { + return "", fmt.Errorf("jscriptx/esm: 解析 %s 失败: %w", p, err) + } + return pkg.Version, nil +} + +// minimalPkg 是摊平后那个包的 package.json。 +// +// **不能省**——esbuild 靠 main/module 定位入口,没有这个文件解析会直接失败。 +// +// 不写 exports 字段:一旦有,解析器就只认里面列出的子路径,而摊平之后所有东西都在 +// 一个文件里,子路径 import(`import x from "qs/lib/foo"`)本来就不成立了。 +func minimalPkg(name, version string) []byte { + b, _ := json.MarshalIndent(map[string]string{ + "name": name, + "version": version, + "type": "module", + "module": vendorEntry, + "main": vendorEntry, + }, "", " ") + return append(b, '\n') +} diff --git a/esm/vendor_test.go b/esm/vendor_test.go new file mode 100644 index 0000000..ab0b732 --- /dev/null +++ b/esm/vendor_test.go @@ -0,0 +1,193 @@ +package esm_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "git.fsdpf.net/go/jscriptx" + "git.fsdpf.net/go/jscriptx/esm" +) + +// writePkg 在 dir/node_modules/name 下摆一个包。 +func writePkg(t *testing.T, dir, name, version, entry, code string, deps map[string]string) { + t.Helper() + + p := filepath.Join(dir, "node_modules", filepath.FromSlash(name)) + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } + + pkg := map[string]any{"name": name, "version": version, "type": "module", "main": entry} + if len(deps) > 0 { + pkg["dependencies"] = deps + } + b, _ := json.Marshal(pkg) + if err := os.WriteFile(filepath.Join(p, "package.json"), b, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(p, entry), []byte(code), 0o644); err != nil { + t.Fatal(err) + } +} + +// 一棵装好的依赖树:主包 + 它依赖的包,两个都在 node_modules 顶层(npm 的平铺布局)。 +func installedTree(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + writePkg(t, dir, "dep-lib", "2.0.0", "index.js", + `export function helper(x) { return "dep:" + x }`, nil) + + writePkg(t, dir, "main-lib", "1.0.0", "index.js", ` + import { helper } from "dep-lib" + export function greet(x) { return helper(x) + "!" } + export default { greet } + `, map[string]string{"dep-lib": "^2.0.0"}) + + return dir +} + +// 摊平之后,产物里不能再有对依赖的 import——依赖得被内联进去。 +func TestVendor_依赖被打进单文件(t *testing.T) { + from := installedTree(t) + out := t.TempDir() + + res, err := esm.Vendor(from, "main-lib", out) + if err != nil { + t.Fatal(err) + } + if res.Version != "1.0.0" { + t.Errorf("版本 = %q", res.Version) + } + // 转发入口 + main-lib + dep-lib + if res.Modules < 2 { + t.Errorf("只打进 %d 个文件,依赖没被拉进来?", res.Modules) + } + + b, err := os.ReadFile(filepath.Join(res.Dir, "index.mjs")) + if err != nil { + t.Fatal(err) + } + src := string(b) + if strings.Contains(src, `"dep-lib"`) || strings.Contains(src, `'dep-lib'`) { + t.Errorf("产物里还留着对 dep-lib 的引用,没真正内联:\n%s", src) + } + if !strings.Contains(src, "dep:") { + t.Errorf("产物里没有依赖的代码:\n%s", src) + } +} + +// 摊平出来的东西要能直接被 Loader 用,脚本的 import 写法不变。 +func TestVendor_摊平后脚本能直接用(t *testing.T) { + from := installedTree(t) + + app := t.TempDir() + if _, err := esm.Vendor(from, "main-lib", filepath.Join(app, "node_modules")); 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 lib, { greet } from "main-lib" + export default class C { + Run(): string { return greet("a") + "/" + lib.greet("b") } + }`), 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 != "dep:a!/dep:b!" { + t.Errorf("Run = %#v", got) + } +} + +// 只有具名导出的包(ESM 原生的很多是这样)也要能摊平。 +// +// 转发源码里写 `export { default }` 会让 esbuild 报错,Vendor 得能退回去重打。 +func TestVendor_没有默认导出也能摊平(t *testing.T) { + dir := t.TempDir() + writePkg(t, dir, "named-only", "1.0.0", "index.js", + `export const answer = 42`, nil) + + res, err := esm.Vendor(dir, "named-only", t.TempDir()) + if err != nil { + t.Fatalf("没有默认导出就摊不平了: %v", err) + } + b, _ := os.ReadFile(filepath.Join(res.Dir, "index.mjs")) + if !strings.Contains(string(b), "42") { + t.Errorf("产物不对: %s", b) + } +} + +// 包没装就要给一句说得清的错,而不是一个 file not found。 +func TestVendor_包没装时说清楚该怎么办(t *testing.T) { + _, err := esm.Vendor(t.TempDir(), "not-installed", t.TempDir()) + if err == nil { + t.Fatal("该报错") + } + for _, want := range []string{"not-installed", "npm i"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("错误里该提到 %q: %v", want, err) + } + } +} + +// 写出来的 package.json 是给解析器找入口用的,少了它整个就不成立。 +func TestVendor_写出最小的package_json(t *testing.T) { + res, err := esm.Vendor(installedTree(t), "main-lib", t.TempDir()) + if err != nil { + t.Fatal(err) + } + + b, err := os.ReadFile(filepath.Join(res.Dir, "package.json")) + if err != nil { + t.Fatal(err) + } + var pkg map[string]any + if err := json.Unmarshal(b, &pkg); err != nil { + t.Fatal(err) + } + for k, want := range map[string]any{ + "name": "main-lib", "version": "1.0.0", + "module": "index.mjs", "main": "index.mjs", + } { + if pkg[k] != want { + t.Errorf("package.json[%s] = %#v, want %#v", k, pkg[k], want) + } + } + // 有 exports 的话解析器只认里面列的子路径,而摊平后子路径已经不存在了 + if _, ok := pkg["exports"]; ok { + t.Error("不该写 exports 字段") + } +}