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:
+283
@@ -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 格式而不是 IIFE:IIFE 会附带一整套 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/")
|
||||
}
|
||||
Reference in New Issue
Block a user