From 4e47809d86c2e11088c0212b04b1de23a33554c6 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Thu, 14 May 2026 16:44:44 +0200 Subject: [PATCH] runtime-api: SB objects handler --- client/boot.ts | 10 ++- client/client.ts | 42 +++++++++-- client/runtime_api/objects_api.test.ts | 93 ++++++++++++++++++++++++ client/runtime_api/objects_api.ts | 99 ++++++++++++++++++++++++++ e2e/fixtures.ts | 11 +-- server/headless.go | 12 ++-- server/runtime_api.go | 4 +- 7 files changed, 247 insertions(+), 24 deletions(-) create mode 100644 client/runtime_api/objects_api.test.ts create mode 100644 client/runtime_api/objects_api.ts diff --git a/client/boot.ts b/client/boot.ts index 14439a30..dc2b6823 100644 --- a/client/boot.ts +++ b/client/boot.ts @@ -16,6 +16,11 @@ import type { BootConfig, ServiceWorkerTargetMessage } from "./types/ui.ts"; import { BoxProxy } from "./lib/box_proxy.ts"; import { importKey } from "@silverbulletmd/silverbullet/lib/crypto"; import "./debug.ts"; + +// Initialize the runtime-bridge namespace. `??=` preserves any value an +// earlier init script may have already installed (desktop wrapper does this) +globalThis.sbRuntime ??= {}; + const logger = initLogger("[Client]"); if (!crypto.subtle) { @@ -140,9 +145,10 @@ safeRun(async () => { await augmentBootConfig(bootConfig!, config!); const isHeadless = new URLSearchParams(location.search).has("headless"); - // Expose headless flag globally so client.init() can detect it after URL params are stripped + // Expose headless flag on the runtime bridge so client.init() can detect + // it after URL params are stripped. if (isHeadless) { - (globalThis as any).__sbHeadless = true; + globalThis.sbRuntime.headless = true; } // Update the browser URL to no longer contain the query parameters using pushState diff --git a/client/client.ts b/client/client.ts index d9b20051..ac0c8145 100644 --- a/client/client.ts +++ b/client/client.ts @@ -73,12 +73,23 @@ import type { ServiceWorkerTargetMessage, } from "./types/ui.ts"; import { WidgetCache } from "./widget_cache.ts"; +import { handleObjectsRequest } from "./runtime_api/objects_api.ts"; // Fetch the file list ever so often, this will implicitly kick off a snapshot comparison resulting in the indexing of changed pages const fetchFileListInterval = 10000; +// Runtime API bridge: written by the client when running headless to evaluate Lua and invoke the objects API in the live client. +export type SBRuntime = { + headless?: boolean; + ready?: boolean; + evalLua?: (expr: string) => Promise; + evalLuaScript?: (script: string) => Promise; + objectsAPI?: (reqJson: string) => Promise; +}; + declare global { var client: Client; + var sbRuntime: SBRuntime; } // TODO: Clean this up, this has become a god class... @@ -519,7 +530,7 @@ export class Client { * and signal readiness once the full index is complete. */ private initHeadlessRuntime() { - if (!(globalThis as any).__sbHeadless) { + if (!globalThis.sbRuntime.headless) { return; } console.log("[RuntimeAPI] Headless mode, exposing eval functions"); @@ -543,9 +554,32 @@ export class Client { return (await Promise.resolve(luaValueToJS(returnValue, sf))) ?? null; }; - (globalThis as any).__sbEvalLua = (expr: string) => + globalThis.sbRuntime.evalLua = (expr: string) => evalLuaCode(`return ${expr}`); - (globalThis as any).__sbEvalLuaScript = evalLuaCode; + globalThis.sbRuntime.evalLuaScript = evalLuaCode; + + globalThis.sbRuntime.objectsAPI = async ( + reqJson: string, + ): Promise => { + try { + const req = JSON.parse(reqJson); + const scriptEnv = new LuaEnv(spaceLuaEnv.env); + const tl = new LuaEnv(); + tl.setLocal("_GLOBAL", spaceLuaEnv.env); + const sf = new LuaStackFrame(tl, null); + const response = await handleObjectsRequest( + { objectIndex: this.objectIndex, env: scriptEnv, stackFrame: sf }, + req, + ); + return JSON.stringify(response); + } catch (e: unknown) { + return JSON.stringify({ + ok: false, + code: "internal_error", + error: e instanceof Error ? e.message : String(e), + }); + } + }; // Signal readiness after widgets are fully ready (index complete + // editor state rebuild settled). Waiting on the widget-ready @@ -554,7 +588,7 @@ export class Client { console.log( "[RuntimeAPI] Ready (eval functions exposed + widgets ready)", ); - (globalThis as any).__sbRuntimeAPIReady = true; + globalThis.sbRuntime.ready = true; }); } diff --git a/client/runtime_api/objects_api.test.ts b/client/runtime_api/objects_api.test.ts new file mode 100644 index 00000000..3757e2f7 --- /dev/null +++ b/client/runtime_api/objects_api.test.ts @@ -0,0 +1,93 @@ +import { expect, test, vi } from "vitest"; +import { handleObjectsRequest } from "./objects_api.ts"; + +function fakeDeps( + overrides: Partial<{ + queryLuaObjects: (env: any, tag: string, query: any) => Promise; + tagNames: () => Promise; + }> = {}, +) { + return { + objectIndex: { + queryLuaObjects: overrides.queryLuaObjects ?? (async () => []), + tagNames: overrides.tagNames ?? (async () => []), + } as any, + env: {} as any, + stackFrame: {} as any, + }; +} + +test("list_tags returns the tagNames output verbatim", async () => { + const deps = fakeDeps({ + tagNames: async () => ["page", "task"], + }); + const res = await handleObjectsRequest(deps, { kind: "list_tags" }); + expect(res).toEqual({ ok: true, items: ["page", "task"] }); +}); + +test("get returns the object when found", async () => { + const deps = fakeDeps({ + queryLuaObjects: async (_env, tag, _query) => [ + { tag, ref: "x", name: "x" }, + ], + }); + const res = await handleObjectsRequest(deps, { + kind: "get", + tag: "page", + ref: "x", + }); + expect(res).toEqual({ ok: true, item: { tag: "page", ref: "x", name: "x" } }); +}); + +test("get returns not_found when missing", async () => { + const deps = fakeDeps({ queryLuaObjects: async () => [] }); + const res = await handleObjectsRequest(deps, { + kind: "get", + tag: "page", + ref: "nope", + }); + expect(res).toEqual({ + ok: false, + code: "not_found", + error: expect.any(String), + }); +}); + +test("list calls queryLuaObjects with translated query and returns items+total", async () => { + const spy = vi.fn().mockResolvedValue([{ id: 1 }, { id: 2 }]); + const deps = fakeDeps({ queryLuaObjects: spy }); + const res = await handleObjectsRequest(deps, { + kind: "list", + tag: "task", + filters: [{ field: "done", op: "eq", value: "false" }], + order: [], + limit: 100, + offset: 0, + }); + expect(res).toEqual({ + ok: true, + items: [{ id: 1 }, { id: 2 }], + equivalentLua: expect.stringContaining("_it.done == false"), + }); + expect(spy).toHaveBeenCalledOnce(); + const [, tag, query] = spy.mock.calls[0]; + expect(tag).toBe("task"); + expect(query.limit).toBe(100); +}); + +test("list rejects bad query with bad_query / bad_field", async () => { + const deps = fakeDeps(); + const res = await handleObjectsRequest(deps, { + kind: "list", + tag: "task", + filters: [{ field: "1bad", op: "eq", value: "v" }], + order: [], + limit: 10, + offset: 0, + }); + expect(res).toEqual({ + ok: false, + code: "bad_field", + error: expect.any(String), + }); +}); diff --git a/client/runtime_api/objects_api.ts b/client/runtime_api/objects_api.ts new file mode 100644 index 00000000..b73656fb --- /dev/null +++ b/client/runtime_api/objects_api.ts @@ -0,0 +1,99 @@ +import { + translateListRequest, + TranslationError, + type Filter, + type OrderKey, +} from "./translate_query.ts"; +import { parseExpressionString } from "../space_lua/parse.ts"; +import type { LuaExpression } from "../space_lua/ast.ts"; +import { + type LuaEnv, + type LuaStackFrame, + luaValueToJS, +} from "../space_lua/runtime.ts"; + +export type ObjectsRequest = + | { kind: "list_tags" } + | { kind: "get"; tag: string; ref: string } + | { + kind: "list"; + tag: string; + filters: Filter[]; + order: OrderKey[]; + limit: number; + offset: number; + select?: string[]; + debug?: boolean; + }; + +export type ObjectsResponse = + | { ok: true; items: any[]; equivalentLua?: string } + | { ok: true; item: any } + | { ok: false; code: string; error: string }; + +export type ObjectsDeps = { + objectIndex: { + queryLuaObjects(env: LuaEnv, tag: string, query: any): Promise; + tagNames(): Promise; + }; + env: LuaEnv; + stackFrame: LuaStackFrame; +}; + +export async function handleObjectsRequest( + deps: ObjectsDeps, + req: ObjectsRequest, +): Promise { + try { + switch (req.kind) { + case "list_tags": { + const items = await deps.objectIndex.tagNames(); + return { ok: true, items }; + } + case "get": { + const items = await deps.objectIndex.queryLuaObjects( + deps.env, + req.tag, + { + objectVariable: "_it", + where: parseExpressionString( + `_it.ref == ${JSON.stringify(req.ref)}`, + ) as LuaExpression, + limit: 1, + }, + ); + if (items.length === 0) { + return { + ok: false, + code: "not_found", + error: `${req.tag}/${req.ref} not found`, + }; + } + return { ok: true, item: items[0] }; + } + case "list": { + const { query, equivalentLua } = translateListRequest(req); + const rawItems = await deps.objectIndex.queryLuaObjects( + deps.env, + req.tag, + query, + ); + const items = req.select?.length + ? await Promise.all( + rawItems.map((v) => luaValueToJS(v, deps.stackFrame)), + ) + : rawItems; + return { ok: true, items, equivalentLua }; + } + } + } catch (e: unknown) { + if (e instanceof TranslationError) { + return { ok: false, code: e.code, error: e.cause }; + } + return { + ok: false, + code: "internal_error", + error: e instanceof Error ? e.message : String(e), + }; + } +} diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 9976bbc3..193a61b7 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -166,19 +166,10 @@ export async function waitForSaveAndReadFromServer( /** * Wait for the SilverBullet client to finish booting widgets. - * - * The client sets `window.__sbRuntimeAPIReady = true` once its widget - * readiness transition has settled (full index complete, space-lua - * scripts loaded, page list cache populated, and the resulting - * `editor:reloadState` rebuild finished — see `client/client.ts`). The - * final rebuild resets the CodeMirror editor; typing before it lands - * causes the cursor to jump and input to split. Waiting on this global - * before typing sidesteps that race without introducing test-only - * hooks into the client. */ export async function waitForEditorReady(page: Page): Promise { await page.waitForFunction( - () => (globalThis as any).__sbRuntimeAPIReady === true, + () => (globalThis as any).sbRuntime?.ready === true, undefined, { timeout: 15_000 }, ); diff --git a/server/headless.go b/server/headless.go index 6edd9b32..f98ec285 100644 --- a/server/headless.go +++ b/server/headless.go @@ -313,7 +313,7 @@ func (hb *HeadlessBrowser) Logs(limit int, since int64) []ConsoleLogEntry { return result } -// evalViaGlobal calls a global async JS function (e.g. __sbEvalLua) with the given code +// evalViaGlobal calls a global async JS function (e.g. sbRuntime.evalLua) with the given code // string via CDP runtime.Evaluate, awaiting the promise and returning the result. func (hb *HeadlessBrowser) evalViaGlobal(ctx context.Context, fnName string, code string) (any, error) { // JSON-encode the code string to safely embed it in JS @@ -337,14 +337,14 @@ func (hb *HeadlessBrowser) evalViaGlobal(ctx context.Context, fnName string, cod return result, nil } -// EvalLua evaluates a Lua expression via the browser's global __sbEvalLua function. +// EvalLua evaluates a Lua expression via the browser's runtime bridge. func (hb *HeadlessBrowser) EvalLua(ctx context.Context, expr string) (any, error) { - return hb.evalViaGlobal(ctx, "__sbEvalLua", expr) + return hb.evalViaGlobal(ctx, "sbRuntime.evalLua", expr) } -// EvalLuaScript evaluates a Lua script via the browser's global __sbEvalLuaScript function. +// EvalLuaScript evaluates a Lua script via the browser's runtime bridge. func (hb *HeadlessBrowser) EvalLuaScript(ctx context.Context, script string) (any, error) { - return hb.evalViaGlobal(ctx, "__sbEvalLuaScript", script) + return hb.evalViaGlobal(ctx, "sbRuntime.evalLuaScript", script) } // waitForClientReady polls until the SilverBullet client's eval functions are ready @@ -377,7 +377,7 @@ func waitForClientReady(ctx context.Context) error { // Check the global readiness flag (set after WS connected + index complete) var ready bool err := chromedp.Run(ctx, - chromedp.Evaluate(`!!globalThis.__sbRuntimeAPIReady`, &ready), + chromedp.Evaluate(`!!(globalThis.sbRuntime && globalThis.sbRuntime.ready)`, &ready), ) if err != nil { continue diff --git a/server/runtime_api.go b/server/runtime_api.go index 170d8b28..6d455ddd 100644 --- a/server/runtime_api.go +++ b/server/runtime_api.go @@ -260,7 +260,7 @@ func (b *RuntimeBridge) HandleLuaAPI(w http.ResponseWriter, r *http.Request) { return } - b.evalAndRespond(w, r, "__sbEvalLua", expr) + b.evalAndRespond(w, r, "sbRuntime.evalLua", expr) } // HandleLuaScriptAPI handles POST /.runtime/lua_script — evaluates a Lua script block. @@ -277,5 +277,5 @@ func (b *RuntimeBridge) HandleLuaScriptAPI(w http.ResponseWriter, r *http.Reques return } - b.evalAndRespond(w, r, "__sbEvalLuaScript", script) + b.evalAndRespond(w, r, "sbRuntime.evalLuaScript", script) }