Files
HiveGrid-Wireframes/hivegrid-cloud/packages/plugin-sdk/src/index.ts
T

484 lines
16 KiB
TypeScript

/**
* @toolctrl/plugin-sdk — Phase 06 Task #21 foundation.
*
* Type definitions plugin authors `import` against. The package ships
* type-only declarations in this foundation step; the AssemblyScript /
* Rust toolchain bindings that resolve to actual WASM imports are
* deferred to Phase 06 Task #23 (audit + docs).
*
* Authoring shape:
*
* import type { HookHandler, PluginContext } from "@toolctrl/plugin-sdk";
*
* export const handleHello: HookHandler<{ name: string }, { greeting: string }> = (
* input,
* ctx,
* ) => {
* ctx.log.info({ name: input.name }, "hello");
* return { greeting: `Hello, ${input.name}!` };
* };
*/
export const PLUGIN_SDK_PACKAGE_VERSION = "0.0.0";
export const PLUGIN_PAYLOAD_ABI_VERSION = 1;
export const PLUGIN_PAYLOAD_ABI_IMPORTS = ["env.toolctrl_payload_len", "env.toolctrl_payload_byte"] as const;
export const PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION = 1;
export const PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME = "plugin-settings-channel";
/**
* Capability names a host exposes to a plugin — mirrors the closed enum
* in `@toolctrl/plugin-runtime`. Kept duplicated here so plugin authors
* don't need to install the runtime package as a build-time dep.
*/
export type PluginCapabilityName =
| "http.outbound"
| "db.read"
| "db.write"
| "storage.read"
| "storage.write"
| "log"
| "audit"
| "tenant.info"
| "secret.read";
/**
* Foundation hook names the host can emit. Mirrors the runtime hook
* registry so plugin authors can type their handlers without importing
* `@toolctrl/plugin-runtime`.
*/
export type PluginHookName =
| "tenant.created"
| "user.created"
| "entity.created"
| "entity.updated"
| "entity.deleted"
| "workflow.created"
| "workflow.updated"
| "workflow.deleted"
| "workflow.executed"
| "workflow.failed";
export interface PluginHookTenantCreatedPayload {
readonly tenantId: string;
readonly slug: string;
readonly ownerEmail?: string | null;
readonly plan?: string | null;
readonly createdAt: string;
}
export interface PluginHookUserCreatedPayload {
readonly userId: string;
readonly tenantId?: string | null;
readonly email: string;
readonly locale?: string | null;
readonly createdAt: string;
}
export interface PluginHookEntityPayload {
readonly entityType: string;
readonly entityId: string;
readonly tenantId: string | null;
readonly userId?: string | null;
readonly changedFields?: ReadonlyArray<string>;
readonly occurredAt: string;
}
export interface PluginHookWorkflowExecutedPayload {
readonly tenantId: string;
readonly workflowId: string;
readonly workflowExecutionId: string;
readonly userId?: string | null;
readonly type: "manual" | "api" | "stream" | "trigger";
readonly status: "success" | "error";
readonly durationMs: number;
readonly errorCode: string | null;
readonly appliesToAllTenants: boolean;
readonly occurredAt: string;
}
export interface PluginHookWorkflowFailedPayload extends Omit<PluginHookWorkflowExecutedPayload, "status" | "errorCode"> {
readonly status: "error";
readonly errorCode: string;
}
export interface PluginHookWorkflowDefinitionPayload {
readonly tenantId: string | null;
readonly workflowId: string;
readonly userId: string | null;
readonly status: "draft" | "live" | "archived";
readonly appliesToAllTenants: boolean;
readonly changedFields?: ReadonlyArray<string>;
readonly occurredAt: string;
}
export type PluginHookPayloads = {
readonly "tenant.created": PluginHookTenantCreatedPayload;
readonly "user.created": PluginHookUserCreatedPayload;
readonly "entity.created": PluginHookEntityPayload;
readonly "entity.updated": PluginHookEntityPayload;
readonly "entity.deleted": PluginHookEntityPayload;
readonly "workflow.created": PluginHookWorkflowDefinitionPayload;
readonly "workflow.updated": PluginHookWorkflowDefinitionPayload;
readonly "workflow.deleted": PluginHookWorkflowDefinitionPayload;
readonly "workflow.executed": PluginHookWorkflowExecutedPayload;
readonly "workflow.failed": PluginHookWorkflowFailedPayload;
};
/** Subset of pino's logger surface the host exposes via the `log` cap. */
export interface PluginLogger {
info(fields: Record<string, unknown>, msg: string): void;
warn(fields: Record<string, unknown>, msg: string): void;
error(fields: Record<string, unknown>, msg: string): void;
}
/** HTTP host binding — gated on the `http.outbound` capability. */
export interface PluginHttpClient {
fetch(input: { url: string; method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; headers?: Record<string, string>; body?: string }): Promise<{
status: number;
headers: Record<string, string>;
body: string;
}>;
}
/** Tenant-scoped KV store — gated on `storage.read` / `storage.write`. */
export interface PluginStorage {
get(key: string): Promise<string | null>;
put(key: string, value: string, opts?: { ttlSeconds?: number }): Promise<void>;
}
/** Audit-event sink — always allowed. */
export interface PluginAudit {
record(input: { action: string; target?: string; meta?: Record<string, unknown> }): Promise<void>;
}
/** Read-only tenant metadata — always allowed. */
export interface PluginTenantInfo {
id: string;
slug: string;
plan: string;
}
/**
* Aggregate host API surface, partial because each plugin only sees
* the bindings whose capabilities it declared in `plugin.toml`.
*/
export interface HostApi {
log: PluginLogger;
audit: PluginAudit;
tenant?: PluginTenantInfo;
http?: PluginHttpClient;
storage?: PluginStorage;
secret?: { get(key: string): Promise<string | null> };
}
export type PluginSettingValue = null | boolean | number | string | ReadonlyArray<PluginSettingValue> | { readonly [key: string]: PluginSettingValue };
export type PluginSettingsFrameReadyMessage = {
readonly type: "plugin.settings.ready";
readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION;
readonly pluginId: string;
readonly channelId: string;
readonly requestId?: string;
};
export type PluginSettingsFrameGetMessage = {
readonly type: "plugin.settings.get";
readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION;
readonly pluginId: string;
readonly channelId: string;
readonly requestId: string;
readonly key: string;
};
export type PluginSettingsFrameSetMessage = {
readonly type: "plugin.settings.set";
readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION;
readonly pluginId: string;
readonly channelId: string;
readonly requestId: string;
readonly key: string;
readonly value: PluginSettingValue;
};
export type PluginSettingsFrameMessage = PluginSettingsFrameReadyMessage | PluginSettingsFrameGetMessage | PluginSettingsFrameSetMessage;
export type PluginSettingsFrameResultMessage =
| {
readonly type: "plugin.settings.result";
readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION;
readonly pluginId: string;
readonly channelId: string;
readonly requestId: string;
readonly ok: true;
readonly key: string;
readonly value: PluginSettingValue;
}
| {
readonly type: "plugin.settings.result";
readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION;
readonly pluginId: string;
readonly channelId: string;
readonly requestId: string;
readonly ok: false;
readonly error: "invalid_payload" | "settings_unavailable" | "settings_update_failed";
readonly message: string;
};
export interface PluginSettingsClientMessageTarget {
postMessage(message: PluginSettingsFrameMessage, targetOrigin: string): void;
}
export interface PluginSettingsClientMessageEvent {
readonly source: unknown;
readonly data: unknown;
}
export interface PluginSettingsClientWindow {
readonly parent: PluginSettingsClientMessageTarget;
addEventListener(type: "message", listener: (event: PluginSettingsClientMessageEvent) => void): void;
removeEventListener(type: "message", listener: (event: PluginSettingsClientMessageEvent) => void): void;
}
export interface PluginSettingsClientDocument {
querySelector(selector: string): { getAttribute(name: string): string | null } | null;
}
export interface PluginSettingsClientRequestOptions {
readonly requestId?: string;
readonly timeoutMs?: number;
}
export interface PluginSettingsClientOptions {
readonly pluginId: string;
readonly channelId?: string;
readonly targetOrigin?: string;
readonly timeoutMs?: number;
readonly requestId?: () => string;
readonly window?: PluginSettingsClientWindow;
readonly document?: PluginSettingsClientDocument;
}
export interface PluginSettingsClient {
readonly pluginId: string;
readonly channelId: string;
ready(options?: { readonly requestId?: string }): void;
get(key: string, options?: PluginSettingsClientRequestOptions): Promise<PluginSettingValue>;
set(key: string, value: PluginSettingValue, options?: PluginSettingsClientRequestOptions): Promise<PluginSettingValue>;
destroy(): void;
}
type PluginSettingsClientPendingRequest = {
readonly key: string;
readonly timeout: ReturnType<typeof setTimeout> | null;
resolve(value: PluginSettingValue): void;
reject(error: Error): void;
};
export class PluginSettingsClientError extends Error {
readonly code: PluginSettingsFrameResultMessage extends infer Message
? Message extends { readonly ok: false; readonly error: infer Code }
? Code
: never
: never;
constructor(code: PluginSettingsClientError["code"], message: string) {
super(message);
this.name = "PluginSettingsClientError";
this.code = code;
}
}
export class PluginSettingsClientTimeoutError extends Error {
constructor(requestId: string) {
super(`Plugin settings request timed out: ${requestId}`);
this.name = "PluginSettingsClientTimeoutError";
}
}
function getDefaultPluginSettingsWindow(): PluginSettingsClientWindow | undefined {
const maybeWindow = (globalThis as { window?: PluginSettingsClientWindow }).window;
return maybeWindow?.parent && typeof maybeWindow.addEventListener === "function" && typeof maybeWindow.removeEventListener === "function"
? maybeWindow
: undefined;
}
function getDefaultPluginSettingsDocument(): PluginSettingsClientDocument | undefined {
const maybeDocument = (globalThis as { document?: PluginSettingsClientDocument }).document;
return maybeDocument && typeof maybeDocument.querySelector === "function" ? maybeDocument : undefined;
}
function readPluginSettingsChannelId(documentRef: PluginSettingsClientDocument | undefined): string | undefined {
return documentRef?.querySelector(`meta[name="${PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME}"]`)?.getAttribute("content") ?? undefined;
}
function isPluginSettingsFrameResultMessage(value: unknown): value is PluginSettingsFrameResultMessage {
return typeof value === "object" && value !== null && (value as { type?: unknown }).type === "plugin.settings.result";
}
/**
* Build a small settings iframe client around the host postMessage protocol.
* Responses are accepted only from `window.parent` and only when protocol
* version, plugin id, channel id, and request id match a pending request.
*/
export function createPluginSettingsClient(options: PluginSettingsClientOptions): PluginSettingsClient {
const frameWindow = options.window ?? getDefaultPluginSettingsWindow();
if (!frameWindow) {
throw new Error("Plugin settings client requires a window with a parent postMessage target");
}
const pluginId = options.pluginId;
const channelId = options.channelId ?? readPluginSettingsChannelId(options.document ?? getDefaultPluginSettingsDocument());
if (!channelId) {
throw new Error("Missing plugin settings channel");
}
const targetOrigin = options.targetOrigin ?? "*";
const defaultTimeoutMs = options.timeoutMs ?? 10_000;
const nextRequestId =
options.requestId ??
(() => {
let next = 0;
return () => `plugin-settings-${++next}`;
})();
const pending = new Map<string, PluginSettingsClientPendingRequest>();
const onMessage = (event: PluginSettingsClientMessageEvent) => {
if (event.source !== frameWindow.parent || !isPluginSettingsFrameResultMessage(event.data)) {
return;
}
const message = event.data;
if (message.version !== PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION || message.pluginId !== pluginId || message.channelId !== channelId) {
return;
}
const request = pending.get(message.requestId);
if (!request) {
return;
}
pending.delete(message.requestId);
if (request.timeout) {
clearTimeout(request.timeout);
}
if (message.ok) {
if (message.key !== request.key) {
request.reject(new Error(`Plugin settings response key mismatch for request: ${message.requestId}`));
return;
}
request.resolve(message.value);
return;
}
request.reject(new PluginSettingsClientError(message.error, message.message));
};
frameWindow.addEventListener("message", onMessage);
const post = (message: PluginSettingsFrameMessage) => {
frameWindow.parent.postMessage(message, targetOrigin);
};
const request = (
type: "plugin.settings.get" | "plugin.settings.set",
key: string,
value: PluginSettingValue | undefined,
requestOptions?: PluginSettingsClientRequestOptions
) => {
const requestId = requestOptions?.requestId ?? nextRequestId();
const timeoutMs = requestOptions?.timeoutMs ?? defaultTimeoutMs;
return new Promise<PluginSettingValue>((resolve, reject) => {
const timeout =
timeoutMs > 0
? setTimeout(() => {
pending.delete(requestId);
reject(new PluginSettingsClientTimeoutError(requestId));
}, timeoutMs)
: null;
pending.set(requestId, { key, timeout, resolve, reject });
post(
type === "plugin.settings.get"
? {
type,
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId,
channelId,
requestId,
key,
}
: {
type,
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId,
channelId,
requestId,
key,
value: value as PluginSettingValue,
}
);
});
};
return {
pluginId,
channelId,
ready: (readyOptions) =>
post({
type: "plugin.settings.ready",
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId,
channelId,
requestId: readyOptions?.requestId,
}),
get: (key, requestOptions) => request("plugin.settings.get", key, undefined, requestOptions),
set: (key, value, requestOptions) => request("plugin.settings.set", key, value, requestOptions),
destroy: () => {
frameWindow.removeEventListener("message", onMessage);
for (const [requestId, pendingRequest] of pending) {
if (pendingRequest.timeout) {
clearTimeout(pendingRequest.timeout);
}
pendingRequest.reject(new Error(`Plugin settings client destroyed before response: ${requestId}`));
}
pending.clear();
},
};
}
/**
* Per-invocation context: bound capabilities + invocation metadata.
*/
export interface PluginContext {
pluginId: string;
pluginVersion: string;
tenantId: string;
hookName: string;
capabilities: ReadonlyArray<PluginCapabilityName>;
log: PluginLogger;
audit: PluginAudit;
tenant?: PluginTenantInfo;
http?: PluginHttpClient;
storage?: PluginStorage;
secret?: { get(key: string): Promise<string | null> };
}
/** A hook handler — synchronous or promise-returning. */
export interface HookHandler<Input = unknown, Output = unknown> {
(input: Input, ctx: PluginContext): Output | Promise<Output>;
}
export type TypedHookHandler<Name extends PluginHookName, Output = unknown> = HookHandler<PluginHookPayloads[Name], Output>;
/**
* Helper to brand a function as a typed hook handler — `defineHook`
* exists so authors can rely on inference of `Input` and `Output`
* without restating the type at the call site.
*/
export function defineHook<Input, Output>(handler: HookHandler<Input, Output>): HookHandler<Input, Output> {
return handler;
}