// 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/") }