Plugin background instances
Plugin Views and actions have bounded lifetimes: a View is destroyed when it unmounts, and an action is destroyed when execution ends. They handle interface interactions and individual operations, but cannot host logic that must keep running after those entry points finish.
A background provides that missing capability: an independent execution environment that can keep running after startup. It survives View unmounts and action completion, remaining alive until it is stopped.
This lets a plugin keep long-lived state, WebSocket connections, or Bluetooth connections in its background, accessed through messages from pages, file Views, and commands. A connection can remain open after a View closes, and a reopened View can read the background’s current state.
For example, a Bluetooth battery plugin can let a Cmd+K command select a device, let the background maintain its connection and battery state, and display the result in a page. The page handles interaction; the background maintains device state. Each has its own lifetime. A plugin can also provide commands without a page.
Share one instance per plugin
Section titled “Share one instance per plugin”Within one window and one Space, a plugin has at most one running background. All callers in that plugin communicate with the same instance. Opening another file or invoking another command does not create a copy. Different windows, Spaces, and plugins have separate instances.
Page View ────┐File View A ──┤ Plugin backgroundFile View B ──┼─ messages → In-memory state, WebSocket, Bluetooth connectionCmd+K action ─┘ Lives independently of individual callersIn-memory state is lost on stop or crash. Plugins explicitly write data to private storage to retain it across runs. The host does not persist memory automatically, provide automatic recovery, or wake backgrounds with alarms.
Example: enhanced recent files
Section titled “Example: enhanced recent files”Record opening counts and last-opened times. The page displays and searches history, while the background collects opens after the page closes and saves changes every 10 seconds. Require Lite plugin API 3.6.0 and declare:
{ "requires": { "pluginApi": "3.6.0" },"background": { "entry": "./background.ts", "fileHistory": true, "autoStart": true }, "storage": { "maxBytes": 16777216 }}Lite injects globalThis.ctx, typed by the SDK’s BackgroundHost. With
fileHistory: true, ctx.events.on("file.opened", listener) receives { id, path, timestamp }
for successful opens in the current window and Space. Paths are Space-relative;
timestamps are Unix milliseconds. Events contain no file contents. Content reads,
resource previews, View rerenders and failed opens do not produce events. There is
no initial snapshot or replay of opens before startup.
import type { BackgroundHost } from "@eidos.space/plugin-sdk"declare const ctx: BackgroundHost
const subscription = ctx.events.on("file.opened", (event) => { console.log(event.path, event.timestamp)})// Dispose when no longer needed; stopping the background also releases it.// subscription.dispose()See the Host events API for event fields, permissions, delivery and cleanup rules.
With storage.maxBytes, ctx.storage supplies the same binary
list/read/write/delete API as Views and actions. Storage is device-local and
plugin-private, shared across Spaces. Use ctx.spaceId to partition Spaces and
ctx.instanceId to identify a run. Multiple windows must avoid overwriting the
same keys; a read followed by a write is not a transaction.
Install Recent History from Lite’s plugin marketplace, enable it in your Space, and approve automatic startup and file-open metadata access. It requires Eidos Lite 0.28.0 or later (Plugin API 3.6.0). The independent source repository includes the complete example and development instructions. Once enabled, use Cmd+K:
- 最近打开:打开面板: search paths, inspect counts, and reopen files.
The panel automatically connects to the background, starting it if needed. Recording
is automatic, with no start/stop controls. Disable the plugin to stop recording.
Close the panel, open other files, and reopen the panel. Its instance ID stays
the same and counts keep increasing. Failed saves remain pending and retry on the
next interval. Crashes or Space closure may lose unsaved events. After approval,
recording starts whenever this Space opens. background.isRunning() checks the current
run without starting it or requesting permission.
Declare the background entry
Section titled “Declare the background entry”Minimum plugin API version: 3.4.0.
Add background to plugin.json, pointing to a separate module. This is a
manifest fragment:
{ "requires": { "pluginApi": "3.4.0" }, "background": { "entry": "./background.ts" }}background.ts holds shared state and handles messages. Declare page and file
Views through views and register commands in extension as usual. They access
the instance through ctx.capabilities.background.
Start and stop the instance
Section titled “Start and stop the instance”With plugin API 3.6, declare background.autoStart: true to start when the plugin
is enabled or its Space opens. Lite asks for approval and remembers it for this
Space and exact package revision. Updates require renewed approval; disabling
revokes approval. Installation alone does not start a disabled plugin.
Startup runs once per Space opening. Stopping or crashing does not trigger a retry loop; use a command to resume, or reopen the Space. Lite does not run backgrounds while closed or restore memory. Bluetooth still requires device selection.
By default, installing or enabling a plugin does not start its instance. The first call to
start(), request(), postMessage(), or onMessage() from a View or command
requests host approval and starts it on demand. If it is already running, these
calls reuse it. requestDevice() uses a separate device approval flow and replaces
the previous run.
To let users decide when to connect to a device or service, make startup calls
inside a button or command handler. onMessage() can also start the background;
it is not a passive status query.
| Event | Result |
|---|---|
| Close a page or finish an action | Release that caller’s requests/subscriptions; keep the background |
| Cancel pending device selection | Grant nothing; preserve the previous run |
| Confirm a device | Replace the run and its device grant |
stop(), disable, uninstall or update |
Stop the background and release connections |
| Switch/close the Space or close its window | Stop that scope’s background |
| Exit Lite | Stop all backgrounds |
Development source reloads also revoke the old run. Reopen or invoke an entry point to request a new run. State is not restored after restart or a crash. Timer callbacks run only while the process is alive; they are not durable scheduled tasks or alarms that wake Lite.
Exchange messages
Section titled “Exchange messages”ctx.capabilities.background is available to Views and action handlers.
Register commands through ExtensionContext.capabilities.actions; use the
background capability on the action context supplied to the handler.
const result = await ctx.capabilities.background!.request({ command: "increment",})request(data) sends { requestId, data }. The worker replies with the same
requestId and places its result in data:
globalThis.onmessage = (event: MessageEvent) => { const { requestId, data } = event.data postMessage({ requestId, data: { received: data } })}export {}This request/reply envelope is an Eidos host convention. For raw messages use
postMessage(data) and await onMessage(listener). Messages must be JSON;
encode binary data explicitly. Application errors can be returned as data,
for example { ok: false, error: "Device unavailable" }; inspect that result.
The request Promise rejects on host errors, cancellation, exit or timeout.
Await replies before returning from an action. Its pending requests and subscriptions end with the action. View subscriptions end when that View closes, while the background continues running. A subscription belongs to one run: recreate it after a stop, device replacement or update.
Example: share a counter between a page and Cmd+K
Section titled “Example: share a counter between a page and Cmd+K”Create a directory containing the following four files. The code shown here
comes directly from the runnable packages/plugin-sandbox/examples/lite-background
example in the repository.
plugin.json
{ "apiVersion": 1, "id": "example.background-counter", "name": "Background Counter", "version": "1.0.0", "requires": { "pluginApi": "3.4.0" }, "background": { "entry": "./background.ts" }, "extension": "./extension.ts", "views": [ { "id": "counter", "title": "Background Counter", "kind": "page", "entry": "./page.ts" } ], "actions": [ { "id": "increment", "title": "Increment background counter", "context": "workspace" }, { "id": "stop", "title": "Stop background counter", "context": "workspace" } ], "placements": [ { "location": "navigation", "view": "counter" }, { "location": "command-palette", "action": "increment" }, { "location": "command-palette", "action": "stop" } ]}background.ts
// Runs in QuickJS. No DOM, Node, Electron or Eidos filesystem access.let count = 0globalThis.onmessage = (event: MessageEvent) => { const { requestId, data } = event.data if (data?.command === "increment") count++ postMessage({ requestId, data: { count } }) postMessage({ type: "count", count })}export {}page.ts
import type { Mount } from "@eidos.space/plugin-sdk"
const mount: Mount = (ctx, root) => { const background = ctx.capabilities.background! const output = document.createElement("pre") const increment = document.createElement("button") const stop = document.createElement("button") increment.textContent = "Increment" stop.textContent = "Stop background" const showError = (error: unknown) => { output.textContent = String(error) } increment.onclick = () => { void background .request({ command: "increment" }) .then((value) => { output.textContent = JSON.stringify(value) }) .catch(showError) } stop.onclick = () => { void background.stop().catch(showError) } root.append(increment, stop, output) // Complete mounting before waiting for the user's native permission dialog. void (async () => { try { await background.start() ctx.subscriptions.add( await background.onMessage((value) => { output.textContent = JSON.stringify(value) }) ) output.textContent = JSON.stringify( await background.request({ command: "read" }) ) } catch (error) { showError(error) } })()}export default mountextension.ts
import type { Activate } from "@eidos.space/plugin-sdk"
const activate: Activate = (ctx) => { ctx.capabilities.actions.register("increment", async ({ capabilities }) => { const result = await capabilities.background!.request({ command: "increment", }) await capabilities.ui.notify(JSON.stringify(result)) }) ctx.capabilities.actions.register("stop", async ({ capabilities }) => { await capabilities.background!.stop() await capabilities.ui.notify("Background stopped") })}export default activateIn a Lite build supporting API 3.4.0:
- Open a test Space and choose Plugin Manager → Load development source….
- Select the directory’s
plugin.jsonand enable the plugin. - Open Background Counter from navigation and approve startup.
- Click Increment, then close the page.
- Run Increment background counter from Cmd+K.
- Reopen the page. The count includes the command’s increment.
- Run Stop background counter. The next start asks for permission again and begins with fresh in-memory state.
To run the example from the Eidos repository, start Lite with
pnpm dev:eidos-lite at the repository root. This prepares the native
sandbox executable. Use the current source-built plugin tools and SDK when
checking a feature not yet present in your installed tools.
Allow a network connection
Section titled “Allow a network connection”Declare network: true in background to request public HTTPS/WSS access:
{ "entry": "./background.ts", "network": true }Optionally add networkOrigins to restrict destinations. A whitelist alone also works:
{ "entry": "./background.ts", "networkOrigins": ["https://api.example.com", "wss://events.example.com"]}The approval dialog describes public access or lists the restricted origins.
When both fields are present, the whitelist takes precedence. An empty list denies
all destinations; omitting both fields grants no network access. Browser
browser.networkOrigins grants do not authorize background access.
Private addresses are not granted in normal runs; the local development example
below requires a separate exact loopback grant. Redirects remain subject to the
sandbox network policy. Replace example origins with your actual service.
In the background module:
const socket = new WebSocket("wss://events.example.com")socket.onmessage = (event) => { postMessage({ type: "server-message", data: String(event.data) })}socket.onclose = () => postMessage({ type: "connection-closed" })socket.onerror = () => postMessage({ type: "connection-error" })Use fetch("https://api.example.com/status") for an HTTP request.
Views and ordinary commands requiring Lite API 3.5 use the same
Worker-style fetch interface, including forms, response
streams and cancellation, with independent origin declarations.
A network grant does not provide an authenticated Eidos connection or account
credentials. The host does not automatically reconnect a failed socket.
Example: choose and connect a BLE battery device
Section titled “Example: choose and connect a BLE battery device”Use a BLE peripheral exposing Battery Service (0x180F) and Battery Level
(0x2A19). This example reads its battery level and subscribes when
notifications are supported. BLE GATT does not route audio to Bluetooth speakers.
Create another plugin directory with these five files. They are the runnable
packages/plugin-sandbox/examples/lite-bluetooth example. The local types
describe the Web Bluetooth-style subset used by the worker.
plugin.json
{ "apiVersion": 1, "id": "example.bluetooth-battery", "name": "Bluetooth Battery", "version": "1.0.0", "requires": { "pluginApi": "3.4.0" }, "background": { "entry": "./background.ts", "bluetooth": true }, "extension": "./extension.ts", "views": [ { "id": "battery", "title": "Bluetooth Battery", "kind": "page", "entry": "./page.ts" } ], "actions": [ { "id": "open-panel", "title": "蓝牙电量:打开面板", "context": "workspace" }, { "id": "connect", "title": "蓝牙电量:选择并连接设备", "context": "workspace" }, { "id": "status", "title": "蓝牙电量:查看连接与电量", "context": "workspace" }, { "id": "stop", "title": "蓝牙电量:断开并停止后台", "context": "workspace" } ], "placements": [ { "location": "navigation", "view": "battery" }, { "location": "command-palette", "action": "open-panel" }, { "location": "command-palette", "action": "connect" }, { "location": "command-palette", "action": "status" }, { "location": "command-palette", "action": "stop" } ]}background.ts
// QuickJS worker: selection and grants belong to Lite; connecting requires a message.import type { Device, Characteristic } from "./bluetooth-types"type Command = { command: string; id?: string }let device: Device | null = nulllet battery: Characteristic | null = nulllet percent: number | null = nulllet phase = "idle"let failure: string | null = nulllet failedAt: string | null = nullconst progress = (next: string) => { phase = next postMessage({ type: "status", ...state() })}let queue = Promise.resolve()const state = () => ({ id: device?.id ?? null, name: device?.name ?? null, connected: device?.gatt.connected ?? false, percent, phase, error: failure, failedAt,})async function disconnect() { if (!device?.gatt.connected) return const done = new Promise<void>((resolve) => device!.addEventListener("gattserverdisconnected", () => resolve(), { once: true, }) ) device.gatt.disconnect() await done}async function handle(data: Command) { switch (data.command) { case "connect": { if (device?.gatt.connected && battery && phase === "connected") return state() failure = null failedAt = null percent = null battery = null device = (await navigator.bluetooth.getDevices()).find( (d) => d.id === data.id ) ?? null if (!device) throw new Error("The host has not granted this device") device.ongattserverdisconnected = () => { battery = null percent = null if (phase !== "failed") phase = "disconnected" postMessage({ type: "disconnected", ...state() }) } try { progress("connecting") const server = await device.gatt.connect() progress("services") const service = await server.getPrimaryService("battery_service") battery = await service.getCharacteristic("battery_level") battery.addEventListener("characteristicvaluechanged", (event) => { const value = (event.target as Characteristic).value if (value?.byteLength) percent = value.getUint8(0) postMessage({ type: "battery", ...state() }) }) progress("reading") const value = await battery.readValue() if (!value.byteLength) throw new Error("Battery Level returned an empty value") if (value.byteLength) percent = value.getUint8(0) if (battery.properties.notify || battery.properties.indicate) await battery.startNotifications() progress("connected") } catch (error) { failure = String(error) failedAt = phase phase = "failed" postMessage({ type: "status", ...state() }) await disconnect() throw error } return state() } case "read": { if (!battery) throw new Error("Choose and connect a battery device first") const value = await battery.readValue() if (value.byteLength) percent = value.getUint8(0) return state() } case "status": return state() case "disconnect": await disconnect() return state() default: throw new Error("Unknown command") }}globalThis.onmessage = ({ data: { requestId, data },}: MessageEvent<{ requestId: string; data: Command }>) => { queue = queue.then(async () => { try { postMessage({ requestId, data: { ok: true, ...(await handle(data)) } }) } catch (error) { postMessage({ requestId, data: { ...state(), ok: false, error: String(error) }, }) } })}bluetooth-types.ts
// The Web Bluetooth-style subset used by this example. No runtime code.export interface Characteristic extends EventTarget { readonly value?: DataView readonly properties: { notify: boolean; indicate: boolean } readValue(): Promise<DataView> startNotifications(): Promise<Characteristic>}interface Service { getCharacteristic(uuid: string): Promise<Characteristic>}interface Server { readonly connected: boolean connect(): Promise<Server> disconnect(): void getPrimaryService(uuid: string): Promise<Service>}export interface Device extends EventTarget { readonly id: string readonly name?: string readonly gatt: Server ongattserverdisconnected: (() => void) | null}declare global { interface Navigator { readonly bluetooth: { getDevices(): Promise<Device[]> } }}page.ts
import type { Mount, Disposable } from "@eidos.space/plugin-sdk"import { labels, parseState, type BatteryState } from "./status"
const mount: Mount = (ctx, root) => { const background = ctx.capabilities.background! let subscription: Disposable | undefined let generation = 0 let busy = false let disposed = false let state: BatteryState = { phase: "idle", id: null, name: null, percent: null, error: null, } let failedAt = "" root.innerHTML = `<style> .battery-panel { max-width: 520px; margin: 40px auto; padding: 24px; font: 14px/1.6 system-ui; color: var(--foreground, inherit); } .battery-panel h1 { font-size: 20px; margin: 0 0 24px; } .battery-panel h2 { font-size: 17px; margin: 0; } .battery-panel p { margin: 8px 0; } .battery-panel .muted { opacity: .65; font-size: 13px; } .battery-panel .value { font-size: 48px; font-weight: 600; margin: 20px 0; } .battery-panel .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 24px; } .battery-panel button { font: inherit; padding: 7px 14px; border: 1px solid var(--border, #8885); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; } .battery-panel button:disabled { opacity: .45; cursor: default; } .battery-panel .primary { background: var(--primary, #247080); color: var(--primary-foreground, white); } .battery-panel details { margin-top: 16px; overflow-wrap: anywhere; } .battery-panel [hidden] { display: none; } </style><section class="battery-panel"> <h1>蓝牙电量</h1> <h2 data-device>选择设备</h2> <p data-status role="status" aria-live="polite"></p> <p class="value" data-value hidden></p> <p data-help class="muted"></p> <details data-details hidden><summary>错误详情</summary><p data-error></p></details> <div class="actions"> <button class="primary" data-choose>选择设备</button> <button data-retry hidden>重试连接</button> <button data-read hidden>刷新电量</button> <button data-stop hidden>断开连接</button> <button data-resume>恢复后台状态</button> </div> <p class="muted">支持提供标准电量服务的低功耗蓝牙设备。关闭页面后连接继续保持。</p> </section>` const element = <T extends HTMLElement>(name: string) => root.querySelector<T>("[data-" + name + "]")! const render = () => { if (disposed) return element("device").textContent = state.name || (state.id ? "未命名设备" : "选择设备") element("status").textContent = labels[state.phase] element("value").hidden = state.phase !== "connected" || state.percent === null element("value").textContent = state.percent + "%" element("details").hidden = !state.error element("error").textContent = state.error element("help").textContent = state.phase === "failed" ? failedAt === "services" ? "无法使用设备的标准电量服务。请确认设备支持 Battery Service,或选择其他设备。" : "请确认设备在附近、可连接,且未被其他应用占用,然后重试。" : state.phase === "disconnected" ? "设备连接已断开,电量不再更新。可以重试连接或选择其他设备。" : busy ? "请稍候;连接结果会显示在这里。" : state.phase === "connected" ? "电量读取成功,连接由后台保持。" : "点击选择设备开始连接。" element<HTMLButtonElement>("choose").disabled = busy element("choose").textContent = state.id ? "换个设备" : "选择设备" element("retry").hidden = !state.id || !["failed", "disconnected"].includes(state.phase) element<HTMLButtonElement>("retry").disabled = busy element("read").hidden = state.phase !== "connected" element<HTMLButtonElement>("read").disabled = busy element("stop").hidden = state.phase === "idle" || state.phase === "stopped" || state.phase === "selecting" element("stop").textContent = busy ? "取消连接" : "断开连接" element<HTMLButtonElement>("resume").disabled = busy element("resume").hidden = state.phase === "connected" || busy } const receive = (value: unknown) => { if (disposed) return if (value && typeof value === "object" && "failedAt" in value) failedAt = String(value.failedAt) state = parseState(value) render() } const subscribe = async (run: number) => { subscription?.dispose() const next = await background.onMessage((value) => { if (run === generation) receive(value) }) if (run !== generation || disposed) next.dispose() else subscription = next } const execute = async (operation: (run: number) => Promise<void>) => { if (busy) return busy = true const run = ++generation render() try { await operation(run) } catch (error) { if (run === generation && !disposed) { const message = String(error) state = { ...state, phase: message.includes("selection cancelled") ? "idle" : "failed", error: message.includes("selection cancelled") ? null : message, } } } finally { if (run === generation) { busy = false render() } } } const connect = async (run: number) => { if (run !== generation) return state = { ...state, phase: "connecting", percent: null, error: null } failedAt = "" render() await subscribe(run) if (run !== generation) return const result = await background.request({ command: "connect", id: state.id, }) if (run === generation) receive(result) } element("choose").onclick = () => void execute(async (run) => { const previous = state state = { ...state, phase: "selecting", error: null } render() let device: { id: string; name?: string } try { device = await background.requestDevice() } catch (error) { if (String(error).includes("selection cancelled")) { state = previous if ( previous.phase === "connected" || previous.phase === "disconnected" ) await subscribe(run) return } throw error } if (run !== generation) return state = { ...state, id: device.id, name: device.name ?? null } await connect(run) }) element("retry").onclick = () => void execute(connect) element("read").onclick = () => void execute(async (run) => { await subscribe(run) const result = await background.request({ command: "read" }) if (run === generation) receive(result) }) element("resume").onclick = () => void execute(async (run) => { await subscribe(run) const result = await background.request({ command: "status" }) if (run === generation) receive(result) }) element("stop").onclick = () => { element<HTMLButtonElement>("stop").disabled = true ++generation busy = true subscription?.dispose() subscription = undefined render() void background .stop() .then(() => { state = { ...state, phase: "stopped", percent: null, error: null } }) .catch((error) => { state = { ...state, phase: "failed", error: String(error) } }) .finally(() => { busy = false element<HTMLButtonElement>("stop").disabled = false render() }) } render() ctx.subscriptions.add({ dispose: () => { disposed = true ++generation subscription?.dispose() }, })}export default mountextension.ts
import type { Activate } from "@eidos.space/plugin-sdk"import { labels, parseState } from "./status"const activate: Activate = (ctx) => { ctx.capabilities.actions.register("open-panel", async ({ capabilities }) => { if (!capabilities.ui.navigate) throw new Error("当前宿主不支持打开插件面板") await capabilities.ui.navigate("battery", "/") }) ctx.capabilities.actions.register("connect", async ({ capabilities }) => { const background = capabilities.background! const device = await background.requestDevice() await capabilities.ui.notify( "正在连接 " + (device.name || "蓝牙设备") + ",并检查电量服务…" ) const result = await background.request({ command: "connect", id: device.id, }) const state = parseState(result) await capabilities.ui.notify( state.phase === "connected" ? (state.name || "设备") + " 已连接 · 电量 " + state.percent + "%" : "连接失败:" + (state.error || labels[state.phase]) ) }) ctx.capabilities.actions.register("status", async ({ capabilities }) => { const state = parseState( await capabilities.background!.request({ command: "status" }) ) await capabilities.ui.notify( (state.name || "蓝牙设备") + " · " + labels[state.phase] + (state.phase === "connected" && state.percent !== null ? " · " + state.percent + "%" : "") + (state.error ? ":" + state.error : "") ) }) ctx.capabilities.actions.register("stop", async ({ capabilities }) => { await capabilities.background!.stop() await capabilities.ui.notify("已断开连接,后台已停止,设备授权已撤销。") })}export default activate- Load its
plugin.jsonthrough Plugin Manager and enable it. - Open Bluetooth Battery → 选择设备 (Choose device), or run 蓝牙电量:选择并连接设备 in Cmd+K.
- Lite’s device picker scans automatically when opened; allow operating-system Bluetooth access if prompted. Put the peripheral into advertising mode.
- Select a device row and click Connect to authorize that device. You can scan again or cancel in the same picker.
- Lite starts a fresh background with that device’s grant. The page or action
sends a connect message; the worker calls
getDevices()andgatt.connect(). - The page shows connection, service discovery, and battery-reading progress. Success appears only after the battery read succeeds; failures retain the device and offer retry or device selection. Cancel an in-flight connection to stop the background. A disconnect hides the old battery value. Close the page. Use 蓝牙电量:查看连接与电量 from Cmd+K to inspect the retained connection. Reopen the page and click 恢复后台状态 (Resume background status) to subscribe to the current run.
- Run 蓝牙电量:断开并停止后台 to end the process and revoke its grant.
The example uses "bluetooth": true, allowing reads, writes and notifications on
the user-selected device’s GATT characteristics. Its code still only reads and
subscribes to battery level, and the device must provide Battery Service.
The manifest cannot supply device IDs. Only the selected device is returned by background
navigator.bluetooth.getDevices(). The worker cannot open its own picker:
navigator.bluetooth.requestDevice() is unavailable there.
A service whitelist is an optional restriction. For example, to allow only Battery Service:
{ "bluetooth": { "services": [{ "uuid": "battery_service" }] } }Omitting operation fields allows the service’s supported reads, writes and notifications.
To narrow access further, declare "read": true, "notify": true. If any operation
field is present, omitted operations are denied. Unsupported device operations still fail.
Cancelling selection preserves the current run. Confirming a device replaces it, resetting memory and disconnecting the previous device. Other pages must resubscribe. If the new connection fails, the old one is not restored. Names and advertisements are not cryptographic proof of device identity, and an advertisement does not guarantee that a service is available.
Grants are per run and are not persisted. A normal start() never grants
Bluetooth. Device loss emits gattserverdisconnected; the example reports
the loss without silently reconnecting.
File Views sharing a WebSocket
Section titled “File Views sharing a WebSocket”WebSocket Workbench uses .wsreq text files as saved request fixtures.
Load packages/plugin-sandbox/examples/lite-file-websocket as a development plugin,
copy its samples/*.wsreq into your Space, and open both with this View.
Click Send sample and approve the host’s network permission dialog.
Run the included Python server and the development host in separate terminals:
python3 packages/plugin-sandbox/examples/lite-file-websocket/server.pyEIDOS_LITE_DEV_BACKGROUND_ORIGIN=ws://127.0.0.1:8787 pnpm dev:eidos-litePython uses only its standard library and listens on loopback. Opening a file sends
nothing; Send reads the current document and sends its text without the path.
The development grant must match the exact literal 127.0.0.1 origin and port,
cannot be mixed with other origins, and still requires the host’s permission dialog.
Packaged Lite ignores this environment variable. For production, change both the
background endpoint and manifest origin to public WSS; private addresses remain denied.
The server must echo { id, text } unchanged. The Python server supports this
example’s unfragmented text frames and is not a production WebSocket server.
File A ─┐ ┌─ reply to A ├─ background.request → one WebSocket ──┤File B ─┘ └─ reply to BThe background is shared within one window, Space and plugin. Concurrent connects
share a promise; separate wire IDs map out-of-order echoes to host request IDs.
background.request() returns each response to its caller, so no broadcast
subscription or session API is needed. This routing is not a security boundary
between Views of the same plugin.
The connection card explicitly labels a newly created or reused socket, with its
message number, connection ID and creation time. It describes the last successful
response, rather than continuously monitoring connection health.
Both responses show the same connection ID. Close one file and send the other:
the ID stays the same while the server keeps the socket alive. Closing a View does
not call stop(). CmdK’s WebSocket Workbench: stop shared connection stops it
for all Views; the next Send starts a fresh run. Server disconnects fail pending
requests; later sends reconnect without replaying messages. Connection and echo
timeouts are each eight seconds, with 16,000 characters per sample and 32 pending requests.
plugin.json
{ "apiVersion": 1, "id": "example.websocket-workbench", "name": "WebSocket Workbench", "version": "1.0.0", "requires": { "pluginApi": "3.4.0" }, "background": { "entry": "./background.ts", "networkOrigins": ["ws://127.0.0.1:8787"] }, "extension": "./extension.ts", "views": [ { "id": "request", "title": "WebSocket Workbench", "kind": "file", "entry": "./file.ts", "capabilities": ["document"], "access": "read" } ], "actions": [ { "id": "stop", "title": "WebSocket Workbench: stop shared connection", "context": "workspace" } ], "placements": [ { "location": "file/open", "view": "request", "extensions": [".wsreq"] }, { "location": "command-palette", "action": "stop" } ]}background.ts
const endpoint = "ws://127.0.0.1:8787/echo"let socket: WebSocket | undefinedlet connecting: Promise<WebSocket> | undefinedlet sequence = 0let connections = 0let connectedAt = 0let sentOnConnection = 0const run = `${Date.now()}-${Math.random().toString(36).slice(2)}`const pending = new Map< number, { resolve: (text: string) => void; reject: (error: Error) => void }>()
function connect(): Promise<WebSocket> { if (socket?.readyState === WebSocket.OPEN) return Promise.resolve(socket) if (connecting) return connecting const ws = new WebSocket(endpoint) socket = ws connecting = new Promise<WebSocket>((resolve, reject) => { const fail = () => { clearTimeout(timer) if (socket !== ws) return socket = undefined reject(new Error("Connection closed. Click Send to reconnect.")) for (const item of pending.values()) item.reject(new Error("Connection lost")) pending.clear() } const timer = setTimeout(() => { fail() ws.close() }, 8000) ws.onopen = () => { clearTimeout(timer) connections++ connectedAt = Date.now() sentOnConnection = 0 resolve(ws) } ws.onerror = fail ws.onclose = fail ws.onmessage = (event: MessageEvent) => { try { const message = JSON.parse(String(event.data)) as { id: number text: string } if (typeof message.text === "string") pending.get(message.id)?.resolve(message.text) } catch { /* Ignore non-protocol server messages. */ } } }).finally(() => { connecting = undefined }) return connecting}
async function send(text: string) { // Bound allocation before connecting; the host also caps concurrent RPCs. if (text.length > 16000) throw new Error("Use a sample of at most 16,000 characters") const ws = await connect() if (pending.size >= 32) throw new Error("Too many pending requests") const id = ++sequence const connection = `${run}/${connections}` const establishedAt = connectedAt const messageNumber = ++sentOnConnection const started = Date.now() const echoed = await new Promise<string>((resolve, reject) => { const timer = setTimeout(() => { pending.delete(id) reject(new Error("Echo timed out")) }, 8000) const finish = () => { clearTimeout(timer) pending.delete(id) } pending.set(id, { resolve: (value) => { finish() resolve(value) }, reject: (error) => { finish() reject(error) }, }) try { ws.send(JSON.stringify({ id, text })) } catch (error) { finish() reject(error) } }) return { ok: true, text: echoed, connection, connectedAt: establishedAt, messageNumber, reused: messageNumber > 1, elapsedMs: Date.now() - started, }}
globalThis.onmessage = (event: MessageEvent) => { const { requestId, data } = event.data void (async () => { try { if (data?.command !== "send" || typeof data.text !== "string") throw new Error("Expected send with text") postMessage({ requestId, data: await send(data.text) }) } catch (error) { postMessage({ requestId, data: { ok: false, error: String(error) } }) } })()}export {}file.ts
import type { Mount } from "@eidos.space/plugin-sdk"
const mount: Mount = (ctx, root) => { const background = ctx.capabilities.background! const documentApi = ctx.capabilities.document! const title = document.createElement("h1") title.textContent = "WebSocket Workbench" const description = document.createElement("p") description.textContent = "Send this sample to ws://127.0.0.1:8787/echo. All open requests share one background connection." const preview = document.createElement("pre") const output = document.createElement("pre") const connectionCard = document.createElement("section") connectionCard.setAttribute("aria-live", "polite") connectionCard.style.cssText = "padding:20px;border:1px solid currentColor;border-radius:12px;margin:20px 0" const status = document.createElement("strong") status.style.cssText = "display:block;font-size:22px" status.textContent = "等待发送" const metrics = document.createElement("p") metrics.style.cssText = "font-size:28px;font-weight:600;margin:12px 0" metrics.textContent = "文件 A / 文件 B → 同一个后台 → 一个 WebSocket" const identity = document.createElement("div") identity.style.cssText = "font:12px/1.8 monospace;overflow-wrap:anywhere" identity.textContent = "发送后显示共享连接编号和本连接累计消息序号。" const hint = document.createElement("p") hint.textContent = "验证方法:发送此文件 → 关闭文件 → 打开另一个 .wsreq 并发送。编号不变、序号继续增加,就复用了连接。" connectionCard.append(status, metrics, identity, hint) const button = document.createElement("button") button.textContent = "Send sample" output.textContent = "Ready · no data sent" root.style.cssText = "padding:24px;max-width:880px;margin:auto;font:14px/1.6 system-ui" for (const element of [preview, output]) element.style.cssText = "white-space:pre-wrap;overflow-wrap:anywhere;padding:16px;border:1px solid currentColor;border-radius:8px" button.style.cssText = "padding:8px 16px;font:inherit;cursor:pointer" root.append(title, description, connectionCard, preview, button, output) void documentApi .read() .then((value) => { if (!ctx.signal.aborted) preview.textContent = value.text }) .catch((error: unknown) => { output.textContent = String(error) }) button.onclick = () => { button.disabled = true output.textContent = "Connecting / waiting for echo…" status.textContent = "正在发送…" void (async () => { try { const { text } = await documentApi.read() if (ctx.signal.aborted) return preview.textContent = text const response = await background.request({ command: "send", text }) if (!ctx.signal.aborted) { output.textContent = JSON.stringify(response, null, 2) if ( response && typeof response === "object" && !Array.isArray(response) && response.ok === true ) { status.textContent = response.reused ? "✓ 复用了已有 WebSocket" : "● 本次建立了新 WebSocket" metrics.textContent = `1 条连接 · 第 ${String(response.messageNumber)} 条消息` identity.textContent = `连接编号:${String(response.connection)}\n建立时间:${new Date(Number(response.connectedAt)).toLocaleTimeString()} · 本次耗时:${String(response.elapsedMs)} ms` identity.style.whiteSpace = "pre-wrap" hint.textContent = response.reused ? "本次发送没有新建连接。后台在文件关闭或切换后继续持有这条连接。" : "现在关闭此文件,打开另一份样本并发送:应显示“复用了已有 WebSocket”,消息序号变为 2。" } else { status.textContent = "发送失败" hint.textContent = "检查 Python 服务是否运行;再次点击发送可重试。下方保留错误详情。" } } } catch (error) { if (!ctx.signal.aborted) { status.textContent = "发送失败" output.textContent = String(error) } } finally { if (!ctx.signal.aborted) button.disabled = false } })() } // Disposing a view must not stop the shared background.}export default mountextension.ts
import type { Activate } from "@eidos.space/plugin-sdk"const activate: Activate = (ctx) => { ctx.capabilities.actions.register("stop", async ({ capabilities }) => { await capabilities.background!.stop() await capabilities.ui.notify("Shared WebSocket stopped") })}export default activateserver.py
"""Loopback-only demo echo server; Python standard library, no dependencies.
Supports the unfragmented text frames used by this plugin, plus ping/close.Not a general-purpose or production WebSocket server."""import base64import hashlibimport structfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Echo(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1"
def do_GET(self): if self.path != "/echo" or self.headers.get("Upgrade", "").lower() != "websocket": self.send_error(400, "Use WebSocket /echo") return key = self.headers.get("Sec-WebSocket-Key", "") accept = base64.b64encode(hashlib.sha1( (key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode() ).digest()).decode() self.send_response(101) self.send_header("Upgrade", "websocket") self.send_header("Connection", "Upgrade") self.send_header("Sec-WebSocket-Accept", accept) self.end_headers() self.close_connection = True print("CONNECTED", self.client_address, flush=True) self.connection.settimeout(600) try: while True: header = self.rfile.read(2) if len(header) != 2: break opcode, size = header[0] & 15, header[1] & 127 if not header[0] & 128 or not header[1] & 128 or opcode not in (1, 8, 9): break if size == 126: size = struct.unpack("!H", self.rfile.read(2))[0] elif size == 127: size = struct.unpack("!Q", self.rfile.read(8))[0] if size > 1024 * 1024: break mask = self.rfile.read(4) data = self.rfile.read(size) if len(mask) != 4 or len(data) != size: break data = bytes(value ^ mask[index % 4] for index, value in enumerate(data)) reply_opcode = 10 if opcode == 9 else opcode prefix = bytes([128 | reply_opcode, size]) if size < 126 else bytes([128 | reply_opcode, 126]) + struct.pack("!H", size) if size < 65536 else bytes([128 | reply_opcode, 127]) + struct.pack("!Q", size) self.wfile.write(prefix + data) self.wfile.flush() print("ECHO", self.client_address, size, "bytes", flush=True) if opcode == 8: break except (OSError, ValueError, struct.error): pass finally: print("DISCONNECTED", self.client_address, flush=True)
def server(port=8787): return ThreadingHTTPServer(("127.0.0.1", port), Echo)
if __name__ == "__main__": with server() as instance: print("Listening on ws://127.0.0.1:8787/echo", flush=True) try: instance.serve_forever() except KeyboardInterrupt: passRuntime environment and limits
Section titled “Runtime environment and limits”The module runs in a Rust + QuickJS process with Worker-style messages, timers,
fetch and WebSocket. It has no DOM, Node module loader, Electron access,
filesystem API or Eidos data capabilities. Network and device access require
explicit host grants. This is capability isolation, not an operating-system sandbox.
The host checks Space authorization on every request and polls it at 500 ms intervals. A run has a 64 MiB JavaScript heap and a 100 ms execution budget per turn. Message requests have a 30-second deadline, with at most 64 pending requests per process and 16 backgrounds per application. Device selection has a five-minute deadline. Initial background approval also has a five-minute deadline and is excluded from the command’s normal execution budget. Stopping, revoking, or closing the initiating caller cancels pending startup; a late confirmation cannot start that cancelled run. Closing a caller after startup does not stop the background.
Messages and logs have no lifetime byte ceiling. Individual output frames are limited to 1 MiB. Each one-second output window permits 16 MiB of messages; exceeding this message rate terminates the run. Logs have a separate 1 MiB window; excess logs are dropped without terminating the run. The Node SDK additionally limits each output pipe to 32 MiB per second and bounds individual frames. Long work should yield between bounded steps.
Verify your plugin
Section titled “Verify your plugin”Test page close/reopen, commands without an open page, cancellation, permission denial, stop/restart, update, and Space switching. For BLE, also test duplicate device names, unsupported services, device loss, and choosing a replacement. Confirm that denied device IDs and undeclared services remain inaccessible.
Native scanning requires a working BLE adapter and OS permission; Linux also requires BlueZ and D-Bus. Automated tests use a real QuickJS process with an explicit mock BLE backend. They do not establish real-device or signed-package permission acceptance. Check those on each platform you distribute.
Use development and distribution to check and package the plugin. Exact manifest fields and methods are in the API reference.