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 里记了命令。
This commit is contained in:
@@ -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` 只降级**语法**,不补**全局对象**。
|
||||
|
||||
+6
-1
@@ -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)
|
||||
|
||||
+184
@@ -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')
|
||||
}
|
||||
@@ -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 字段")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user