约定:文件名 = 主类型名;同一个类型要拆多个文件时用 类型_子项.go。
engine.go 355 → 231 行。原来混了三件不相干的事
engine_globals.go ← bind / lazyGlobal / freeze / defineReadOnly(96 行)
它是「Go 值 → 只读 JS 全局」的转换层,跟脚本缓存毫无关系
engine_console.go ← console.go,跟上面是同一主题
script.go 325 → 100 行,只留公开方法
script_vm.go ← VM 的取、还、装载。上个 commit 合一的三条路径现在住一起
script_static.go ← static.go
errors.go 364 → 150 行
errors_goja.go ← goja 错误的翻译层
errors_hints.go ← missingGlobalHint,一份 JS 运行时知识库,跟错误分类是
两回事;拆出来之后 missing_global_test.go 才有对应源文件
bundle_finalize.go ← esmwrap.go
bundle.go 收下 validIdent / hashVersion(原来住在 engine.go)
Engine.New 原来排在所有私有函数之后,挪到导出方法那一段。
classify 83 → 54 行:四个 errors.As 分支各手搓一个 8 字段的 &Error{},脚本上下文
那三行重复了 4 遍,抽出 gojaError 构造器。
顺带修四处注释漂移:
Session/会话 代码里叫 Instance,注释里大面积残留。engine_globals.go 那条
「注入会话全局对象失败」还是用户可见文案,而公开 API 里根本
没有「会话」这个概念
Extension 文档示例写 Module() string,接口是 Module() (path, source string)。
这是唯一一段教人写扩展的文档,照抄编译不过
doc.go 的 freeze 说白名单「逐层拷贝成只读对象,不会跨 VM 共享可变的 Go map」,
但那只对 map[string]any 成立。结构体指针和 slice 是**共享同一个
对象**的——扩展走的正是这条路,不该被当成隔离保证
232 lines
6.6 KiB
Go
232 lines
6.6 KiB
Go
package jscriptx
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"runtime"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/dop251/goja"
|
||
)
|
||
|
||
const (
|
||
// DefaultTimeout 是单次脚本调用的默认时限,超过就中断脚本。
|
||
DefaultTimeout = 5 * time.Second
|
||
// DefaultMaxCallStackSize 限制脚本的调用栈深度,防止递归打爆 Go 栈。
|
||
DefaultMaxCallStackSize = 2000
|
||
)
|
||
|
||
// Engine 是脚本引擎,持有全局白名单、执行策略和脚本缓存。
|
||
// 一个进程通常只需要一个 Engine,它本身并发安全。
|
||
type Engine struct {
|
||
globals map[string]any
|
||
timeout time.Duration
|
||
maxVMs int
|
||
maxStack int
|
||
logger *slog.Logger
|
||
|
||
loader Loader
|
||
autoReload bool
|
||
|
||
// WithLoader 收下的层,New 里合成 loader;Option 没有出错的地方,
|
||
// 校验只能推迟到那时候。
|
||
loaderLayers []Loader
|
||
|
||
bundleOpts []BundleOption
|
||
|
||
mu sync.Mutex
|
||
scripts map[string]*Script
|
||
closed bool
|
||
}
|
||
|
||
// New 创建引擎。全局白名单里的名字不合法时返回错误。
|
||
func New(opts ...Option) (*Engine, error) {
|
||
e := &Engine{
|
||
globals: map[string]any{},
|
||
timeout: DefaultTimeout,
|
||
maxVMs: runtime.GOMAXPROCS(0) * 2,
|
||
maxStack: DefaultMaxCallStackSize,
|
||
logger: slog.Default(),
|
||
scripts: map[string]*Script{},
|
||
}
|
||
for _, opt := range opts {
|
||
opt(e)
|
||
}
|
||
if e.maxVMs < 1 {
|
||
e.maxVMs = 1
|
||
}
|
||
if err := e.resolveLoader(); err != nil {
|
||
return nil, err
|
||
}
|
||
for name := range e.globals {
|
||
if !validIdent(name) {
|
||
return nil, fmt.Errorf("%w: 全局名 %q 不是合法的 JS 标识符", ErrBadGlobal, name)
|
||
}
|
||
}
|
||
return e, nil
|
||
}
|
||
|
||
// resolveLoader 把 WithLoader 收下的层合成一个 Loader。
|
||
func (e *Engine) resolveLoader() error {
|
||
switch len(e.loaderLayers) {
|
||
case 0:
|
||
return nil
|
||
case 1:
|
||
e.loader = e.loaderLayers[0]
|
||
if e.loader == nil {
|
||
return errors.New("jscriptx: WithLoader 收到 nil")
|
||
}
|
||
default:
|
||
o, err := Overlay(e.loaderLayers...)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
e.loader = o
|
||
}
|
||
e.loaderLayers = nil
|
||
return nil
|
||
}
|
||
|
||
// Compile 用一段 ESM/TypeScript 源码注册脚本:先经 esbuild 打包,再交给 goja 编译,
|
||
// 结果进缓存,之后 Script(name) 能取到。同名脚本会被替换,旧的 VM 池随即释放
|
||
// (已经借出去的调用不受影响)。
|
||
//
|
||
// 源码必须有 export——产物是 IIFE,没有导出的顶层代码会被当死代码摇掉。
|
||
// 源码里要写 import 的话,得用 jscriptx/esm 子包按目录加载,或者配 WithResolveDir
|
||
// 给一个解析基准目录。
|
||
func (e *Engine) Compile(name, source string) (*Script, error) {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
if e.closed {
|
||
return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭")
|
||
}
|
||
return e.compileLocked(name, source, hashVersion(source), false)
|
||
}
|
||
|
||
// Script 按名字取脚本:命中缓存直接返回;没命中就走 Loader 加载并编译。
|
||
// 打开了 WithAutoReload 时,每次都会跟 Loader 核对版本号,变了就重编译。
|
||
func (e *Engine) Script(name string) (*Script, error) {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
if e.closed {
|
||
return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭")
|
||
}
|
||
|
||
cached, ok := e.scripts[name]
|
||
if ok && !e.autoReload {
|
||
return cached, nil
|
||
}
|
||
if e.loader == nil {
|
||
if ok {
|
||
return cached, nil
|
||
}
|
||
return nil, newError(KindNotFound, name, "", ErrScriptNotFound,
|
||
"没有配置 Loader,也没有通过 Compile 注册过这个脚本")
|
||
}
|
||
return e.loadLocked(name, cached)
|
||
}
|
||
|
||
// Reload 强制重新从 Loader 加载并编译,不管版本号有没有变。
|
||
func (e *Engine) Reload(name string) (*Script, error) {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
if e.closed {
|
||
return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭")
|
||
}
|
||
if e.loader == nil {
|
||
return nil, newError(KindLoad, name, "", ErrScriptNotFound, "没有配置 Loader,无法重新加载")
|
||
}
|
||
return e.loadLocked(name, nil)
|
||
}
|
||
|
||
// Invalidate 把脚本从缓存里剔除并释放它的 VM 池,下次 Script 会重新加载。
|
||
func (e *Engine) Invalidate(name string) {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
if s, ok := e.scripts[name]; ok {
|
||
delete(e.scripts, name)
|
||
s.Close()
|
||
}
|
||
}
|
||
|
||
// Names 返回当前缓存里的脚本名。
|
||
func (e *Engine) Names() []string {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
out := make([]string, 0, len(e.scripts))
|
||
for name := range e.scripts {
|
||
out = append(out, name)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Close 关闭引擎,释放所有脚本的 VM 池。之后再取脚本会报 ErrClosed。
|
||
func (e *Engine) Close() {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
e.closed = true
|
||
for name, s := range e.scripts {
|
||
s.Close()
|
||
delete(e.scripts, name)
|
||
}
|
||
}
|
||
|
||
// New 是 Script(name) + Script.New(ctx, args...) 的快捷方式:按名字取脚本,
|
||
// 实例化它导出的 class,构造参数直接传给 constructor。
|
||
//
|
||
// ctrl, err := e.New(ctx, "PkgVersion/PkgImportController")
|
||
// defer ctrl.Close()
|
||
// got, err := ctrl.Call(ctx, "Init")
|
||
func (e *Engine) New(ctx context.Context, name string, ctorArgs ...any) (*Instance, error) {
|
||
s, err := e.Script(name)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.New(ctx, ctorArgs...)
|
||
}
|
||
|
||
// compileLocked 编译并替换缓存里的同名脚本。调用方必须持有 e.mu。
|
||
//
|
||
// prepared 为 true 表示源码已经过打包(Loader 自己做过了),跳过这一步——
|
||
// 对已经是 IIFE 的产物再打包一次是纯浪费。
|
||
func (e *Engine) compileLocked(name, source, version string, prepared bool) (*Script, error) {
|
||
if !prepared {
|
||
// 脚本名同时当文件名传给打包器:扩展名(.ts/.json)决定按什么语法解析,
|
||
// 也是 sourcemap 和报错里显示的位置。
|
||
bundled, err := Bundle(name, source, e.bundleOpts...)
|
||
if err != nil {
|
||
return nil, newError(KindCompile, name, "", err, "脚本打包失败")
|
||
}
|
||
source = bundled
|
||
}
|
||
|
||
// 传 false = 不强制严格模式。
|
||
//
|
||
// 注意产物里**没有** "use strict":esbuild 输出 ESM 格式时不加这个指令,
|
||
// wrapESM 包成 IIFE 时也没加。所以脚本跑在非严格模式下,后果之一是给只读
|
||
// 全局赋值会**静默失败**而不是抛错(见 lazyglobal_test.go 的断言)。
|
||
//
|
||
// 想改成严格模式就把这里传 true,但那是行为变更:脚本里任何依赖非严格语义的
|
||
// 写法(给未声明变量赋值、with、八进制字面量……)都会开始报错。
|
||
prog, err := goja.Compile(name, source, false)
|
||
if err != nil {
|
||
return nil, newError(KindCompile, name, "", err, "脚本编译失败")
|
||
}
|
||
|
||
s := &Script{
|
||
engine: e,
|
||
name: name,
|
||
version: version,
|
||
prog: prog,
|
||
pool: make(chan *vmHandle, e.maxVMs),
|
||
}
|
||
if old, ok := e.scripts[name]; ok {
|
||
old.Close()
|
||
}
|
||
e.scripts[name] = s
|
||
return s, nil
|
||
}
|