用 goja 承载业务回调,让业务逻辑变更不必重新编译发布 Go 程序。脚本用
ESM + TypeScript 写,Go 侧按名字把它们当普通对象实例化并调用方法。
主要组成:
- Engine 编译脚本、管配置,公开 API 不暴露任何 goja 类型
- Script 一份编译好的脚本 + 它的 VM 池,热更新时整体顶替
- Instance 独占一个 VM 的实例,状态留在 JS 侧
- Caller 自定义调用约定,把脚本函数适配成 Go 侧要的签名
- Scope 让同一个 ctx 下的多个脚本共享 Go 侧对象
- Extension 扩展接口:给脚本添全局对象,配套 TS 类型
- Overlay 多层 Loader 叠加,后面的盖前面的
几个关键取舍:
- 源码一律先过 esbuild 打包成 ESM,再改写成立即执行函数。goja 不认
import/export,而业务脚本要能拆文件、用 TypeScript。
- VM 池化复用,但每个 VM 单线程。goja 的 Runtime 不是 goroutine 安全的。
- Go 侧函数返回的 error 在脚本里表现为抛异常,不占返回值位置。
- 脚本能看见的全局只有白名单放行的那些,且注入是惰性的——没读到的
全局根本不会被转换。
162 lines
6.0 KiB
Go
162 lines
6.0 KiB
Go
package jscriptx
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/evanw/esbuild/pkg/api"
|
||
)
|
||
|
||
// BundleOption 配置源码的打包方式。
|
||
type BundleOption func(*bundleOptions)
|
||
|
||
type bundleOptions struct {
|
||
resolveDir string
|
||
target api.Target
|
||
define map[string]string
|
||
nodePaths []string
|
||
alias map[string]string
|
||
}
|
||
|
||
// WithResolveDir 给源码一个解析 import 的基准目录。不设时源码里不能有 import
|
||
// (单段源码没有文件系统上下文,esbuild 解析不了相对路径)。
|
||
func WithResolveDir(dir string) BundleOption {
|
||
return func(o *bundleOptions) { o.resolveDir = dir }
|
||
}
|
||
|
||
// WithBundleTarget 设置输出的 ECMAScript 版本,默认 ES2017。
|
||
func WithBundleTarget(t api.Target) BundleOption {
|
||
return func(o *bundleOptions) { o.target = t }
|
||
}
|
||
|
||
// WithBundleDefine 设置编译期常量替换。
|
||
func WithBundleDefine(define map[string]string) BundleOption {
|
||
return func(o *bundleOptions) { o.define = define }
|
||
}
|
||
|
||
// WithNodePaths 指定额外的 node_modules 搜索目录,相当于 Node 的 NODE_PATH。
|
||
// 传进来的目录本身相当于一个 node_modules:包直接放在它下面,不要再套一层。
|
||
//
|
||
// 没有同时配 WithResolveDir 时,会拿当前工作目录当解析起点——esbuild 需要一个起点
|
||
// 才会启动模块解析,真正的查找仍然走这里给的目录。
|
||
func WithNodePaths(paths ...string) BundleOption {
|
||
return func(o *bundleOptions) { o.nodePaths = append(o.nodePaths, paths...) }
|
||
}
|
||
|
||
// WithAlias 把模块名映射到具体的文件或目录,绕过 node_modules 查找。
|
||
func WithAlias(alias map[string]string) BundleOption {
|
||
return func(o *bundleOptions) {
|
||
if o.alias == nil {
|
||
o.alias = map[string]string{}
|
||
}
|
||
for k, v := range alias {
|
||
o.alias[k] = v
|
||
}
|
||
}
|
||
}
|
||
|
||
// Bundle 把一段 ESM/TypeScript 源码打包成本库能直接执行的形式。
|
||
//
|
||
// goja 没有 ES module 支持,也不认识 TypeScript,所有脚本都要先过这一步。
|
||
// Engine.Compile 内部就是调它;自定义 Loader 如果返回的是原始源码,也用它处理。
|
||
//
|
||
// filename 是这段源码的文件名,有两个实际作用:**扩展名决定按什么语法解析**
|
||
// (.ts/.mts 按 TypeScript,.json 按 JSON,其余按 TypeScript——它是 JS 的超集,
|
||
// 普通 JS 照样能过),以及作为 sourcemap 和报错里显示的位置。
|
||
//
|
||
// 打包后的代码是自包含的 IIFE,模块导出挂在 ModuleGlobal 上,末尾补一句入口表达式。
|
||
// 因为产物是 IIFE,**脚本必须有 export**——没有导出的顶层代码会被当成死代码摇掉。
|
||
func Bundle(filename, source string, opts ...BundleOption) (string, error) {
|
||
o := &bundleOptions{target: api.ES2017}
|
||
for _, opt := range opts {
|
||
opt(o)
|
||
}
|
||
if o.resolveDir == "" && len(o.nodePaths) > 0 {
|
||
// NodePaths 是"额外去哪儿找",esbuild 仍然需要一个起点才会启动解析流程,
|
||
// 没有起点时连 NodePaths 都不查。用当前工作目录当锚点,真正的查找还是走
|
||
// NodePaths。(Alias 是直接映射,不受这个限制。)
|
||
o.resolveDir = "."
|
||
}
|
||
|
||
res := api.Build(api.BuildOptions{
|
||
Stdin: &api.StdinOptions{
|
||
Contents: source,
|
||
Sourcefile: filename,
|
||
Loader: loaderFor(filename),
|
||
ResolveDir: o.resolveDir,
|
||
},
|
||
Bundle: true,
|
||
// 用 ESM 格式而不是 IIFE:IIFE 会附带一整套 CommonJS interop helper,
|
||
// 每建一个 VM 都要重跑一遍。拿到 ESM 产物后由 FinalizeBundle 自己包成
|
||
// 立即执行函数,见 esmwrap.go。
|
||
Format: api.FormatESModule,
|
||
Target: o.target,
|
||
Define: o.define,
|
||
NodePaths: o.nodePaths,
|
||
Alias: o.alias,
|
||
Write: false,
|
||
Sourcemap: api.SourceMapInline,
|
||
SourcesContent: api.SourcesContentExclude,
|
||
LogLevel: api.LogLevelSilent,
|
||
})
|
||
if len(res.Errors) > 0 {
|
||
return "", bundleError(filename, res.Errors, o.resolveDir)
|
||
}
|
||
if len(res.OutputFiles) == 0 {
|
||
return "", fmt.Errorf("打包 %s 没有产出", filename)
|
||
}
|
||
return FinalizeBundle(string(res.OutputFiles[0].Contents)), nil
|
||
}
|
||
|
||
// FinalizeBundle 把 esbuild 的 ESM 产物改写成本库能直接执行的形式:一个立即执行函数,
|
||
// 完成值就是脚本的导出(default 优先,只有命名导出时是整个模块对象)。
|
||
//
|
||
// 自定义 Loader 自己调 esbuild 打包时,产物也要过这一步,格式才对得上。
|
||
// 怎么改写、为什么不直接用 esbuild 的 IIFE 格式,见 esmwrap.go。
|
||
func FinalizeBundle(code string) string {
|
||
const marker = "//# sourceMappingURL="
|
||
i := strings.LastIndex(code, marker)
|
||
if i < 0 {
|
||
return wrapESM(code)
|
||
}
|
||
// sourcemap 注释留在最后,包装只作用于代码部分
|
||
return wrapESM(strings.TrimRight(code[:i], "\n")) + code[i:]
|
||
}
|
||
|
||
// loaderFor 按文件名的扩展名挑解析方式。
|
||
func loaderFor(filename string) api.Loader {
|
||
switch {
|
||
case strings.HasSuffix(filename, ".ts"), strings.HasSuffix(filename, ".mts"):
|
||
return api.LoaderTS
|
||
case strings.HasSuffix(filename, ".json"):
|
||
return api.LoaderJSON
|
||
default:
|
||
// 没有扩展名或是 .js 时按 TS 解析:TS 是 JS 的超集,普通 JS 照样能过,
|
||
// 顺便让不带扩展名的脚本名也能写类型注解。
|
||
return api.LoaderTS
|
||
}
|
||
}
|
||
|
||
// bundleError 把 esbuild 的报错整理成一条带位置和出路的错误。
|
||
func bundleError(filename string, errs []api.Message, resolveDir string) error {
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "打包 %s 失败", filename)
|
||
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)
|
||
// 最常见的坑:单段源码里写了 import 却没有解析基准目录
|
||
if resolveDir == "" && strings.Contains(e.Text, "Could not resolve") {
|
||
b.WriteString("(这段源码没有解析 import 的基准目录:" +
|
||
"用 jscriptx/esm 子包按目录加载,或给 Compile 配 WithResolveDir)")
|
||
}
|
||
}
|
||
return fmt.Errorf("%s", b.String())
|
||
}
|