diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..4093804 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,408 @@ +# 插件系统(Plugin System) + +> **English**: this document is the reference for writing ModelRouter plugins. +> The Chinese version is the primary one; section titles map 1:1. + +ModelRouter 的插件是**单个 `.lua` 文件**,放在 `config.yaml` 的 `plugin_dir` 目录里。 +插件能做两件事: + +1. **挂钩子**:在请求流水线的若干 stage 上注册回调,看到每个请求的完整信息, + 并可以把结果累加进自己的状态。 +2. **贡献界面**:在启动时返回 HTML / CSS / JS,由内核注入 WebUI——可以是一整个 + 新页面,也可以是往现有页面里追加一个组件。 + +两者互相独立:只想统计请求数的插件不必碰界面;只想加个仪表盘的插件不必碰钩子。 + +--- + +## 1. 快速上手 + +一个最小的插件: + +```lua +-- plugins/hello.lua +local plugin = { + name = "hello", + version = "1.0.0", + description = "示例插件", + author = "you", +} + +-- 声明钩子 +plugin.hooks = { + request_end = "on_request_end", +} + +-- 钩子实现 +function plugin.on_request_end(payload) + -- payload 是解码后的 table,不是 JSON 字符串 + log("info", string.format("%s via %s: %d prompt tokens", + payload.model, payload.source, payload.prompt_tokens or 0)) + return nil -- 最后一个 stage 没有下游,return 无意义 +end + +-- 贡献界面 +plugin.ui = { + page = { + page_id = "hello", -- kebab-case + title = "Hello", + icon = "👋", + order = 90, -- 侧栏排序 + mount = [[
hello
]], + }, +} + +return plugin -- 必须返回一个 table +``` + +放进 `plugin_dir` 后重启即生效。`GET /api/plugins` 确认它被加载了。 + +--- + +## 2. 加载与生命周期 + +``` +core.New + └─ lua.NewVM(adapter_dir).Start() 适配器状态 + └─ lua.NewPlugins(vm, plugin_dir) + ├─ SeedBundled() 仅当目录不存在时写入内置插件(目前是 billing) + └─ LoadDir() 按文件名字典序逐个加载 +``` + +**加载失败不影响网关启动。** 一个语法错误的插件会被记录在 `GET /api/plugins` +的 `error` 字段里,永远不会被调用。这与适配器一致,但理由更强:插件是可选的 +第三方扩展,因为一个 `.lua` 打错字就让网关起不来是错误的取舍。 + +**目录一旦存在就是权威的。** 与适配器同规则:首启会 seed 内置插件,之后目录里 +的文件说了算,删除或编辑内置插件都是真实生效的操作。 + +### 2.1 热更新 + +| 方式 | 效果 | +|---|---| +| `POST /api/plugins {name, code}` | 写文件 + 立即加载新版本(旧的 Lua 状态被关闭重建,**累计量清零**) | +| `DELETE /api/plugins/{name}` | 删文件 + 卸载 | +| 改文件后 `POST` 同名 | 同上 | + +改文件但**不** POST,需要重启才生效。 + +--- + +## 3. 流水线 stage + +一个请求依次经过三个 stage。插件可以为任意 stage 注册钩子;未注册的 stage +被忽略,所以插件不会因为网关将来新增 stage 而报错。 + +``` + 客户端请求 + │ + ┌─────────▼──────────┐ + │ request_start │ 已解析、已鉴权,尚未选源 + │ · type │ "chat" | "stream" | "image" + │ · model │ 客户端请求的原始 model("AUTO" 也在这里) + │ · key / role │ 掩码后的网关 key id("***a1b2c3")与角色 + │ · source │ 空(还没选源) + │ · stream │ + │ · messages_count │ + │ · tools_count │ + │ · ts │ unix 秒 + └─────────┬──────────┘ + │ (调度:tier 遍历 → 槽位轮转 → 冷却/配额过滤) + ┌─────────▼──────────┐ + │ routed │ 已选定 (source, model),尚未发往上游 + │ · source / model │ 实际选中的 + │ · tier │ AUTO 链的档位;直连 = -1;AUTO = -2 + │ · stream / key │ + └─────────┬──────────┘ + │ (HTTP 往返 / SSE 流) + ┌─────────▼──────────┐ + │ request_end │ 每个请求恰好一次,成功失败都触发 + │ · ok / status │ + │ · latency_ms │ + │ · first_byte_ms │ 流式的首字节时间 + │ · prompt_tokens │ 上游真实 usage,缺失时为字节估算 + │ · completion_tokens + │ · cache_hit_tokens / cache_miss_tokens + │ · image_count │ 生图数量(图片不计 token) + │ · error │ 失败原因,成功时为 "" + │ · time │ unix **毫秒** + └─────────┬──────────┘ + │ + 写审计 + 聚合统计 +``` + +### 3.1 触发点在哪 + +| stage | 代码位置 | 说明 | +|---|---|---| +| `request_start` | `gateway/chat.go` `handleChat` | 每个 chat 请求一次 | +| `routed` | `singleChat` / `streamChat` / `singleChatAuto` / `streamChatAuto` | 成功选定源之后,各一次 | +| `request_end` | `gateway/chat.go` `writeRec` | **所有出口的唯一汇合点**,每个请求一次 | + +`request_end` 放在 `writeRec` 是因为四条入口路径(直连/AUTO × 流式/非流式)都 +经过它,既不会漏(流式的 token 数只有流结束才知道),也不会重复。 + +**hot path 注意事项**:没有插件注册某 stage 时,`Fire` 立刻返回(一次 `RLock` +加一次 map 查找)。装了插件之后,每个请求会在该 stage 上多一次 Lua 调用—— +这是同步的,在关键路径上。计费插件那种"每请求一次"是正常的;把重活放进钩子是 +反模式。 + +### 3.2 钩子的返回值 + +- 返回 `nil` 或不返回 = **没有意见**,payload 原样传给下一个插件 +- 返回 table = 其中的键会**合并进 payload**,并作为 `Fire` 的返回值 + +前三个 stage 的返回值目前没有内部消费者(最后一个 stage 之后就是写审计), +所以计费插件改用 `plugin.state` + `/state` 端点来暴露数据。 + +--- + +## 4. 界面扩展 + +### 4.1 整页 + +```lua +plugin.ui = { + page = { + page_id = "billing", -- 必填,kebab-case。侧栏 data-tab 与 #tab-billing + title = "Billing", -- 必填,侧栏文字 + icon = "💰", -- 可选 + order = 40, -- 侧栏排序,默认 100 + mount = [[...HTML...]], + }, +} +``` + +### 4.2 往现有页面追加元素 + +```lua +plugin.ui = { + elements = { + { + target = "status", -- status | chat | keys | sort | sources | adapters + anchor = "top", -- "top" | "bottom" | "before:" | "after:" + order = 5, + mount = [[...HTML...]], + }, + }, +} +``` + +### 4.3 `mount` 里可以带 ` diff --git a/internal/gateway/ui_plugin_test.go b/internal/gateway/ui_plugin_test.go new file mode 100644 index 0000000..d76b2b3 --- /dev/null +++ b/internal/gateway/ui_plugin_test.go @@ -0,0 +1,158 @@ +package gateway + +import ( + "strings" + "testing" +) + +// The plugin UI injection is JavaScript inside the embedded index.html, and it +// is the ONLY thing that turns a plugin's `ui` block into a visible page or +// element. These tests pin the wiring on the JS side; the server side (what the +// payload contains) is covered by TestUIInjectServesPluginUI and the lua +// package's TestBillingPluginDeclaresUI. +// +// What makes this worth pinning: a missing hook here fails SILENTLY. The page +// simply never appears, there is no error anywhere, and it looks like "the +// plugin didn't declare a page" rather than "the UI forgot to inject it". + +// uiSource returns the embedded WebUI document. +func uiSourceX(t *testing.T) string { + t.Helper() + return uiSource(t) +} + +// TestUIFetchesPluginInjection: the boot sequence must ask the kernel what to +// inject. Without this fetch the whole feature is inert. +func TestUIFetchesPluginInjection(t *testing.T) { + src := uiSourceX(t) + if !strings.Contains(src, "/api/ui-inject") { + t.Error("the WebUI never calls /api/ui-inject; plugin pages and elements can never appear") + } +} + +// TestUIInjectsBeforeFirstRender: injection must be awaited before the first +// refresh, otherwise the sidebar is built without the plugin entry and the +// first paint races the fetch. This is an ordering contract, so it is asserted +// on the source order rather than trusted. +func TestUIInjectsBeforeFirstRender(t *testing.T) { + src := uiSourceX(t) + iInject := strings.Index(src, "injectPluginUI()") + iRefresh := strings.LastIndex(src, `refresh("status")`) + if iInject < 0 { + t.Fatal("injectPluginUI() is never called") + } + if iRefresh < 0 { + t.Fatal("the boot sequence no longer calls refresh(\"status\")") + } + if iInject > iRefresh { + t.Error("injectPluginUI() is called after the first refresh; the sidebar " + + "and #main would be built before the plugin page exists") + } + // And it must be awaited, not fire-and-forget. + window := src[iInject:] + if !strings.Contains(window[:200], ".finally") && !strings.Contains(window[:200], "await") { + t.Error("injectPluginUI() is not awaited before refresh; a slow response " + + "would race the first paint") + } +} + +// TestUIPluginScriptsRunAfterMarkup is the subtle one. Setting innerHTML with a +// +]==], + }, + -- Two elements on the EXISTING status page: a headline tile and a + -- per-source cost breakdown, so the number is visible without opening the + -- Billing tab. + elements = { + { + target = "status", + anchor = "top", + order = 5, + mount = [==[ +
+
Total spend (billing plugin)
+
—
+
+
+ +]==], + }, + }, +} + +return plugin \ No newline at end of file diff --git a/internal/lua/plugins_test.go b/internal/lua/plugins_test.go new file mode 100644 index 0000000..41ea89a --- /dev/null +++ b/internal/lua/plugins_test.go @@ -0,0 +1,313 @@ +package lua + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// newPluginVM builds a VM plus a plugin registry rooted at dir. +func newPluginVM(t *testing.T) (*VM, *Plugins, string) { + t.Helper() + dir := filepath.Join(t.TempDir(), "adapters") + vm := NewVM(dir) + if err := vm.Start(); err != nil { + t.Fatalf("vm start: %v", err) + } + t.Cleanup(vm.Stop) + pdir := filepath.Join(t.TempDir(), "plugins") + return vm, NewPlugins(vm, pdir), pdir +} + +// loadPlugin writes one plugin to disk and loads it. +func loadPlugin(t *testing.T, ps *Plugins, name, code string) error { + t.Helper() + if err := os.MkdirAll(ps.dir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ps.dir, name+".lua"), []byte(code), 0644); err != nil { + t.Fatal(err) + } + return ps.LoadSource(name, code) +} + +// TestPluginManifestAndHooks: the two hook registration forms both work and the +// manifest is read. +func TestPluginManifestAndHooks(t *testing.T) { + _, ps, _ := newPluginVM(t) + code := ` +local p = {} +p.name = "demo" +p.version = "1.2.3" +p.description = "a demo plugin" +p.author = "tester" +p.hooks = { request_end = "on_end" } +function p.on_end(payload) + payload.seen = true + payload.name_seen = "demo" + return payload +end +return p +` + if err := loadPlugin(t, ps, "demo", code); err != nil { + t.Fatalf("load: %v", err) + } + list := ps.List() + if len(list) != 1 { + t.Fatalf("List() = %d plugins, want 1", len(list)) + } + if list[0]["name"] != "demo" || list[0]["version"] != "1.2.3" { + t.Errorf("manifest not read: %+v", list[0]) + } + hooks := list[0]["hooks"].([]string) + if len(hooks) != 1 || hooks[0] != string(StageRequestEnd) { + t.Errorf("hooks = %v, want [request_end]", hooks) + } + out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"}) + if out["seen"] != true || out["name_seen"] != "demo" { + t.Errorf("hook did not mutate payload: %+v", out) + } +} + +// TestPluginAnonymousHookForm: `request_end = function() end` directly on the +// table must register too, since a single-hook plugin should not need a name. +func TestPluginAnonymousHookForm(t *testing.T) { + _, ps, _ := newPluginVM(t) + code := ` +local p = { name = "anon" } +p.request_end = function(payload) + payload.hit = 1 + return payload +end +return p +` + if err := loadPlugin(t, ps, "anon", code); err != nil { + t.Fatalf("load: %v", err) + } + out := ps.Fire(StageRequestEnd, map[string]interface{}{}) + if out["hit"] != float64(1) { + t.Errorf("anonymous hook did not fire: %+v", out) + } +} + +// TestPluginHookStagesFireInOrder: each stage reaches only its own hooks. +func TestPluginHookStagesFireInOrder(t *testing.T) { + _, ps, _ := newPluginVM(t) + code := ` +local p = { name = "stages" } +p.hooks = { + request_start = "s1", + routed = "s2", + request_end = "s3", +} +function p.s1(x) x.order = (x.order or "") .. "1" return x end +function p.s2(x) x.order = (x.order or "") .. "2" return x end +function p.s3(x) x.order = (x.order or "") .. "3" return x end +return p +` + if err := loadPlugin(t, ps, "stages", code); err != nil { + t.Fatalf("load: %v", err) + } + payload := map[string]interface{}{} + ps.Fire(StageRequestStart, payload) + ps.Fire(StageRouted, payload) + ps.Fire(StageRequestEnd, payload) + if payload["order"] != "123" { + t.Errorf("stage order = %v, want \"123\"", payload["order"]) + } +} + +// TestPluginErrorIsContained is the critical safety property: a throwing hook +// must not propagate. Forwarding depends on it. +func TestPluginErrorIsContained(t *testing.T) { + _, ps, _ := newPluginVM(t) + code := ` +local p = { name = "boom" } +p.hooks = { request_end = "kaboom" } +function p.kaboom(payload) + error("intentional plugin failure") +end +return p +` + if err := loadPlugin(t, ps, "boom", code); err != nil { + t.Fatalf("load: %v", err) + } + // Must not panic and must return the payload unchanged. + out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"}) + if out["model"] != "m" { + t.Errorf("payload was altered by a failing plugin: %+v", out) + } + // And the failure must be visible, not silent. + errs := ps.HookErrors() + if errs["request_end"] == nil { + t.Error("a failing plugin left no error record; it would be silently missing") + } +} + +// TestPluginFailingHookDoesNotBlockLaterPlugins: one bad plugin must not stop +// the next one from running. +func TestPluginFailingHookDoesNotBlockLaterPlugins(t *testing.T) { + _, ps, _ := newPluginVM(t) + bad := ` +local p = { name = "bad" } +p.hooks = { request_end = "f" } +function p.f(x) error("boom") end +return p +` + good := ` +local p = { name = "good" } +p.hooks = { request_end = "f" } +function p.f(x) x.good = true return x end +return p +` + _ = loadPlugin(t, ps, "bad", bad) + if err := loadPlugin(t, ps, "good", good); err != nil { + t.Fatalf("load good: %v", err) + } + out := ps.Fire(StageRequestEnd, map[string]interface{}{}) + if out["good"] != true { + t.Errorf("a good plugin was blocked by a failing one: %+v", out) + } +} + +// TestPluginSyntaxErrorIsIsolated: a plugin that will not compile is listed +// with its error and is never called — it must not prevent LoadDir from loading +// the rest. +func TestPluginSyntaxErrorIsIsolated(t *testing.T) { + _, ps, _ := newPluginVM(t) + broken := "this is not lua(((" + good := ` +local p = { name = "ok" } +p.hooks = { request_end = "f" } +function p.f(x) x.ok = true return x end +return p +` + _ = loadPlugin(t, ps, "broken", broken) + if err := loadPlugin(t, ps, "ok", good); err != nil { + t.Fatalf("load ok: %v", err) + } + if err := ps.LoadDir(); err != nil { + t.Fatalf("LoadDir: %v", err) + } + // The broken plugin must not be callable and must carry an error. + for _, row := range ps.List() { + if row["name"] == "broken" { + if row["loaded"] == true { + t.Error("a plugin with a syntax error reported itself as loaded") + } + if row["error"] == nil || row["error"] == "" { + t.Error("a broken plugin carries no error message") + } + } + } + // The good plugin still works. + out := ps.Fire(StageRequestEnd, map[string]interface{}{}) + if out["ok"] != true { + t.Errorf("good plugin stopped working: %+v", out) + } +} + +// TestPluginUIExtension: a plugin can contribute a page and elements. +func TestPluginUIExtension(t *testing.T) { + _, ps, _ := newPluginVM(t) + code := ` +local p = { name = "ui" } +p.hooks = { request_end = "f" } +function p.f(x) return x end +p.ui = { + page = { + page_id = "billing", + title = "Billing", + icon = "💰", + order = 50, + mount = "
hi
", + }, + elements = { + { target = "status", anchor = "top", mount = "
cost
" }, + }, +} +return p +` + if err := loadPlugin(t, ps, "ui", code); err != nil { + t.Fatalf("load: %v", err) + } + ui := ps.UI() + if ui.Page == nil { + t.Fatal("no page contributed") + } + if ui.Page.PageID != "billing" || ui.Page.Title != "Billing" { + t.Errorf("page = %+v", ui.Page) + } + if !strings.Contains(ui.Page.Mount, "console.log") { + t.Error("mount lost its script content") + } + if len(ui.Elements) != 1 || ui.Elements[0].Target != "status" { + t.Errorf("elements = %+v", ui.Elements) + } +} + +// TestPluginHookReturnsNilIsNoOpinion: a hook returning nothing must leave the +// payload untouched (plugins should not be forced to echo it back). +func TestPluginHookReturnsNilIsNoOpinion(t *testing.T) { + _, ps, _ := newPluginVM(t) + code := ` +local p = { name = "silent" } +p.hooks = { request_end = "f" } +function p.f(payload) + -- records nothing, returns nothing + return nil +end +return p +` + if err := loadPlugin(t, ps, "silent", code); err != nil { + t.Fatalf("load: %v", err) + } + out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "ok": true}) + if out["model"] != "m" || out["ok"] != true { + t.Errorf("a no-op hook disturbed the payload: %+v", out) + } +} + +// TestPluginUIJSONShape is a wire-format guard: the kernel sends this to the +// browser, so the shape is a contract with the WebUI. +func TestPluginUIJSONShape(t *testing.T) { + _, ps, _ := newPluginVM(t) + code := ` +local p = { name = "shape" } +p.hooks = { request_end = "f" } +function p.f(x) return x end +p.ui = { elements = { { target = "keys", mount = "k" } } } +return p +` + if err := loadPlugin(t, ps, "shape", code); err != nil { + t.Fatalf("load: %v", err) + } + b, err := json.Marshal(ps.UI()) + if err != nil { + t.Fatalf("marshal UI: %v", err) + } + var view struct { + Elements []struct { + Target string `json:"target"` + Mount string `json:"mount"` + } `json:"elements"` + } + if err := json.Unmarshal(b, &view); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(view.Elements) != 1 || view.Elements[0].Target != "keys" { + t.Errorf("UI JSON shape = %+v", view.Elements) + } +} + +// TestPluginFireWithNoPluginsIsNoop: an empty registry must not allocate or fail. +func TestPluginFireWithNoPluginsIsNoop(t *testing.T) { + _, ps, _ := newPluginVM(t) + in := map[string]interface{}{"a": 1} + out := ps.Fire(StageRequestEnd, in) + if out["a"] != 1 || ps.Count() != 0 { + t.Errorf("empty registry misbehaved: %+v", out) + } +} diff --git a/internal/lua/vm.go b/internal/lua/vm.go index d22e7bc..51ff6af 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -36,7 +36,7 @@ import ( golua "github.com/aarzilli/golua/lua" ) -//go:embed adapters/*.lua +//go:embed adapters/*.lua plugins/*.lua var bundledAdapters embed.FS // adapterGlobal is the reserved global holding the adapter table after the @@ -103,6 +103,11 @@ type adapterPool struct { lastGrow time.Time idleRounds int // consecutive janitor rounds that saw reclaimable slack peakInUse int // high-water mark of inUse, for observability + // pluginMode makes boot() store the returned table under pluginGlobal + // instead of adapterGlobal. Everything else (elastic sizing, reclaim) is + // identical, which is why plugins reuse this pool rather than getting a + // second implementation. + pluginMode bool } const ( @@ -116,6 +121,13 @@ const ( // growCooldown keeps a burst of misses from batching repeatedly while the // previous batch is still booting. growCooldown = time.Second + // maxPluginStates caps how many concurrent VM states ONE plugin may occupy. + // A plugin is third-party code on the request path, so its ceiling is much + // lower than an adapter's (which is sized from the sources' + // max_concurrent): a plugin hook is a short synchronous call, so a handful + // of states is already far more parallelism than any real hook needs, and a + // runaway plugin cannot balloon memory the way a per-source adapter pool can. + maxPluginStates = 4 // residentWorkers is how many states an adapter keeps warm once it has // served at least one request. Booting is milliseconds, but keeping one warm // removes that from the critical path of the next request. Adapters that @@ -265,7 +277,14 @@ func (p *adapterPool) boot() (*worker, error) { L.Close() return nil, fmt.Errorf("adapter %s must return a table", p.name) } - L.SetGlobal(adapterGlobal) + // SetGlobal POPS the value off the stack, so it can only be called once per + // boot. Plugins therefore store under pluginGlobal only, and NOT under + // adapterGlobal: a second SetGlobal on the now-empty stack would assign nil. + if p.pluginMode { + L.SetGlobal(pluginGlobal) + } else { + L.SetGlobal(adapterGlobal) + } L.SetTop(0) return &worker{L: L}, nil } @@ -601,6 +620,55 @@ func (v *VM) Start() error { return nil } +// bundledPluginsDir is the directory inside the embedded FS holding the +// plugins shipped with the gateway. It is separate from adapters/ on purpose: +// the two are loaded by different machinery into different kinds of Lua state +// (a protocol transform vs. request-pipeline hooks), and keeping them apart +// makes it obvious that dropping a file in one does not affect the other. +const bundledPluginsDir = "plugins" + +// ReadBundledPlugin returns the source of a plugin shipped with the gateway. +// It exists so a test (or an operator tool) can load a bundled plugin without +// depending on whether seeding has already run for this directory. +func ReadBundledPlugin(name string) (string, error) { + data, err := bundledAdapters.ReadFile(bundledPluginsDir + "/" + name + ".lua") + if err != nil { + return "", fmt.Errorf("bundled plugin %s: %w", name, err) + } + return string(data), nil +} + +// writeBundledPlugins seeds the plugin directory with the shipped plugins. +// +// It runs only when the directory does not exist yet (same rule as adapters): +// once the directory exists it is authoritative, so deleting a shipped plugin is +// a real delete and editing one survives restarts. +func writeBundledPlugins(dir string) error { + if dir == "" { + return nil + } + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir plugin dir: %w", err) + } + entries, err := bundledAdapters.ReadDir(bundledPluginsDir) + if err != nil { + return nil // nothing embedded; not an error + } + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".lua" { + continue + } + data, err := bundledAdapters.ReadFile(bundledPluginsDir + "/" + e.Name()) + if err != nil { + continue + } + if err := os.WriteFile(filepath.Join(dir, e.Name()), data, 0644); err != nil { + return err + } + } + return nil +} + func (v *VM) Stop() { select { case <-v.janitorStop: @@ -1104,6 +1172,56 @@ func pushGoValue(L *golua.State, v interface{}) { } func jsonEncode(v interface{}) ([]byte, error) { return json.Marshal(v) } + +// luaToJSON converts the Lua value at idx into a Go value via json.encode, then +// unmarshals it into out. It is the bridge used by the plugin manifest/UI +// reader: the plugin returns a plain Lua table, and Go wants a typed struct. +// +// It goes through JSON rather than walking the Lua stack directly because the +// adapter/plugin boundary already speaks JSON everywhere else (transform_request +// gets a JSON string, hooks get a JSON string), so this keeps one representation +// instead of two. +func luaToJSON(L *golua.State, idx int, out interface{}) error { + if L.GetTop() < 1 { + return fmt.Errorf("empty stack") + } + abs := idx + if abs < 0 { + abs = L.GetTop() + 1 + abs + } + if abs < 1 || abs > L.GetTop() { + return fmt.Errorf("index %d out of range (top=%d)", idx, L.GetTop()) + } + // Absolute indices throughout: this binding aborts the process (SIGABRT) + // on a bad index rather than panicking, so the stack is captured before any + // push instead of being addressed relative to a shifting top. + // + // json.encode is pushed onto the stack and the value is pushed AFTER it, so + // Call(1, 1) invokes it (Call takes no function index — it calls whatever + // sits below the nargs values). + L.GetGlobal("json") + if L.IsNil(-1) { + L.SetTop(0) + return fmt.Errorf("json global missing") + } + L.GetField(-1, "encode") + if L.Type(-1) != golua.LUA_TFUNCTION { + L.SetTop(0) + return fmt.Errorf("json.encode missing") + } + L.PushValue(abs) + if err := L.Call(1, 1); err != nil { + L.SetTop(0) + return err + } + if L.GetTop() < 1 || L.Type(-1) != golua.LUA_TSTRING { + L.SetTop(0) + return fmt.Errorf("json.encode did not return a string") + } + s := L.ToString(-1) + L.SetTop(0) + return json.Unmarshal([]byte(s), out) +} func jsonDecode(s string) (interface{}, error) { var v interface{} if err := json.Unmarshal([]byte(s), &v); err != nil {