package esm import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "git.fsdpf.net/go/jscriptx/esm/npm" "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。 // // 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) { v := &vendorOpts{target: defaultTarget} for _, o := range opts { o(v) } var res VendorResult // name 可以带子路径(es-toolkit/compat)。版本从**根包**读, // 但打包入口和输出目录都用完整的名字。 pkg, _ := npm.SplitPath(name) version, err := installedVersion(fromDir, pkg) 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 } // Install 从注册表拉一个包(连同它的依赖),摊平后放进 outRoot。 // // 等于 npm.Fetch + Vendor 两步,只是中间那棵依赖树落在临时目录里,用完就删—— // **整条链不需要 node**。 // // res, err := esm.Install(ctx, "qs", "app/node_modules") // // qs v6.16.0 打进 47 个文件 -> 73.6 KB // // spec 的形式是 `名字` 或 `名字@范围`,不写范围取 latest。 // // 要装的包已经在本地装好了(或者碰上 npm.Fetch 支持不了的复合版本范围), // 就直接用 Vendor 指着那个目录。 func Install(ctx context.Context, spec, outRoot string, opts ...VendorOption) (VendorResult, error) { tmp, err := os.MkdirTemp("", "jscriptx-install-") if err != nil { return VendorResult{}, fmt.Errorf("jscriptx/esm: 建临时目录失败: %w", err) } defer os.RemoveAll(tmp) // spec 可能带子路径(es-toolkit/compat@^1):拉的是根包,摊的是子路径 name, rng := npm.SplitSpec(spec) pkg, _ := npm.SplitPath(name) fetchSpec := pkg if rng != "" { fetchSpec = pkg + "@" + rng } if _, err := npm.Fetch(ctx, tmp, fetchSpec); err != nil { return VendorResult{}, err } return Vendor(tmp, name, outRoot, opts...) } // 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') }