@lovision/plugin-sdk
v1.1.0
Published
Worker-side SDK for Instinct plugins.
Readme
@lovision/plugin-sdk
Worker-side SDK for Instinct plugins.
Published from the main branch through npm Trusted Publishing.
This package is the intentional public contract for plugin code that runs inside the worker sandbox. It does not include local dev/build/scaffold tooling, editor UI, install confirmation, or diagnostics panels.
Companion guides
Step 14 also ships task-first docs for external plugin authors under:
docs/plugin-system-guides/external-developers/README.mdGetting StartedManifest, Permissions, and Network AllowlistDiagnostics: Lanes, Rows, and Traces
Public surface
@lovision/plugin-sdk exports one runtime value and a small set of typing
helpers:
definePlugin(...)PluginContextDefinePluginOptionsCommandInvocation- conflict / event / transactional-snapshot types that plugin code may want to reference explicitly
Internal runtime classes (SdkRuntime, createContext, facade classes, test
fixtures) are intentionally not part of the package root.
Supported worker API
PluginContext exposes:
ctx.call<T>(method, params, opts?)— raw host capability invokectx.handle(method, handler)— worker-side responder registrationctx.selection.{get,set,clear,count}ctx.nodes.{update,create,remove,move,resize,rename}ctx.document.{snapshot,snapshotDiff,transactionalSnapshot}ctx.events.on("documentchange" | "selectionchange", handler)ctx.viewport.{get,scrollToSelection,focusNode}ctx.storage.localctx.storage.documentctx.storage.node(nodeId)ctx.notify.send(message, options?)ctx.closePlugin(message?)
All typed wrappers still sit on top of host-owned permissions and runtime
checks. Declaring a manifest permission makes the call legal; omitting it
causes a PluginError(code: "PERMISSION_DENIED").
Entry shapes
definePlugin accepts two callbacks; either or both can be provided.
command
When PluginManager.invoke(pluginId, commandId, params) runs, the host sends
an internal __runCommand RPC. The SDK auto-registers a handler that dispatches
to your command callback:
import { definePlugin } from "@lovision/plugin-sdk";
definePlugin({
command: async (ctx, { id, params }) => {
const root = await ctx.call("document.snapshot", undefined);
return { processed: id, params };
},
});run
run fires once when the worker boots. Use it to register ctx.handle
responders that should exist before the host starts dispatching commands:
definePlugin({
run: (ctx) => {
ctx.handle("ping", () => ({ pong: true }));
},
});If you provide run but not command, the SDK still wires __runCommand
and falls back to invoking run(ctx) again — handy for single-command
plugins where setup is the command body.
Typed wrappers
PluginContext exposes typed wrappers over the host facade:
definePlugin({
command: async (ctx) => {
const ids = await ctx.selection.get(); // string[]
if (ids.length === 0) {
await ctx.notify.send("Nothing selected", { kind: "warning" });
await ctx.closePlugin();
return;
}
const result = await ctx.nodes.update(
ids.map((id) => ({ id, changes: { opacity: 0.5 } })),
);
await ctx.storage.document.set("clean-selection:last-run", {
count: ids.length,
at: 1_713_568_000_000,
});
await ctx.notify.send(`Updated ${ids.length} node(s) (v${result.newVersion})`);
await ctx.closePlugin("done");
},
});| API | Permission | Notes |
| --- | --- | --- |
| ctx.selection.get() | selection:read | returns the host editor's current selection |
| ctx.selection.set(ids) / clear() / count() | selection:write or selection:read | selection wrappers |
| ctx.nodes.update(updates, opts?) | document:write.style | supports expectedVersion and Step 6 conflict handling |
| ctx.nodes.create/remove/move/resize/rename | matching document:write.* scope | structural and text/layout mutations |
| ctx.document.snapshot() | document:read | frozen SceneSnapshot |
| ctx.document.snapshotDiff(fromVersion) | document:read | snapshot-native diff (upsert / remove) |
| ctx.document.transactionalSnapshot() | document:read plus matching write scope when flushed | SDK-local writable proxy over document.snapshot() |
| ctx.events.on(name, handler) | document:read or selection:read | documentchange / selectionchange |
| ctx.viewport.get() / scrollToSelection() / focusNode(nodeId) | viewport:read / viewport:write | best-effort editor navigation |
| ctx.storage.local | storage:local | plugin-private local KV |
| ctx.storage.document | storage:document | document-scoped plugin KV |
| ctx.storage.node(nodeId) | storage:node | node-scoped plugin KV |
| ctx.notify.send(msg, options?) | notify | host notification |
| ctx.closePlugin(message?) | none | closes the current invocation |
Calls without the requisite manifest permission reject with
PluginError(code: "PERMISSION_DENIED", data: { method, required, declared, missing }).
WRITE_CONFLICT rejects carry data: { currentVersion, expectedVersion, conflictingNodes?, missing? }.
Storage values are limited to JSONValue (null | boolean | number | string | JSONValue[] | { [k]: JSONValue }).
Transactional writes and events
snapshotDiff and events.on(...) are meant to work together:
const snap = await ctx.document.snapshot();
const off = await ctx.events.on("documentchange", async (event) => {
const diff = await ctx.document.snapshotDiff(event.fromVersion);
console.log(diff.changedNodeIds);
});ctx.events.on("documentchange", handler)requiresdocument:readctx.events.on("selectionchange", handler)requiresselection:read- the SDK auto-unsubscribes any remaining listeners when the current command invocation ends
ctx.document.transactionalSnapshot() is the read-then-write path for
Figma-like plugins:
const tx = await ctx.document.transactionalSnapshot();
tx.root.children[0].opacity = 1;
tx.root.children[0].name = "Hero";
await tx.flush({ onConflict: "retry" });- reads see pending writes before flush
pendingPatch()exposes the unflushedNodeUpdate[]cancel()discards only the current pending patch- unflushed transactions auto-flush on normal command return and before
ctx.closePlugin() - uncaught command errors do not auto-flush
ctx.nodes.update(...) also accepts onConflict: "abort" | "retry" | handler
for direct patch-based flows.
Diagnostics and Phase 1c runtime semantics
Step 13 added host-owned diagnostics semantics that plugin authors need to know even though the UI does not live in this package:
network:fetchis still manifest-gated and allowlist-gated. Declaring the permission alone is not enough; the host also enforces the allowed domains.network deniedrow summaries show the rejected host plus a short trace ID.- the editor
Logssurface keeps the full trace, URL, and payload. - diagnostics are lane-owned by the host:
- formal-install issues appear on Installed/MainMenu-facing rows
- development overlay issues stay on the Development row
- one lane clearing does not clear the other
ESLint subpath
The package ships a minimal plugin-facing ESLint flat config at
@lovision/plugin-sdk/eslint-config.
import pluginSdkLint from "@lovision/plugin-sdk/eslint-config";
export default [...pluginSdkLint];Today that config intentionally only enables one high-value warning:
no-await-in-loop
The goal is to catch the most common worker-side anti-pattern early. If the
warning fires, restructure the loop into staged concurrency (Promise.all,
batched requests, or a serial loop that no longer awaits inside the iterator
body).
Explicit non-goals
- no local HTTPS dev server
- no bundle builder or manifest validator
- no
create-instinct-pluginscaffold - no editor menu / dev panel / install confirmation UI
- no public fixture modules
Those surfaces are intentionally outside @lovision/plugin-sdk so the worker
contract can stay small and stable.
