feat(esm): 按目录加载脚本、打包与热更新

把一个目录当脚本仓库:按路径寻址、esbuild 打包、内容变了自动重编。

  - Loader    扫目录建索引,Load(name) 给出打好包的源码和版本号
  - bundler   esbuild 的封装。ESM 格式而不是 IIFE——IIFE 会附带一整套
              CommonJS interop helper,每建一个 VM 都要重跑一遍
  - plugin    把扩展的 TS 模块变成可以 import 的虚拟模块,磁盘上没有文件
  - typings   把这些虚拟模块的类型按 node_modules 布局落盘,编辑器才认识

esbuild 原生实现了 Node 的模块解析,所以脚本能直接 import node_modules
里的第三方库。写出来的类型文件是 index.ts 而不是 index.d.ts:扩展给的是
真正的模块源码,里面可能带实现,声明文件里不允许有实现。

node_modules 不参与热更新的版本计算——依赖包是装出来的,改动总伴随显式的
安装动作,而真实的 npm 包动辄上千个文件,每次取脚本 stat 一遍太贵。
This commit is contained in:
2026-09-05 22:13:57 +08:00
parent 7fb8d0f966
commit 6ce9b483fd
34 changed files with 2403 additions and 0 deletions
+283
View File
@@ -0,0 +1,283 @@
// Package esm 让业务脚本能用 ESM 语法和 TypeScript 编写:加载一个目录,
// 用 esbuild 把每个入口连同它 import 的模块打包成 goja 能直接执行的代码。
//
// goja 本身没有 ES module 支持(export/import 在它的 token 表里还是 futureKeyword),
// 也不认识 TypeScript。打包这一步就是用来抹平这个差距的——源码怎么舒服怎么写,
// 交给 goja 的永远是打包好的、自包含的一份代码。
//
// 典型用法:
//
// loader, err := esm.NewLoader("app")
// e, err := jscriptx.New(
// jscriptx.WithLoader(loader),
// jscriptx.WithAutoReload(true), // 改了 .ts 不用重启
// jscriptx.WithGlobals(bindings.Safe()),
// )
// ctrl, err := e.New(ctx, "PkgVersion/PkgImportController")
// got, err := ctrl.Call(ctx, "Init")
//
// 对应的目录结构:
//
// app/
// ├── PkgVersion/
// │ ├── PkgImportController.ts
// │ └── PkgManifestController.ts
// └── Resource/
// ├── ResCreateController.ts
// └── ResExecuter.ts ← 可以被同目录的 controller import
//
// # 共享只到代码层面,不到状态层面
//
// 多个 controller import 同一个模块时,各自拿到的是独立的副本:模块被内联进各自的
// 产物是一层原因,更根本的是每个 controller 实例独占一个 VM,VM 之间不共享任何 JS 状态。
//
// 公共的纯函数、工具类、常量、类型 → 随便 import
// 公共的状态(缓存、计数器、连接) → 各自一份,必须由 Go 侧注入
//
// 这是那种低负载下看不出问题、一上量才暴露的坑,务必注意。
package esm
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"git.fsdpf.net/go/jscriptx"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
"github.com/evanw/esbuild/pkg/api"
)
// bundle 是一个入口的打包结果。
type bundle struct {
name string // 按路径的名字,如 PkgVersion/PkgImportController
code string // 打包产物(自包含的立即执行函数)
inputs []string // 参与打包的源文件(相对 dir),用来算版本号
version string
}
// buildAll 扫描目录并打包所有入口。一次性打包所有入口比逐个打包更快,
// esbuild 内部会复用已解析的模块。
func (l *Loader) buildAll() (map[string]*bundle, error) {
entries, err := l.findEntries()
if err != nil {
return nil, err
}
if len(entries) == 0 {
return map[string]*bundle{}, nil
}
abs, err := filepath.Abs(l.dir)
if err != nil {
return nil, err
}
// esbuild 内部会把符号链接解析掉(macOS 上 /var/... → /private/var/...),
// AbsWorkingDir/Outbase 必须用同一种形式,否则 Outbase 匹配不上,
// 产物路径会被剥掉目录前缀。
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
abs = resolved
}
result := api.Build(api.BuildOptions{
EntryPoints: entries,
AbsWorkingDir: abs,
// Outbase 必须显式钉在根目录:不设时 esbuild 拿所有入口的公共父目录当基准,
// 入口恰好都在同一个子目录下时产物路径会被剥掉前缀,名字就对不上了。
Outbase: abs,
Bundle: true,
// ESM 格式而不是 IIFEIIFE 会附带一整套 CommonJS interop helper
// 每建一个 VM 都要重跑一遍。FinalizeBundle 会把产物改写成立即执行函数。
Format: api.FormatESModule,
Outdir: outDir,
Write: false,
Metafile: true,
Target: l.target,
Sourcemap: api.SourceMapInline,
SourcesContent: api.SourcesContentExclude,
LogLevel: api.LogLevelSilent,
Define: l.define,
NodePaths: l.nodePaths,
Alias: l.alias,
Plugins: []api.Plugin{extensionPlugin(l.exts)},
Loader: map[string]api.Loader{
".js": api.LoaderJS,
".ts": api.LoaderTS,
".mjs": api.LoaderJS,
".json": api.LoaderJSON,
},
})
if len(result.Errors) > 0 {
return nil, buildError(result.Errors)
}
inputsOf, err := parseMetafile(result.Metafile)
if err != nil {
return nil, err
}
out := make(map[string]*bundle, len(result.OutputFiles))
for _, f := range result.OutputFiles {
// 不能用 filepath.Rel(abs, f.Path)esbuild 会把符号链接解析掉
// macOS 上 /var/... 变成 /private/var/...),算出来的相对路径是错的。
// 直接从产物路径里截 outDir 之后的部分。
rel, ok := afterOutDir(f.Path)
if !ok {
continue
}
name := toName(rel)
b := &bundle{
name: name,
code: jscriptx.FinalizeBundle(string(f.Contents)),
inputs: inputsOf[path.Join(outDir, rel)],
}
b.version = l.versionOf(b.inputs)
out[name] = b
}
return out, nil
}
// findEntries 按 glob 找出入口文件,返回相对 dir 的路径。
func (l *Loader) findEntries() ([]string, error) {
var out []string
err := filepath.WalkDir(l.dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if d.Name() == "node_modules" || strings.HasPrefix(d.Name(), ".") {
if p != l.dir {
return fs.SkipDir
}
}
return nil
}
rel, err := filepath.Rel(l.dir, p)
if err != nil {
return nil
}
rel = filepath.ToSlash(rel)
if !isSource(rel) || !l.matches(rel) {
return nil
}
out = append(out, rel)
return nil
})
if err != nil {
return nil, fmt.Errorf("扫描目录 %s 失败: %w", l.dir, err)
}
sort.Strings(out)
return out, nil
}
// matches 判断相对路径是否命中入口 glob。
func (l *Loader) matches(rel string) bool {
for _, g := range l.globs {
if ok, _ := path.Match(g, rel); ok {
return true
}
}
return false
}
func isSource(rel string) bool {
switch strings.ToLower(filepath.Ext(rel)) {
case ".js", ".ts", ".mjs":
return true
}
return false
}
// toName 把产物的相对路径变成脚本名:去掉扩展名,统一用 /。
func toName(rel string) string {
rel = filepath.ToSlash(rel)
return strings.TrimSuffix(rel, path.Ext(rel))
}
// versionOf 根据参与打包的源文件的修改时间和大小算一个版本号,
// 任何一个依赖改了版本号就会变,热更新据此触发重新编译。
//
// node_modules 里的文件不算在内:依赖包是装出来的,改动总伴随显式的安装/升级动作,
// 而真实的 npm 包动辄成百上千个文件,每次取脚本都 stat 一遍太贵。
// 依赖装完或换版本后调 Rebuild 让它重新打包。
func (l *Loader) versionOf(inputs []string) string {
h := sha256.New()
for _, in := range inputs {
if isVendored(in) {
continue
}
st, err := os.Stat(filepath.Join(l.dir, filepath.FromSlash(in)))
if err != nil {
fmt.Fprintf(h, "%s|missing\n", in)
continue
}
fmt.Fprintf(h, "%s|%d|%d\n", in, st.ModTime().UnixNano(), st.Size())
}
return hex.EncodeToString(h.Sum(nil)[:8])
}
// parseMetafile 从 esbuild 的 metafile 里取出每个产物用到了哪些源文件。
func parseMetafile(meta string) (map[string][]string, error) {
if meta == "" {
return map[string][]string{}, nil
}
var m struct {
Outputs map[string]struct {
Inputs map[string]struct {
BytesInOutput int `json:"bytesInOutput"`
} `json:"inputs"`
} `json:"outputs"`
}
if err := json.Unmarshal([]byte(meta), &m); err != nil {
return nil, fmt.Errorf("解析 esbuild metafile 失败: %w", err)
}
out := make(map[string][]string, len(m.Outputs))
for outPath, o := range m.Outputs {
ins := make([]string, 0, len(o.Inputs))
for in := range o.Inputs {
ins = append(ins, in)
}
sort.Strings(ins)
out[filepath.ToSlash(outPath)] = ins
}
return out, nil
}
// buildError 把 esbuild 的报错整理成一条带位置的错误。
func buildError(errs []api.Message) error {
var b strings.Builder
b.WriteString("打包失败")
for i, e := range errs {
if i >= 5 {
fmt.Fprintf(&b, "\n …还有 %d 条错误", len(errs)-i)
break
}
b.WriteString("\n ")
if loc := e.Location; loc != nil {
fmt.Fprintf(&b, "%s:%d:%d: ", loc.File, loc.Line, loc.Column)
}
b.WriteString(e.Text)
}
return fmt.Errorf("%s", b.String())
}
// afterOutDir 从产物的绝对路径里截出 outDir 之后的部分。
func afterOutDir(p string) (string, bool) {
slash := filepath.ToSlash(p)
marker := "/" + outDir + "/"
i := strings.LastIndex(slash, marker)
if i < 0 {
return "", false
}
return slash[i+len(marker):], true
}
// isVendored 判断一个源文件是不是来自 node_modules。
func isVendored(p string) bool {
p = filepath.ToSlash(p)
return strings.HasPrefix(p, "node_modules/") || strings.Contains(p, "/node_modules/")
}