Files
jscriptx/esm/vendor.go
T
what efb3141734 feat(esm): Vendor 把装好的 npm 包连同依赖摊平成单文件
解决的是「脚本要用第三方库,但目标机器上没有 node」。

npm 真正干的活是解析依赖树——读 semver 范围、查注册表定版本、递归、处理冲突。
这步绕不开,得在有 node 的机器上做一次。但做完之后依赖树就是死数据了,用
esbuild 摊平成一个文件,发布物里只带那一个就够:

    qs         v6.16.0   打进  47 个文件 -> 73.6 KB   (原 19 个包 1.7 MB)
    es-toolkit v1.52.0   打进 219 个文件 -> 51.9 KB

产物是最小的 node_modules 布局,脚本照常 import,写法完全不变。

两个实现细节:

  - 入口不能直接写包名,esbuild 的 EntryPoints 是文件路径。所以造一段转发
    源码当 stdin 入口,包名放进 import,才走正常的 node_modules 解析。
  - 转发源码里写 export { default } 时,只有具名导出的包会报错(ESM 原生的
    很多是这样),退回去用只带具名导出的版本重打一次。

打包目标从 Loader 里提成了共用常量:摊平出来的库必须跟脚本同一档,
否则库能打出脚本引擎跑不了的语法。

零依赖的包不用这个——直接下 tarball 解开就行,README 里记了命令。
2026-09-07 09:54:18 +08:00

185 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')
}