feat(plugin): expose stable contribution identity

This commit is contained in:
jearhe
2026-10-07 00:13:30 +08:00
committed by GitHub
parent d9338d1a35
commit 4e144035b1
9 changed files with 146 additions and 8 deletions
+60 -1
View File
@@ -1661,11 +1661,16 @@ describe("PluginHostBridge", () => {
describe("plugin SDK source", () => {
interface SdkWindow {
dbxPlugin?: {
ready: Promise<unknown>;
contributionId?: string;
context?: Record<string, unknown>;
onInit: (listener: (context: unknown) => void) => () => void;
invoke: (method: string, params?: unknown, options?: { timeoutMs?: number }) => Promise<unknown>;
getPlanCapabilities: (connectionId: string) => Promise<unknown>;
explainPlan: (request: unknown) => Promise<unknown>;
getTableMetadata: (context: unknown) => Promise<unknown>;
};
receiveHostMessage: (message: unknown) => void;
}
function loadSdk(posted: unknown[], initialTheme?: { appearance: "dark" | "light"; tokens: Record<string, string> }): SdkWindow {
@@ -1677,10 +1682,19 @@ describe("plugin SDK source", () => {
},
dispatchEvent: vi.fn(),
} as unknown as Document;
let onMessage: ((event: MessageEvent) => void) | undefined;
const parent = { postMessage: (message: unknown) => posted.push(message) };
const addEventListener = (type: string, listener: (event: MessageEvent) => void) => {
if (type === "message") onMessage = listener;
};
// The SDK IIFE only touches window and the bare-global addEventListener at
// boot; parent.postMessage is captured for later request() calls, so a
// stub window/parent is enough here.
new Function("window", "parent", "addEventListener", "document", pluginSdkSource(initialTheme))(sandbox, { postMessage: (message: unknown) => posted.push(message) }, () => {}, document);
new Function("window", "parent", "addEventListener", "document", pluginSdkSource(initialTheme))(sandbox, parent, addEventListener, document);
sandbox.receiveHostMessage = (message) => {
if (!onMessage) throw new Error("SDK did not register its host-message listener");
onMessage({ source: parent, data: message } as unknown as MessageEvent);
};
return sandbox;
}
@@ -1720,6 +1734,51 @@ describe("plugin SDK source", () => {
expect(posted[0]).toMatchObject({ source: "dbx-plugin", type: "ready" });
});
it("exposes the opened contribution after ready when init was already delivered and context is empty", async () => {
const sdkMessages: unknown[] = [];
const hostMessages: unknown[] = [];
const sdk = loadSdk(sdkMessages);
const probe: PluginWorkbenchContribution = { type: "workbench", id: "sample.probe", label: "Probe" };
const generation: PluginWorkbenchContribution = { type: "workbench", id: "sample.generation", label: "Generation" };
const target = { postMessage: (message: unknown) => hostMessages.push(message) } as unknown as Window;
const bridge = new PluginHostBridge(plugin([], [probe, generation]), generation, {}, () => target, { invoke: vi.fn(), notify: vi.fn(), sendBinary: vi.fn(), readAsset: vi.fn() });
bridge.sendInit();
const init = hostMessages[0] as Record<string, unknown>;
expect(init).toMatchObject({ type: "init", contributionId: "sample.generation", context: {} });
sdk.receiveHostMessage(init);
await sdk.dbxPlugin!.ready;
expect(sdk.dbxPlugin!.contributionId).toBe("sample.generation");
expect(sdk.dbxPlugin!.contributionId).not.toBe(probe.id);
expect(sdk.dbxPlugin!.context).toEqual({});
const lateInit = vi.fn();
sdk.dbxPlugin!.onInit(lateInit);
expect(lateInit).toHaveBeenCalledWith({});
});
it("sets contribution identity independently of table context before ready and onInit", async () => {
const sdk = loadSdk([]);
const hostMessages: unknown[] = [];
const tableContext = { connectionId: "connection-1", database: "app", schema: "public", table: "orders" };
const generation: PluginWorkbenchContribution = { type: "workbench", id: "sample.generation", label: "Generation" };
const target = { postMessage: (message: unknown) => hostMessages.push(message) } as unknown as Window;
const bridge = new PluginHostBridge(plugin([], [generation]), generation, tableContext, () => target, { invoke: vi.fn(), notify: vi.fn(), sendBinary: vi.fn(), readAsset: vi.fn() });
let observed: { context: unknown; contributionId?: string } | undefined;
sdk.dbxPlugin!.onInit((context) => {
observed = { context, contributionId: sdk.dbxPlugin!.contributionId };
});
bridge.sendInit();
sdk.receiveHostMessage(hostMessages[0]);
await sdk.dbxPlugin!.ready;
expect(observed).toEqual({ context: tableContext, contributionId: "sample.generation" });
expect(sdk.dbxPlugin!.context).toEqual(tableContext);
expect(sdk.dbxPlugin!.contributionId).toBe("sample.generation");
});
it("keeps cloneable values intact and plain scalars by reference", () => {
const posted: unknown[] = [];
const { dbxPlugin } = loadSdk(posted);
@@ -1252,6 +1252,7 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
const pending = new Map();
const listeners = { event: new Set(), binary: new Set(), init: new Set(), context: new Set(), filedrop: new Set(), dragstate: new Set(), close: new Set() };
let sequence = 0;
let contributionId;
let context;
let locale = 'en';
let theme;
@@ -1353,6 +1354,7 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
};
window.dbxPlugin = Object.freeze({
ready,
get contributionId() { return contributionId; },
get context() { return context; },
get locale() { return locale; },
get theme() { return theme; },
@@ -1511,6 +1513,7 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
pending.delete(message.id);
if (message.error) handler.reject(new Error(message.error)); else handler.resolve(message.result);
} else if (message.type === 'init') {
contributionId = message.contributionId;
capabilities = message.capabilities || {};
context = message.context;
locale = typeof message.locale === 'string' ? message.locale : 'en';
+12 -1
View File
@@ -274,7 +274,7 @@ context-menu 也可以声明由 Host 处理的 Workbench action:
同一插件内通过 `id` 互相引用:Connection Provider 的 `workbench` 必须指向一个已声明的工作台,`filesystem_provider` 必须指向一个已声明的文件系统 Provider。图标优先使用 Contribution 的 `icon`,缺失时回退到插件级 `icon`。
自定义文件浏览器应使用 `workbench`:列表、虚拟滚动、预览、右键菜单和分栏由插件前端实现。只有希望使用 DBX 通用文件管理器时才选择 `filesystem_provider`;同时声明两者时默认打开工作台。未声明 `action` 的旧式 `context-menu` 调用后端 `contextMenu/<contribution-id>`;声明式 `open-workbench` action 则由 Host 直接打开同插件 Workbench。`result-view` 在工作台 context 中接收结果快照,被打开的 contribution id 会通过 `dbx-plugin-init` 负载下发,因此单个 UI 入口可以为多个工作台和结果视图服务;具体负载见[完整贡献点协议](https://github.com/t8y2/dbx/blob/main/plugins/README.md)。
自定义文件浏览器应使用 `workbench`:列表、虚拟滚动、预览、右键菜单和分栏由插件前端实现。只有希望使用 DBX 通用文件管理器时才选择 `filesystem_provider`;同时声明两者时默认打开工作台。未声明 `action` 的旧式 `context-menu` 调用后端 `contextMenu/<contribution-id>`;声明式 `open-workbench` action 则由 Host 直接打开同插件 Workbench。`result-view` 在工作台 context 中接收结果快照。SDK 会在初始化后通过 `dbxPlugin.contributionId` 暴露 Host 选中的 contribution id,因此单个 UI 入口可以为多个工作台和结果视图服务;具体负载见[完整贡献点协议](https://github.com/t8y2/dbx/blob/main/plugins/README.md)。
### 文件系统协议
@@ -327,11 +327,21 @@ Workbench 上下文跨越 DBX 与插件边界时是 JSON 数据快照。宿主
UI 在隔离的 iframe 中运行,DBX 通过 `window.dbxPlugin` 提供桥接对象。不要导入 DBX 的 Vue/Tauri 模块,也不要假设浏览器页面能直接访问 Node.js、文件系统或网络。
多个 Workbench 或 result-view contribution 共用同一个 UI 入口时,可通过 Host 选中的 identity 路由页面:
```js
await window.dbxPlugin.ready;
const contributionId = window.dbxPlugin.contributionId;
```
`contributionId` 与 `context` 相互独立;例如从 Plugin Center 打开的 Workbench,其 context 可以是空对象 `{}`。SDK 会在 resolve `ready` 前记录 identity,因此 `await dbxPlugin.ready` 后可确定性地读取。为兼容旧插件,`dbx-plugin-init` 仍会携带完整 init payload 并作为通知派发;但该事件只派发一次,不应成为读取权威状态的唯一来源。
以下自定义 `objects/list` 和 `objects/changed` 需要由插件后端实现,资源示例假设包内存在 `ui/assets/empty-state.svg`:
```js
await window.dbxPlugin.ready;
const contributionId = window.dbxPlugin.contributionId;
const context = window.dbxPlugin.context;
const result = await window.dbxPlugin.invoke("objects/list", { prefix: "docs/" }, { timeoutMs: 30000 });
const off = window.dbxPlugin.onContext((nextContext) => render(nextContext));
@@ -343,6 +353,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
| API | 作用 |
| --- | --- |
| `ready` | 等待 Host 完成初始化;应用启动逻辑放在 `await dbxPlugin.ready` 之后。 |
| `contributionId` | Host 打开的 contribution 的只读 identity;`await ready` 后可读取,且与 `context` 独立。 |
| `context` / `onContext(fn)` | 读取或监听当前工作台上下文。 |
| `locale` | 读取当前语言;插件 UI 需自行完成翻译。 |
| `theme` | 读取 `appearance` 和 DBX 设计 tokens,适配明暗主题。 |
+12 -1
View File
@@ -274,7 +274,7 @@ Only legacy context-menu contributions without `action` require a backend entryp
Reference contributions by ID: a connection provider's `workbench` must point to a declared workbench and `filesystem_provider` to a declared filesystem provider. A contribution icon takes precedence over the plugin-level icon.
Choose `workbench` for a custom file browser: the plugin owns its list, virtualization, previews, context menus, and split panes. Choose `filesystem_provider` only to use DBX's generic file manager; declaring both defaults to the workbench. A legacy `context-menu` invokes `contextMenu/<contribution-id>` on the backend, while a declarative `open-workbench` action opens its same-plugin Workbench directly; `result-view` receives a result snapshot in workbench context, and the opened contribution id arrives in the `dbx-plugin-init` payload so one UI entrypoint can serve several workbenches and result views. See the [full contribution protocol](https://github.com/t8y2/dbx/blob/main/plugins/README.md) for payloads.
Choose `workbench` for a custom file browser: the plugin owns its list, virtualization, previews, context menus, and split panes. Choose `filesystem_provider` only to use DBX's generic file manager; declaring both defaults to the workbench. A legacy `context-menu` invokes `contextMenu/<contribution-id>` on the backend, while a declarative `open-workbench` action opens its same-plugin Workbench directly; `result-view` receives a result snapshot in workbench context. The SDK exposes the Host-selected contribution id as `dbxPlugin.contributionId` after initialization, so one UI entrypoint can serve several workbenches and result views. See the [full contribution protocol](https://github.com/t8y2/dbx/blob/main/plugins/README.md) for payloads.
### Filesystem Protocol
@@ -327,11 +327,21 @@ The host also authors three reserved fields on every workbench context: `workben
The UI runs in an isolated iframe. DBX exposes the bridge as `window.dbxPlugin`; do not import DBX Vue/Tauri modules or assume that Node.js, local files, or arbitrary network access are available.
When multiple Workbench or result-view contributions share one UI entrypoint, use the Host-selected identity to route the UI:
```js
await window.dbxPlugin.ready;
const contributionId = window.dbxPlugin.contributionId;
```
`contributionId` is independent of `context`; for example, a Workbench opened from Plugin Center can have an empty `{}` context. The SDK records the identity before resolving `ready`, making it deterministic to read afterward. The legacy `dbx-plugin-init` event still carries the full init payload as a notification, but it is one-shot and must not be the only source of authoritative state.
The following custom `objects/list` and `objects/changed` methods must be implemented by your backend. The asset example assumes the package contains `ui/assets/empty-state.svg`:
```js
await window.dbxPlugin.ready;
const contributionId = window.dbxPlugin.contributionId;
const context = window.dbxPlugin.context;
const result = await window.dbxPlugin.invoke("objects/list", { prefix: "docs/" }, { timeoutMs: 30000 });
const off = window.dbxPlugin.onContext((nextContext) => render(nextContext));
@@ -343,6 +353,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
| API | Purpose |
| --- | --- |
| `ready` | Waits for Host initialization; start application logic after `await dbxPlugin.ready`. |
| `contributionId` | Read-only identity of the contribution opened by the Host; available after `await ready` and independent of `context`. |
| `context` / `onContext(fn)` | Reads or observes the current workbench context. |
| `locale` | Reads the current DBX locale; the plugin translates its own UI. |
| `theme` | Reads `appearance` and DBX design tokens for light/dark support. |
+1 -1
View File
@@ -66,7 +66,7 @@ Field names come from the plugin manifest, not the runtime. Imported records get
## Supported boundary
- Host API 1.0 subset: `ready`, `context`, `locale`, `theme`, `request`, `invoke`, `notify`, `onInit`, `onContext`, `onEvent`, `onBinary`, `sendBinary`, resource reads and `openWorkbench`.
- Host API 1.0 subset: `ready`, `contributionId` (readable after `ready`), `context`, `locale`, `theme`, `request`, `invoke`, `notify`, `onInit`, `onContext`, `onEvent`, `onBinary`, `sendBinary`, resource reads and `openWorkbench`.
- Backend transports: default `stdio-jsonl` and explicit `stdio-framed`, protocol version 1. Initialization verifies plugin identity and version. Binary channels require framed transport.
- Permissions: event, binary and workbench navigation permissions are enforced. Unimplemented methods, such as `host.openFilesystem`, return errors. Native connection actions, query-result contributions and the DBX component kit are not emulated.
- JSON bridge parameters: 2 MiB. UI binary messages: 8 MiB. Sidecar JSON: 8 MiB. Sidecar binary: 64 MiB. Explicit bridge timeouts are clamped to 1–120000 ms, matching the host baseline.
+5
View File
@@ -1,6 +1,7 @@
// This function is serialized into the sandbox. It must not capture module state.
function installBridge(channel) {
let sequence = 0,
contributionId,
context = {},
locale = "zh-CN",
theme;
@@ -102,6 +103,9 @@ function installBridge(channel) {
}
window.dbxPlugin = Object.freeze({
ready,
get contributionId() {
return contributionId;
},
get context() {
return context;
},
@@ -150,6 +154,7 @@ function installBridge(channel) {
const m = event.data;
if (event.source !== parent || m?.source !== "dbx-host" || m.channel !== channel || m.version !== 1) return;
if (m.type === "init") {
contributionId = m.contributionId;
initialized = true;
clearInterval(readyTimer);
context = m.context || {};
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import { reactive } from "vue";
import { JSDOM } from "jsdom";
import { sandboxDocument } from "../browser-bridge.mjs";
import { hostMessage } from "../ui/messages.js";
import { hostMessage, pluginInitMessage } from "../ui/messages.js";
test("bridge delivers host-compatible environment, lifecycle and binary events", async (t) => {
const dom = new JSDOM(sandboxDocument("<head></head>", "test"), { runScripts: "dangerously" });
@@ -20,7 +20,7 @@ test("bridge delivers host-compatible environment, lifecycle and binary events",
w.dbxPlugin.onBinary((value) => {
binary = value;
});
send({ type: "init", context: { value: 1 } });
send({ type: "init", contributionId: "example.main", context: { value: 1 } });
await w.dbxPlugin.ready;
assert.equal(initialized, 1);
w.dbxPlugin.onInit(() => initialized++);
@@ -34,9 +34,47 @@ test("bridge delivers host-compatible environment, lifecycle and binary events",
assert.deepEqual([...binary.data], [0, 255]);
assert.equal(binary.channel, "bytes");
assert.equal(events.length, 5);
assert.equal(events[0].detail.contributionId, "example.main");
assert.equal(events[1].detail.value, 2);
});
test("dev-host exposes the contribution identity after ready when init was delivered before consumption", async (t) => {
const dom = new JSDOM(sandboxDocument("<html><head></head><body></body></html>", "generation"), { runScripts: "dangerously" });
t.after(() => dom.window.close());
const w = dom.window;
const frame = { contributionId: "example.generation", context: {} };
const init = hostMessage("generation", pluginInitMessage(frame, "zh-CN", { appearance: "light" }, []));
w.dispatchEvent(new w.MessageEvent("message", { source: w, data: init }));
await w.dbxPlugin.ready;
assert.equal(w.dbxPlugin.contributionId, "example.generation");
assert.deepEqual(w.dbxPlugin.context, {});
let lateContext;
w.dbxPlugin.onInit((context) => (lateContext = context));
assert.deepEqual(lateContext, {});
});
test("dev-host exposes contribution identity independently of table context before onInit", async (t) => {
const dom = new JSDOM(sandboxDocument("<html><head></head><body></body></html>", "generation"), { runScripts: "dangerously" });
t.after(() => dom.window.close());
const w = dom.window;
const context = { connectionId: "connection-1", database: "app", schema: "public", table: "orders" };
const frame = { contributionId: "example.generation", context };
const init = hostMessage("generation", pluginInitMessage(frame, "zh-CN", { appearance: "light" }, []));
let observed;
w.dbxPlugin.onInit((value) => {
observed = { context: value, contributionId: w.dbxPlugin.contributionId };
});
w.dispatchEvent(new w.MessageEvent("message", { source: w, data: init }));
await w.dbxPlugin.ready;
assert.deepEqual(observed, { context, contributionId: "example.generation" });
assert.deepEqual(w.dbxPlugin.context, context);
assert.equal(w.dbxPlugin.contributionId, "example.generation");
});
test("host messages snapshot nested reactive context and permissions before postMessage", () => {
const source = reactive({ context: { connectionId: "one" }, permissions: ["host.events"] });
const message = hostMessage("generation", { type: "init", ...source });
+2 -2
View File
@@ -1,7 +1,7 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Plus, Upload, Plug, Unplug, Pencil, Trash2, X, RefreshCw, Sun, Moon, Terminal, Settings2, Languages } from "lucide-vue-next";
import { hostMessage } from "./messages.js";
import { hostMessage, pluginInitMessage } from "./messages.js";
import DebugPanel from "./DebugPanel.vue";
import PluginIcon from "./PluginIcon.vue";
import { localizeManifest, translate } from "./i18n.js";
@@ -134,7 +134,7 @@ function post(frame, message) {
windows.get(frame.id)?.contentWindow?.postMessage(snapshot, "*");
}
function init(frame) {
post(frame, { type: "init", context: JSON.parse(JSON.stringify(frame.context)), locale: locale.value, theme: themes(), permissions: manifest.value.permissions || [] });
post(frame, pluginInitMessage(frame, locale.value, themes(), manifest.value.permissions || []));
}
function theme() {
dark.value = !dark.value;
+11
View File
@@ -1,3 +1,14 @@
export function hostMessage(channel, message) {
return JSON.parse(JSON.stringify({ ...message, source: "dbx-host", version: 1, channel }));
}
export function pluginInitMessage(frame, locale, theme, permissions) {
return {
type: "init",
contributionId: frame.contributionId,
context: JSON.parse(JSON.stringify(frame.context)),
locale,
theme,
permissions,
};
}