Files
HiveGrid-Wireframes/hivegrid-cloud/docs/plugins/sdk-reference.md
T

211 lines
9.2 KiB
Markdown

# Plugin SDK Reference
> `@toolctrl/plugin-sdk` — the typed surface plugin authors compile
> against. Mirrors the runtime capability set so an SDK upgrade is a
> straight version bump on the author side.
## Installing
The SDK lives in the host monorepo today as `workspace:*`. External
plugin authors install it as a regular npm dependency:
```bash
pnpm add -D @toolctrl/plugin-sdk
```
## Top-level exports
| Export | Kind | Purpose |
| ----------------------------------------- | ----- | --------------------------------------------------------------------------- |
| `defineHook` | fn | Identity wrapper that captures `Input`/`Output` types at the call site |
| `createPluginSettingsClient` | fn | Settings iframe helper around the host `postMessage` protocol |
| `HookHandler` | type | `(input, ctx) => Output \| Promise<Output>` |
| `PluginContext` | type | Per-invocation context (logger, capabilities, tenant info) |
| `HostApi` | type | Aggregate of optional bindings; only the declared ones are bound |
| `PluginCapabilityName` | type | Closed enum of capabilities |
| `PluginLogger` | type | Pino-subset (info/warn/error) |
| `PluginHttpClient` | type | `http.outbound` capability surface |
| `PluginStorage` | type | `storage.read` / `storage.write` capability surface |
| `PluginAudit` | type | `audit` capability surface; production-available but still capability-gated |
| `PluginTenantInfo` | type | Read-only tenant metadata (`tenant.info`) |
| `PluginSettingsClient` | type | Settings iframe helper returned by `createPluginSettingsClient` |
| `PluginSettingsClientError` | class | Host-declared settings request failure |
| `PluginSettingsClientTimeoutError` | class | Settings request timeout failure |
| `PluginSettingsFrameMessage` | type | Settings iframe `postMessage` request union |
| `PluginSettingsFrameResultMessage` | type | Host response union for settings iframe requests |
| `PluginSettingValue` | type | JSON-safe value shape accepted by per-user plugin settings |
| `PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION` | const | Current settings iframe protocol version (`1`) |
| `PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME` | const | Meta tag name that carries the host-generated channel id |
## Authoring shape
```ts
import { defineHook, type HookHandler } from "@toolctrl/plugin-sdk";
export const handleHello: HookHandler<{ name: string }, { greeting: string }> = defineHook((input, ctx) => {
ctx.log.info({ name: input.name }, "hello");
return { greeting: `Hello, ${input.name}!` };
});
```
Every hook export resolves to a single WebAssembly function with a
`(arg) -> result` shape. The runtime serialises both directions as
JSON; complex types fall through the standard `JSON.stringify` lens.
## Payload ABI
The foundation runtime exposes the active invocation payload as UTF-8
JSON through two raw WebAssembly imports:
| Import | Signature | Purpose |
| --------------------------- | --------------------- | ------------------------------------------------- |
| `env.toolctrl_payload_len` | `() -> i32` | Length in bytes of the JSON payload |
| `env.toolctrl_payload_byte` | `(index: i32) -> i32` | Byte value at `index`, or `0` outside the payload |
The SDK exports `PLUGIN_PAYLOAD_ABI_VERSION` and
`PLUGIN_PAYLOAD_ABI_IMPORTS` so toolchain bindings can assert the host
contract. Higher-level AssemblyScript/Rust helpers are still follow-up
work; raw WASM plugins can already decode the JSON bytes themselves.
## Settings iframe protocol
When a manifest declares `ui.settings.entry`, the host renders that HTML
inside a sandboxed `PluginSettingsFrame`. The iframe has an opaque
origin (`sandbox="allow-scripts"`, no `allow-same-origin`) and a deny-by-default
CSP, so settings UIs communicate with the host only through the versioned
`postMessage` protocol exported by the SDK.
Use `createPluginSettingsClient()` to read the injected channel id, send
`ready`, `get`, and `set` messages to `window.parent`, and accept responses
only when the message came from `window.parent` and matches the expected
protocol version, plugin id, channel id, and request id:
```ts
import { createPluginSettingsClient, PluginSettingsClientError, PluginSettingsClientTimeoutError } from "@toolctrl/plugin-sdk";
const settings = createPluginSettingsClient({
pluginId: "com.acme.settings",
});
settings.ready();
try {
const theme = await settings.get("theme");
applyTheme(theme);
await settings.set("theme", "dark");
} catch (error) {
if (error instanceof PluginSettingsClientError) {
console.warn("setting request failed", error.code, error.message);
} else if (error instanceof PluginSettingsClientTimeoutError) {
console.warn("setting request timed out", error.message);
} else {
throw error;
}
}
```
The host validates `pluginId`, `channelId`, `requestId`, keys, and values.
Values must be JSON-safe (`PluginSettingValue`) and stay within the host
size/depth budgets. Persistent writes are CSRF-protected, tenant/user scoped,
and rate-limited by the route action.
## Per-capability bindings on `ctx`
Each capability bound to the invocation appears as an optional field on
`ctx`. The runtime sets a field only when the manifest declared it AND
the gateway has a host implementation for it. Always null-check before
use:
```ts
if (!ctx.http) {
ctx.log.warn({}, "http capability not bound — manifest must declare http.outbound");
return { ok: false };
}
await ctx.http.fetch({ url: "https://example.com", method: "GET" });
```
This shape gives you compile-time documentation: every capability the
plugin uses is visible at the top of the handler.
## Logger
`ctx.log` is a structured logger. The host writes the message to its
own logging pipeline (pino under the hood). The signature mirrors pino:
```ts
ctx.log.info({ tenantId, recordId }, "entity created");
ctx.log.warn({ retries: 3 }, "retrying");
ctx.log.error({ err: errSerializable }, "fatal");
```
Never log secrets — output sanitisation is best-effort, not a guarantee.
## HTTP client (`ctx.http`)
Reserved SDK shape for `http.outbound`. It is not bound in the current
production/foundation runtime; manifests may declare it only as a reviewed
future capability, and calls fail closed until the host binding is explicitly
enabled after security review.
```ts
const response = await ctx.http!.fetch({
url: "https://hooks.slack.com/services/T0/B0/abc",
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "hello" }),
});
```
When enabled, the host validates the URL against `allowedHosts` from the manifest's
`http.outbound` scope before issuing the request. Any other host
results in a denied audit row.
## Storage (`ctx.storage`)
Reserved SDK shape for `storage.read` and `storage.write`. It is not bound in
the current production/foundation runtime; tenant-scoped storage remains
fail-closed until the capability receives a dedicated security review.
```ts
const channel = await ctx.storage!.get(`slack:${ctx.tenantId}:default-channel`);
await ctx.storage!.put(`slack:${ctx.tenantId}:default-channel`, "#alerts", {
ttlSeconds: 86_400,
});
```
Keys are byte-strings (no length cap today, but treat 256B as a soft
budget). Values are byte-strings; encode JSON yourself if you need
structured data.
## Audit (`ctx.audit`)
Production-available when the plugin manifest declares `audit` and the tenant
has granted that capability. Records a tenant-visible audit event:
```ts
await ctx.audit.record({
action: "slack.notification-sent",
target: `entity:${recordId}`,
meta: { channel: "#alerts" },
});
```
Use for state-change observability. Do not abuse as a queue —
metadata payloads have a hard cap.
## Errors
Throwing from a handler aborts the invocation. The runtime captures the
exception, records `outcome=error` against every in-flight capability,
flushes the audit batch, and marks the invocation `failed`. The plugin
crash-tracker auto-disables a plugin after repeated failures.
## Versioning
The SDK follows SemVer. Major bumps may add or remove capability
surfaces; consult `docs/plugins/versioning.md` before pinning to a new
major. The runtime advertises its host range through `minHostVersion` /
`maxHostVersion` in the manifest — when in doubt, lint your manifest
against the running host.