Files
jscriptx/esm/bundler.go
T
what 6ce9b483fd 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 一遍太贵。
2026-09-05 22:13:57 +08:00

284 lines
8.5 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 让业务脚本能用 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/")
}