Files
jscriptx/README.md
T
what efb3141734 feat(esm): Vendor 把装好的 npm 包连同依赖摊平成单文件
解决的是「脚本要用第三方库,但目标机器上没有 node」。

npm 真正干的活是解析依赖树——读 semver 范围、查注册表定版本、递归、处理冲突。
这步绕不开,得在有 node 的机器上做一次。但做完之后依赖树就是死数据了,用
esbuild 摊平成一个文件,发布物里只带那一个就够:

    qs         v6.16.0   打进  47 个文件 -> 73.6 KB   (原 19 个包 1.7 MB)
    es-toolkit v1.52.0   打进 219 个文件 -> 51.9 KB

产物是最小的 node_modules 布局,脚本照常 import,写法完全不变。

两个实现细节:

  - 入口不能直接写包名,esbuild 的 EntryPoints 是文件路径。所以造一段转发
    源码当 stdin 入口,包名放进 import,才走正常的 node_modules 解析。
  - 转发源码里写 export { default } 时,只有具名导出的包会报错(ESM 原生的
    很多是这样),退回去用只带具名导出的版本重打一次。

打包目标从 Loader 里提成了共用常量:摊平出来的库必须跟脚本同一档,
否则库能打出脚本引擎跑不了的语法。

零依赖的包不用这个——直接下 tarball 解开就行,README 里记了命令。
2026-09-07 09:54:18 +08:00

803 lines
32 KiB
Markdown
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.
# jscriptx
用嵌入式 JS 引擎承载业务回调,让业务逻辑变更不必重新编译发布 Go 程序。
脚本用 **ESM + TypeScript** 编写、按目录组织,Go 侧按路径把它们当普通对象实例化并调用方法。
底层是 [goja](https://github.com/dop251/goja)(纯 Go 的 JS 引擎)+
[esbuild](https://github.com/evanw/esbuild)(纯 Go 的打包器),**依赖就这两个**,公开 API
不暴露任何 goja 类型。
goja 的反射会自动包装 Go 对象,框架里的链式 API 在脚本里照原样写,不需要为每个方法写胶水代码:
```ts
resource.GetDBTable(user, req.WithPermission(req.ResAll))
.Where(db.C("name").Eq("测试产品"))
.Select("name", "cost_price")
```
## 文档
| | |
| --- | --- |
| [docs/call-flow.md](docs/call-flow.md) | 执行流程:源码怎么变成可执行的、一次调用经过哪些环节、错误怎么分类 |
| [docs/lifecycle.md](docs/lifecycle.md) | 生命周期:Engine / Script / VM / Instance / 作用域各活多久,状态该放哪 |
## 快速开始
```
app/
├── package.json npm 配置
├── tsconfig.json esbuild 自动往上找到并生效
├── node_modules/ 依赖
└── src/ ← NewLoader 指这里
├── PkgVersion/
│ └── PkgImportController.ts
└── Resource/
├── ResCreateController.ts
└── ResExecuter.ts ← 可以被同目录的 controller import
```
```ts
// app/src/Resource/ResCreateController.ts
import ResExecuter from "./ResExecuter"
export default class ResCreateController {
private ex = new ResExecuter()
private count = 0
constructor(private resName: string) {}
Init(): string { return "ready: " + this.resName }
Store(cfg: string): string { this.count++; return this.ex.Store(cfg) }
}
```
```go
loader, err := esm.NewLoader("app/src")
e, err := jscriptx.New(
jscriptx.WithLoader(loader),
jscriptx.WithAutoReload(true), // 改了 .ts 不用重启
jscriptx.WithGlobals(myWhitelist), // 脚本能看见什么
jscriptx.WithTimeout(3*time.Second),
)
obj, err := e.New(ctx, "Resource/ResCreateController", "产品")
defer obj.Close()
got, err := obj.Call(ctx, "Init")
```
---
# 脚本怎么写
## 为什么需要打包
goja 的 ES6+ 支持相当完整——class(含私有字段、静态块)、async/await、generator、
解构、可选链、Proxy、BigInt 实测都能直接跑。但它**没有 ES module**`import`/`export`
在 goja 的 token 表里是保留字,解析阶段就报错;TypeScript 更不在它的职责范围内。
esbuild 只补这两件事:
| | goja | 靠 esbuild |
| --- | --- | --- |
| class / async / 解构 / 可选链… | ✅ 原生 | 不需要 |
| `import` / `export` | ❌ 保留字 | ✅ 打包时内联,产物里一个不剩 |
| TypeScript | ❌ | ✅ 转译掉 |
| node_modules 公共库 | ❌ | ✅ 解析 + 内联 |
交给 goja 的最终产物是**普通 JS 语法**,不含任何模块系统的东西。
## 目录与入口
默认入口规则是**第一层子目录下的所有 `.js`/`.ts`**`*/*.js``*/*.ts`),按 `目录/文件名` 寻址。
`import` 的模块不必是入口;想把辅助模块挡在入口之外,用 `esm.WithGlobs("*/*Controller.ts")`
收窄——挡在入口外不影响它被 import。
**加载器指向 `src` 而不是 `app`**,寻址就还是干净的 `Resource/ResCreateController`
不带 `src/` 前缀;`node_modules``tsconfig.json` 在上层,esbuild 逐级往上都能找到。
`tsconfig.json` 是完整生效的,实测过这两项:
```jsonc
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@lib/*": ["./src/lib/*"] }, // 路径别名
"experimentalDecorators": true // 装饰器,@Route("/api") 之类
}
}
```
## 多层覆盖
`WithLoader` 可以给多个 Loader,它们叠成一层层的,**后面的盖前面的**——
取脚本时从最后一层往前找,谁先有就用谁的:
```go
base, _ := esm.NewLoader("app/src")
custom, _ := esm.NewLoader("custom/src")
e, _ := jscriptx.New(jscriptx.WithLoader(base, custom)) // custom 盖 base
// custom/src/Resource/ResCreateController.ts 会盖掉 app/src 里的同名脚本
obj, _ := e.New(ctx, "Resource/ResCreateController")
```
把「定制层」放最后,业务侧放一份同名脚本就能改写默认实现,不用动被覆盖的那一份。
撤掉定制层的文件、重新打包后会自动落回下面那层。
每层是**独立的 Loader**,各有各的配置——入口规则、目标版本、`node_modules` 位置、
扩展模块都可以不一样;来源也不必相同,一层来自磁盘目录、另一层来自数据库都行:
```go
disk, _ := esm.NewLoader("app/src")
db := jscriptx.PreparedFunc(func(name string) (string, string, error) {
row, err := queryScript(name) // 找不到时返回包着 ErrScriptNotFound 的错误
if err != nil {
return "", "", err
}
bundled, err := jscriptx.Bundle(name, row.Source) // 自己打包
if err != nil {
return "", "", err
}
return bundled, row.UpdatedAt, nil
})
e, _ := jscriptx.New(jscriptx.WithLoader(disk, db)) // 数据库里的脚本盖住磁盘上的
```
各层的 `Prepared` 必须一致(要么都自己打好包,要么都交出原始源码),否则 `New` 报错——
引擎只能对整个 Loader 做一次判断,没法分脚本区别对待。上面那段就是靠 `PreparedFunc`
+ `Bundle` 把数据库那层也变成「打好包的」,好跟 `esm.Loader` 那层对齐。
### 点名要哪一层
给层贴上版本标签,调用时就能指定要哪个版本的实现:
```go
v1, _ := esm.NewLoader("app/v1/src", esm.WithVersion("v1"))
v2, _ := esm.NewLoader("app/v2/src", esm.WithVersion("v2"))
e, _ := jscriptx.New(jscriptx.WithLoader(v1, v2))
e.New(ctx, "Resource/ResCreateController") // 不点名:上层盖下层,拿到 v2
e.New(ctx, "Resource/ResCreateController@v1") // 点名:要 v1 那一层的
```
点名是**只认这一层**:那层没有这个脚本就直接报 `ErrScriptNotFound`,不会掉到别的层去——
不然点名要 v1 却跑了 v2 的实现,比报错难查得多。
各版本在引擎里是各自独立的缓存条目,热更新互不干扰。标签之间不能重名,
标签里也不能带 `@`
不是 `esm.Loader` 的层用 `jscriptx.Tag` 贴标签:
```go
jscriptx.WithLoader(disk, jscriptx.Tag("hotfix", dbLoader)) // Load("Foo/Bar@hotfix")
```
不经过 Engine 也能直接用这套叠放:`jscriptx.Overlay(base, custom)` 返回一个
`*OverlayLoader`,除了 `Load` 还有 `Names()`(汇总各层入口,去重排序)、
`Versions()`(各层的版本标签)和 `Rebuild()`(挨个让各层重建)。
## 公共库:node_modules
esbuild 原生实现了 Node 的模块解析算法:
```ts
import { upper } from "tinylib" // 裸模块名
import { money } from "@fsdpf/util" // scoped 包,入口可以是 .ts
import ResExecuter from "./ResExecuter" // 相对 import 混用没问题
```
从入口文件所在目录**逐级往上**找 `node_modules`,所以放在 `app/node_modules` 或它的任意
上层目录都能找到。公共库在完全不相干的路径下时用 `esm.WithNodePaths("/opt/fsdpf/js-libs")`
指过去(传进去的目录本身相当于一个 `node_modules`,包直接放在它下面,不要再套一层);
要把某个模块名钉死到一份实现上用 `esm.WithAlias`
两个细节:`node_modules` 里的文件不会被当成入口;它们也**不参与热更新的版本计算**——
依赖包是装出来的,改动总伴随显式的安装动作,而真实的 npm 包动辄上千个文件,每次取脚本
stat 一遍太贵。装完或换版本后调 `loader.Rebuild()`
> esbuild 只**解析** `node_modules`,不下载。包怎么进去(npm/pnpm 安装、直接提交进仓库)由你决定。
### 目标机器上没有 node 怎么办
`node_modules` 对 esbuild 来说就是一个约定好的目录布局,谁摆进去的不重要。所以:
**零依赖的包**直接下 tarball 就行,全程只用 curl 和 tar:
```sh
curl -sL https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz -o /tmp/x.tgz
mkdir -p app/node_modules/es-toolkit
tar -xzf /tmp/x.tgz -C app/node_modules/es-toolkit --strip-components=1
```
npm 的 tarball 里固定是一个 `package/` 目录,`--strip-components=1` 剥掉就是标准布局。
**有依赖的包**得先解析依赖树——那正是 npm 真正在干的活(读 semver 范围、查注册表
定版本、递归、处理冲突),手工做不现实。但这一步只需要在**有 node 的机器上做一次**,
之后用 `Vendor` 把整棵树摊平成一个文件:
```go
res, err := esm.Vendor(".", "qs", "app/node_modules")
// qs v6.16.0 打进 47 个文件 -> 73.6 KB(原 node_modules 19 个包 1.7 MB
```
产物是最小布局,脚本照常 `import qs from "qs"`
```
app/node_modules/qs/
├── index.mjs 依赖全内联
└── package.json 只为让解析器找到入口
```
**发布物里带上这两个文件,目标机器不需要 node、不需要 npm、不需要联网。**
两个 `package.json` 别混:**脚本根目录那个不需要**(esbuild 不看它,`type: module`
也不影响);**包自己那个必需**——解析器靠 `main`/`module` 定位入口,拿掉就打包失败。
零依赖的包那个跟着 tarball 自带,`Vendor` 摊平的那个由它生成。
### 挑第三方库要看两件事
**一、有没有用 goja 没有的全局。** esbuild 的 `target` 只降级**语法**,不补**全局对象**。
所以库的语法多新都无所谓,但只要它调了下面这些,就是运行期才炸:
```
goja 有: Promise Proxy BigInt Object.fromEntries Object.hasOwn
Array#at / #flat / #findLast String#replaceAll RegExp#matchAll
goja 没有:setTimeout setInterval queueMicrotask ← 没有事件循环
structuredClone Object.groupBy
TextEncoder Intl WeakRef Symbol.asyncIterator
```
碰到这些时本库会在 `ReferenceError` 后面补一句说明,不至于让人以为是打包漏了依赖。
**二、能不能 tree-shake。** 每建一个 VM 都要把 bundle 跑一遍,体积直接变成并发成本。
同样一组功能(groupBy / sum / chunk / get / isString / cloneDeep)实测下来:
| 写法 | 打包产物 | 建 VM |
|---|---|---|
| 手写原生 | 1.8 KB | 0.02 ms |
| `radash` | 3.9 KB | 0.04 ms |
| `es-toolkit` | 7.0 KB | 0.03 ms |
| `es-toolkit/compat`lodash 兼容 API | 48 KB | 0.11 ms |
| `ramda` | 70 KB | 0.48 ms |
| `lodash-es` | 132 KB | 0.66 ms |
| **`lodash`CommonJS** | **419 KB** | **2.69 ms** |
**CommonJS 包摇不动**——`import { isString } from "lodash"` 只挑一个函数,产物照样 411 KB。
要用 lodash 就装 `lodash-es`,或者只从子路径 import`lodash/isString`8 KB)。
`remeda``clone` 依赖 `structuredClone`,在 goja 里直接报错。`ramda` 是唯一整包
零定时器引用的,但体积是 es-toolkit 的十倍、API 是柯里化风格,除非确实需要那套写法,
否则不值得。
## 入口形态
产物是自包含的立即执行函数,所以**脚本必须有 `export`**——没有导出的顶层代码会被当死代码
摇掉(真忘了写会得到一句明确的报错,不是莫名其妙的 `undefined`)。
```ts
export default class DeviceHandler { // 由本库实例化,构造参数从 Go 侧传
constructor(deviceId: string) {}
onMessage(payload: string) {}
}
export default new DeviceHandler() // 直接用这个实例
export default { onMessage(p) {} } // 对象当实例,按方法名调用
export default function handle(x) {} // 单函数入口,用 jscriptx.DefaultFunc 调用
export function Options() {} // 只有命名导出时,整个模块当实例
```
拿到实例的几种形态都是按方法名调用,`this` 绑定到实例,**继承来的方法也找得到**。
## 脚本级的钩子:静态方法
有些事情不属于任何一次调用——比如脚本第一次被启用时配置一下 Go 侧的扩展。写成 class 的**静态方法**,用 `CallStatic` 调:
```ts
export default class PkgImportController {
static Startup() { store.Set("pkg.registry", "https://…") }
constructor() { } // 每次调用的准备
Execute(g) { }
}
```
```go
_, err := script.CallStatic(ctx, "Startup")
if err != nil && !errors.Is(err, jscriptx.ErrFuncNotFound) {
return err // ErrFuncNotFound 表示脚本没写这个钩子,是常态
}
```
`Call` 有两点不同,都是"静态"这个语义要求的:
- **不构造实例**——`constructor` 不会跑。它做的是"这一次调用的准备",跟脚本级的初始化无关
- **VM 用完就丢**,不回池
所以静态方法**建不出能存活的 JS 对象**——VM 一丢就没了。它的用处是把配置写进 Go 侧的扩展,那些副作用留在 Go 那边,后续每次调用都读得到。`ScopeExtensions` 在静态方法里照常可用,这正是它的用武之地。
`HasStatic(fn)` 能查有没有,但它也要建一个 VM;只想"有就调"的话直接 `CallStatic``ErrFuncNotFound` 更省。
## 脚本跑在非严格模式下
打包产物里**没有** `"use strict"`——esbuild 输出 ESM 格式时不加这个指令,包成 IIFE 时也没加。
一个后果是给只读全局赋值会**静默失败**而不是抛错:
```ts
store = {} // 什么都不会发生,也不报错
```
白名单和扩展仍然是真正只读的(改不动、删不掉),只是脚本改它时得不到任何提示。
## 多返回值:error 不出现在返回值里,它变成异常
这是脚本作者唯一需要额外理解的"非直觉"行为,是 goja 的转换规则,本库沿用:
| Go 函数签名 | 脚本里拿到什么 |
| --- | --- |
| `func() T` | 裸值 `T` |
| `func() (T, error)` | `error` 为 nil:裸值 `T`;非 nil:抛 JS 异常 |
| `func() (A, B)` | 数组 `[A, B]` |
| `func() (A, B, error)` | `error` 为 nil:数组 `[A, B]`;非 nil:抛 JS 异常 |
```ts
const r = sd.ToSQL() // r[0] 是 SQLr[1] 是参数数组
try { risky() } catch (e) { /* Go 侧返回的 error 在这里 */ }
```
脚本不 catch 的话,异常会冒泡成 Go 侧的 `*jscriptx.Error`,原始的 Go error 挂在 `Cause` 上,
调用方的 `errors.Is` 照样匹配得到。
另外 Go 的 `nil` 到脚本里是 **`null` 不是 `undefined`**。
## async 可以用,但没有事件循环
`async` 方法返回的 Promise 由本库自动解包,用起来跟同步方法一样:
```ts
async Load(id: string): Promise<string> {
const raw = await Promise.resolve(id) // 纯计算的 await 没问题
return raw
}
```
**goja 没有事件循环**,脚本里等不了真正的异步(定时器、网络、IO)——那种 Promise 永远
pending,会得到 `ErrPromisePending` 和一句说明。异步的活交给 Go 侧做,脚本只写同步逻辑。
Promise 被 reject 则转成普通的 Go error`ErrPromiseRejected`)。
微任务(`.then` 回调)会在**调用返回给 Go 之前**跑完,不会漏到下一次调用里——所以池化的
VM 不存在"上个请求的回调在这个请求里执行"这种串扰。
`setTimeout` 等定时器**没有提供,也不打算提供**:它需要一个拥有 VM 的事件循环,而 VM 是
池化复用的——定时器没触发时 VM 还不了池,回调真跑起来时请求上下文早没了。这几个全局
故意保持 `undefined`(而不是定义成"一调就报错"的桩),库里 `typeof setTimeout !== "undefined"`
的特性探测才能正常降级;真调用了会在错误里说明该怎么办。
## 共享只到代码层面,不到状态层面
多个脚本 `import` 同一个模块时,各自拿到的是**独立副本**:模块被内联进各自的产物是一层原因,
更根本的是每个实例独占一个 VM,VM 之间不共享任何 JS 状态。
| 公共的东西 | 能不能 import 共享 |
| --- | --- |
| 纯函数、工具类、常量、类型 | ✅ 随便用 |
| 模块级状态(缓存、计数器、连接) | ❌ 各自一份,要共享得走作用域扩展 |
这是那种低负载看不出问题、一上量才暴露的坑。
---
# Go 侧怎么调
## 两种执行方式
区别只有一个——**脚本实例活多久**:
| 方式 | 取 VM | 实例生命周期 | 脚本里的 `this.xxx` |
| --- | --- | --- | --- |
| `Script.Call` | 从 VM 池借 | = VM 生命周期 | 随时可能归零,**只能当缓存** |
| `Script.New``Instance` | 独占一个 VM | 由你 `Close` 决定 | 跨调用保持 |
```go
// 需要状态:New 一个实例,用完 Close
obj, err := e.New(ctx, "Resource/ResCreateController", "产品")
defer obj.Close()
obj.Call(ctx, "Store", cfg)
// 不需要状态:直接 Call,走池
s, _ := e.Script("Resource/ResQueryController")
s.Call(ctx, "Query", id)
```
`New` 会立刻建好 VM,所以 `constructor` 抛异常在 `New` 当场就报(`Func` 字段是 `"constructor"`)。
**本库不代管实例的生命周期**——没有按 key 复用、没有空闲回收。要长期持有(比如按设备 ID 存着),
业务侧自己拿 map 存,跟 Go 版 controller 的写法一致:
```go
var devices sync.Map // deviceID -> *jscriptx.Instance
func onConnect(id string) {
obj, _ := mqttCtrl.New(ctx, id)
devices.Store(id, obj)
}
func onDisconnect(id string) {
obj, _ := devices.LoadAndDelete(id)
obj.(*jscriptx.Instance).Close()
}
```
## 并发粒度
| 场景 | 能并发吗 |
| --- | --- |
| 不同实例(不同请求、不同设备) | ✅ 各自独占 VM |
| 同一作用域下的不同 controller | ✅ 各有各的 VM,只共享扩展 |
| **同一个实例**的多次调用 | ❌ 串行 |
| 不带作用域的池化调用 | ✅ 池里多个 VM |
只有第三种是串行的,而那是**必需**的——`this.count++` 要跨调用保持,就必须保证同一时刻只有
一个 goroutine 在动这个实例,跟 Go 侧用 `sync.Mutex` 保护 struct 字段是一回事。
## 作用域:让多个脚本共享数据
作用域通过 ctx 传递,只携带一段业务流程里要共享的东西,**不管任何生命周期**:
```go
st := store.New()
ctx = jscriptx.WithScope(ctx,
jscriptx.ScopeKey(requestID), // 可选,脚本里 scope.key
jscriptx.ScopeExtensions(st), // 扩展
jscriptx.ScopeGlobals(map[string]any{"user": u}), // 作用域专属全局
)
obj1, _ := cartCtrl.New(ctx) // 两个 controller
obj2, _ := orderCtrl.New(ctx) // 同一个 ctx → 同一份 store
```
```ts
// CartController.ts
export default class CartController {
Add(sku: string, qty: number) { store.Incr("total", qty) }
}
// OrderController.ts —— 另一个文件、另一个 VM,但读得到同一份 store
export default class OrderController {
Checkout() { return store.Get("total") }
}
```
**共享靠的是 Go 侧对象,不是共用 Runtime**——共用 Runtime 会让作用域内所有脚本被迫串行,
那才是真的并发瓶颈。现在各脚本各跑各的,只是手里的 `store` 指向同一个 Go 对象。
## 扩展
扩展就是"给脚本添一个全局对象",实例由你创建,跟着 ctx 走:
```go
type Extension interface {
Name() string // 脚本里的全局名
Bindings() map[string]any // 暴露的方法,大写开头
Module() (path, source string) // 配套 TS 模块,脚本可以 import
}
```
**本库不带任何内置扩展**——扩展该由用引擎的人按自己的场景定义,引擎只给接口。
`ext/store` 曾经在这里,2026-09-05 搬到了 framework 侧,那里才知道"进程级共享状态"
对业务意味着什么。)写一个是这样:
```go
type tx struct{ conn *sql.Tx }
func (t *tx) Name() string { return "tx" }
func (t *tx) Bindings() map[string]any {
return map[string]any{"commit": t.conn.Commit, "rollback": t.conn.Rollback}
}
func (t *tx) Module() (string, string) { return "@fsdpf/tx", txTypings }
```
Go 侧和脚本读写的天然是同一份,不用再取回来:
```go
st := store.New()
ctx = jscriptx.WithScope(ctx, jscriptx.ScopeExtensions(st))
// …脚本里 store.Set("k", v)…
st.Get("k")
```
### 脚本可以 import 扩展拿类型
```go
loader, _ := esm.NewLoader("app/src", esm.WithExtensions(store.New()))
```
```ts
import store, { Store } from "@jscriptx/store" // 有类型提示
export default class C {
Put(k: string, v: string) { store.Set(k, v) }
}
```
模块是**虚拟的**——磁盘上没有这个文件,路径和源码由扩展的 `Module()` 提供,打包时内联。
`WithExtensions` 只用于打包阶段(提供模块和类型),运行时注入哪些扩展仍由 ctx 决定。
import 了却没注册,**打包阶段就会报错**,不会拖到运行时。
### 把类型落盘给编辑器
虚拟模块只有 esbuild 看得见,编辑器解析不了 `import store from "@jscriptx/store"`,写脚本一路飘红。落盘一份就好了:
```go
n, err := loader.WriteTypings("app/node_modules")
// app/node_modules/@jscriptx/store/{package.json,index.ts}
```
用 node_modules 的布局而不是 tsconfig 的 `paths`——编辑器和 tsc 本来就按 Node 规则往上找,不用改任何配置。写出来的只是类型,运行期用不到,随时可以删掉重写,也不该提交进版本库。
只要内容不落盘的话,`loader.Typings()` 返回 `import 路径 -> 源码`;不想先建 Loader 就用包级的 `esm.Typings(exts...)`
### 注入是惰性的
扩展的方法集**不在建 VM 时转换**,而是装一个只读的访问器属性,脚本第一次读到那个全局名才把它转成 JS 对象,转完在这个 VM 里缓存。
一个脚本通常只用得上少数几个扩展,而转换要把每个方法都包装成 JS 函数——用不到的那些不该在每次建 VM 时都付一遍。实测这曾是脚本层剩余开销里最大的一块。
对脚本完全透明:读到什么就转什么,只读性、可枚举性都跟以前一样。唯一能观测到的差别是属性描述符从 `{value, writable}` 变成 `{get, set}`,业务代码不会碰到。
## 自定义调用约定
`Call`/`CallInto` 表达不了的调用模式——典型是「回调 + next」中间件——用 `WithCall`
```go
err := target.WithCall(ctx, "handle", func(c jscriptx.Caller) error {
switch c.Arity() { // 脚本函数声明了几个形参
case 2:
res, err := c.Call(model, next) // next 是 Go 闭包,脚本能直接调
...
}
return nil
})
```
完整实现(三种回调签名自动分派)见 `ExampleCaller``go doc` 里能看到。
本库**不预设脚本回调该长什么样**——那是框架的约定,各家不同。
## 值不能跨出脚本边界
`Call` 的返回值和 `CallInto` 的目标都不允许是 JS 函数/闭包:那种值绑在 VM 上,跨出边界
就失效了,本库会直接拒绝(`ErrValueEscape`)。需要回调语义用 `WithCall`
---
# 安全边界
脚本能访问的全局对象,只有 `WithGlobals` 显式放行的那些,加上 JS 语言自带的内置对象。
goja 不提供文件、网络、`require`,也没有 `setTimeout`
白名单对象注入时会逐层拷贝成**只读** JS 对象:脚本改不动它,多个 VM 之间也不会共享同一个
可变的 Go map(否则脚本一句 `db.C = null` 既污染别的 VM,又是实打实的数据竞争)。
## 白名单该怎么定
本库不预设放行哪些 API——那取决于你的框架。下面是一份可以照抄的参考,思路是
**只放行"不带数据库连接、不能自己发起查询"的纯构造器和常量**
```go
func Safe() map[string]any {
return map[string]any{
"db": map[string]any{
"C": db.C, "T": db.T, "V": db.V, // 构造列名/表名/字面量,纯 SQL 片段
},
"req": map[string]any{
"WithPermission": req.WithPermission,
// 标志位按 int 暴露而不是自定义数值类型:goja 会把后者包装成 JS 对象,
// 那样脚本里 req.ResRow | req.ResMask 这种按位运算就不成立了。
// 传回 Go 侧时 goja 会自动转回原类型。
"ResRow": int(req.ResRow),
"ResMask": int(req.ResMask),
"ResAll": int(req.ResAll),
},
}
}
```
**刻意不放行**
| 不放行 | 原因 |
| --- | --- |
| `db.From` | 能凭空造出查询数据集,绕过资源层 |
| `db.L` | 能往 SQL 里塞裸片段,可以挂子查询探测别的表,等于绕过权限设计 |
| `engine` / 任何数据库连接对象 | 同上 |
脚本要碰数据,由 Go 侧把**已经过权限包装的** `req.Resource` 或 dataset 当参数传进去。
脚本自己拿不到裸连接,也就绕不开资源层的行级权限过滤和字段脱敏。
---
# 可靠性
## 失控脚本
每次调用都带超时(`WithTimeout`,默认 5 秒)。超时或调用方 context 取消时,会从另一个
goroutine 中断脚本,**死循环也能断掉**。被中断过或 panic 过的 VM 直接丢弃不回池。
脚本执行期间的 panic(脚本里的类型错误、注入进去的 Go 方法内部 panic)都会被 recover
成 error 返回,不会掀翻调用方的 goroutine。
## 错误
所有错误都是 `*jscriptx.Error`,带分类、脚本名、函数名、脚本侧调用栈(行列号)和调用参数摘要,
并实现了 `slog.LogValuer`
```go
if err != nil {
logger.Error("脚本执行失败", slog.Any("err", err))
// kind=runtime script=Resource/ResCreateController func=Boom line=15 column=25
// at="Boom (Resource/ResCreateController:15:25)" msg="Error: 创建失败"
}
```
**行号指向 `.ts` 源文件而不是打包产物**esbuild 输出 inline sourcemapgoja 自带 sourcemap 支持。
哨兵错误:`ErrTimeout``ErrFuncNotFound``ErrScriptNotFound``ErrValueEscape`
`ErrClosed``ErrUnsupportedSignature``ErrPromiseRejected``ErrPromisePending``ErrBadGlobal`
## 热更新
版本号由参与打包的**所有**源文件的 mtime 算出,改了被 `import` 的公共模块也会触发重编译;
新增文件会被自动发现。换掉旧脚本时,已经拿着旧 `*Script` 的调用会继续跑完旧版本。
```go
e, _ := jscriptx.New(jscriptx.WithLoader(loader), jscriptx.WithAutoReload(true))
```
---
# 性能
Apple M4 Pro`go test -run XXX -bench . -benchmem`。每行都标了对应的基准名,
数字过时了可以自己重跑。
| 场景 | 基准 | 耗时 | 分配 |
| --- | --- | --- | --- |
| 纯 Go 基准线 | `BenchmarkNative` | 96 ns | 1 |
| `Instance.Call`(状态在 JS 实例里) | `BenchmarkInstanceCall` | 454 ns | 10 |
| `Script.Call`(同脚本同参数,架构对照) | `BenchmarkCall_同脚本对照` | 417 ns | 10 |
| `Instance.Call` + 作用域扩展 | `BenchmarkStoreExtension` | 1.05 μs | 29 |
| `Script.Call` + 传 Go 对象当参数 | `BenchmarkCall` | 1.76 μs | 57 |
| 同上,但传可取消的 context | `BenchmarkCall_带可取消context` | 4.33 μs | 63 |
| `Script.New`(建一个实例) | `BenchmarkInstanceNew` | 3.49 μs | 110 |
| 重新编译 + 建全新 VM | `BenchmarkVM新建` | 224 μs | 1687 |
几点说明:
- **池化和独占 VM 的架构开销几乎一样**(417 vs 454 ns)。差别大的是状态放哪:放 JS 实例
只要 0.45 μs,放作用域扩展 1.05 μs,靠参数把 Go 对象反射包装过去要 1.76 μs——
那个反射往返才是大头。
- 打包和编译只在**加载和热更新**时发生,不在调用路径上。
- 传可取消的 `context` 明显变贵(1.76 → 4.33 μs):哨兵要起 goroutine 盯 `ctx.Done()`
只有超时限制时走定时器快路径。MQTT 那种高频路径建议传 `context.Background()`
`WithTimeout` 兜底。
## 内存
每个实例独占一个 VM,3000 个实例的常驻内存:
| 配置 | 单实例 | 3000 个 |
| --- | --- | --- |
| 裸 VM | 7.0 KB | 20.5 MB |
| + `console` | 12.5 KB | 36.6 MB |
| + `console` + 一个扩展 | 21.8 KB | 64.0 MB |
> 这组是一次性手工测的(建 N 个实例后读 `runtime.MemStats`),仓库里没有对应的
> 自动化测试,换机器或改了注入逻辑之后不保证还准。上面那张表的数字才是可重跑的。
白名单和 console 只在**建 VM 时**注入,对每次调用零影响。而且注入是惰性的(见「注入是惰性的」),
脚本没读到的全局根本不会被转换——上表是「全都读到」的上界。实例多又不需要脚本日志时,
`WithLogger(nil)` 关掉 console 能省约 44%。
## 写脚本时的性能建议
### 循环里访问 Go 对象的方法,先取出来
goja 每次从 Go 对象上取方法都要**新建一个函数包装**,它不缓存。这里的「Go 对象」包括
传进脚本的参数、扩展、以及扩展返回的对象。
`Benchmark绑定_*` 在 JS 里循环 1000 次,下表已减掉空循环的基线、除以次数,
是**单次访问**的净开销:
| 写法 | 基准 | 耗时 | 分配次数 | 相对纯 JS |
| --- | --- | --- | --- | --- |
| `obj.name`(纯 JS 对象) | `绑定_纯JS属性` | 90 ns | 0.9 | 1× |
| `obj.Name`Go 结构体字段) | `绑定_Go结构体字段` | 128 ns | 3.9 | **4.2×** |
| `fn("x")`(绑定的 Go 函数) | `绑定_Go函数` | 270 ns | 7.9 | **8.5×** |
| `obj.Get("x")`Go 对象的方法) | `绑定_Go方法调用` | 675 ns | 19.9 | **21×** |
| 循环外先取出来再调 | `绑定_Go方法提前取出` | 319 ns | 10.0 | **10.6×** |
```ts
for (const row of rows) out.push(tpl.Render(row)) // ⚠️ 每轮都重新包装
const render = tpl.Render.bind(tpl) // ✅ 包装一次
for (const row of rows) out.push(render(row))
```
要点:
- **贵在属性访问,不在调用本身**。`obj.Get` 这一下就要造函数包装,把它提出来能省掉一半。
- **读 Go 结构体的字段便宜得多**(3.9 次分配),因为不用造包装。
- **纯 JS 对象几乎免费**(0.9 次)——它不跨语言。
所以要同时满足**循环**和**Go 对象**两个条件才值得改写。一次调用里访问三五次
(三次约 2 μs)别为它牺牲可读性。
### 别在模块顶层做重初始化
顶层每建一个 VM 就跑一遍。顶层建一张 2000 条的查找表,建一个实例要 **2.17 ms / 1.74 MB**
而轻量脚本只要 10 μs / 9 KB。要预计算就放 Go 侧做成扩展。
### 少往脚本里传 Go 对象
每次传参都要反射包装一遍,方法多的接口尤其贵——上表里 `Script.Call` 传 Go 对象比
`Instance.Call` 贵近四倍,差的就是这个。
---
# 附录
## 不按目录加载时
`esm` 子包管的是"按目录加载 + 路径寻址 + 热更新"。只有一段源码时直接 `Compile`
它内部同样走 esbuildTypeScript 照写),区别只是没有文件系统上下文,默认不能 `import`
```go
s, _ := e.Compile("user_approval.ts", src)
out, _ := s.Call(ctx, "handle", arg)
```
要在这段源码里 `import`,给一个解析基准目录:
```go
jscriptx.New(jscriptx.WithBundleOptions(jscriptx.WithResolveDir("app/src/Resource")))
```
## 自定义脚本来源
脚本存数据库表或配置中心时,实现这个接口交给 `WithLoader`
```go
type Loader interface {
Load(name string) (source string, version string, err error)
}
```
`version` 用来判断脚本变没变(`updated_at`、源码哈希都行,返回空字符串则退化成按源码哈希算)。
给出的源码默认由 Engine 交给 esbuild 打包,直接返回原始源码即可。**自己已经打过包的**要再
实现 `Prepared` 接口——这不是优化开关而是正确性要求:打包产物里已经没有 `export` 了,
再打一遍会被当死代码整段摇空。记得钉一行 `var _ jscriptx.Prepared = (*MyLoader)(nil)`
因为 Engine 靠类型断言识别,方法签名写错不会有编译错误。
## 测试
```bash
go test -race ./...
```
不需要任何 build tag,依赖只有 goja 和 esbuild。
## 为什么用 esbuild 而不是 rollup
在 goja 里跑 rollup 这条路验证过,走不通:
- **rollup 4** 依赖 `WebAssembly.instantiate` 和 wasm-bindgen 那套 gluegoja 没有 WASM 支持。
Go 侧确实能跑 WASMwazero,纯 Go),但要在 goja 里实现 `WebAssembly` JS API、桥接线性内存、
实现几十个 wasm-bindgen 回调、再 polyfill `TextDecoder`/`fetch`——数周工程且性能很差。
- **rollup 3** 是纯 JS 能跑,但要 polyfill `TextDecoder`/`TextEncoder`/`fetch`/`Buffer`/`process`
(实测缺 6 处),且不处理 `.ts`,还得再叠一个转译器。
esbuild 是 Go 原生的同类工具,一行依赖,原生支持 TypeScript,实测快两个数量级。