Files
jscriptx/engine.go
T
what 7e1893b246 refactor: 文件按主类型划分,一个 struct 一个功能
约定:文件名 = 主类型名;同一个类型要拆多个文件时用 类型_子项.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 是**共享同一个
                 对象**的——扩展走的正是这条路,不该被当成隔离保证
2026-09-10 15:28:59 +08:00

232 lines
6.6 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 (
"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
}