Files
jscriptx/bundle.go
T
what 0627d49425 feat: 嵌入式 JS 脚本引擎核心
用 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 在脚本里表现为抛异常,不占返回值位置。
  - 脚本能看见的全局只有白名单放行的那些,且注入是惰性的——没读到的
    全局根本不会被转换。
2026-09-05 22:11:55 +08:00

162 lines
6.0 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 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 格式而不是 IIFEIIFE 会附带一整套 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())
}