improved plugin api

This commit is contained in:
KingKDot
2026-05-03 00:29:05 -05:00
parent e0587a1331
commit 273b35d326
9 changed files with 841 additions and 1 deletions
@@ -0,0 +1,307 @@
import path from "path";
import fs from "fs/promises";
import { existsSync } from "fs";
import { v4 as uuidv4 } from "uuid";
import { logger } from "../../logger";
import type { WorkerInbound, WorkerOutbound, PluginRpcCaller } from "./types";
type Subscriber = {
id: string;
send: (sse: string) => void;
close: () => void;
};
type PendingRpc = {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
type PluginInstance = {
pluginId: string;
worker: Worker;
ready: Promise<void>;
resolveReady: () => void;
rejectReady: (err: Error) => void;
isReady: boolean;
pending: Map<string, PendingRpc>;
subscribers: Set<Subscriber>;
startedAt: number;
};
const RPC_TIMEOUT_MS = 30_000;
const SHUTDOWN_GRACE_MS = 750;
export type PluginRuntime = {
startPlugin: (pluginId: string) => Promise<void>;
stopPlugin: (pluginId: string) => Promise<void>;
restartPlugin: (pluginId: string) => Promise<void>;
dispatchClientEvent: (
clientId: string,
pluginId: string,
event: string,
payload: unknown,
) => void;
rpc: (
pluginId: string,
method: string,
params: unknown,
caller: PluginRpcCaller,
) => Promise<unknown>;
subscribe: (
pluginId: string,
send: (sse: string) => void,
close: () => void,
) => (() => void) | null;
isRunning: (pluginId: string) => boolean;
hasServerCode: (pluginId: string) => boolean;
shutdownAll: () => Promise<void>;
};
export type PluginRuntimeOptions = {
pluginRoot: string;
workerHostUrl: string; // file:// URL or absolute path that `new Worker()` accepts
setLastError: (pluginId: string, error: string) => void;
};
export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
const instances = new Map<string, PluginInstance>();
function pluginServerScript(pluginId: string): string {
return path.join(opts.pluginRoot, pluginId, "server.js");
}
function hasServerCode(pluginId: string): boolean {
return existsSync(pluginServerScript(pluginId));
}
function isRunning(pluginId: string): boolean {
return instances.has(pluginId);
}
async function startPlugin(pluginId: string): Promise<void> {
if (instances.has(pluginId)) return;
const script = pluginServerScript(pluginId);
if (!existsSync(script)) return;
const dataDir = path.join(opts.pluginRoot, pluginId, "data");
await fs.mkdir(dataDir, { recursive: true });
const dbPath = path.join(dataDir, "plugin.db");
const worker = new Worker(opts.workerHostUrl);
let resolveReady!: () => void;
let rejectReady!: (err: Error) => void;
const ready = new Promise<void>((res, rej) => {
resolveReady = res;
rejectReady = rej;
});
const inst: PluginInstance = {
pluginId,
worker,
ready,
resolveReady,
rejectReady,
isReady: false,
pending: new Map(),
subscribers: new Set(),
startedAt: Date.now(),
};
worker.onmessage = (e: MessageEvent<WorkerOutbound>) => {
handleWorkerMessage(inst, e.data);
};
(worker as any).onerror = (event: any) => {
const message = event?.message || event?.error?.message || String(event);
logger.error(`[plugin:${pluginId}] worker error: ${message}`);
opts.setLastError(pluginId, `worker crash: ${message}`);
rejectReady(new Error(message));
void stopPlugin(pluginId);
};
instances.set(pluginId, inst);
const bootMsg: WorkerInbound = {
type: "boot",
pluginId,
serverScript: script,
dbPath,
pluginRoot: opts.pluginRoot,
};
worker.postMessage(bootMsg);
try {
await ready;
inst.isReady = true;
logger.info(`[plugin:${pluginId}] server runtime ready (db=${path.basename(dbPath)})`);
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
logger.error(`[plugin:${pluginId}] failed to start: ${error}`);
opts.setLastError(pluginId, error);
try { worker.terminate(); } catch {}
instances.delete(pluginId);
throw err;
}
}
async function stopPlugin(pluginId: string): Promise<void> {
const inst = instances.get(pluginId);
if (!inst) return;
instances.delete(pluginId);
for (const sub of inst.subscribers) {
try { sub.close(); } catch {}
}
inst.subscribers.clear();
for (const pending of inst.pending.values()) {
clearTimeout(pending.timer);
pending.reject(new Error("Plugin runtime stopped"));
}
inst.pending.clear();
try {
const shutdownMsg: WorkerInbound = { type: "shutdown" };
inst.worker.postMessage(shutdownMsg);
} catch {}
setTimeout(() => {
try { inst.worker.terminate(); } catch {}
}, SHUTDOWN_GRACE_MS);
logger.info(`[plugin:${pluginId}] server runtime stopped`);
}
async function restartPlugin(pluginId: string): Promise<void> {
if (instances.has(pluginId)) {
await stopPlugin(pluginId);
}
await startPlugin(pluginId);
}
function dispatchClientEvent(
clientId: string,
pluginId: string,
event: string,
payload: unknown,
): void {
const inst = instances.get(pluginId);
if (!inst) return;
const send = () => {
try {
const msg: WorkerInbound = { type: "event", clientId, event, payload };
inst.worker.postMessage(msg);
} catch (err) {
logger.warn(
`[plugin:${pluginId}] event dispatch failed: ${(err as Error).message}`,
);
}
};
if (inst.isReady) {
send();
} else {
inst.ready.then(send).catch(() => {});
}
}
function rpc(
pluginId: string,
method: string,
params: unknown,
caller: PluginRpcCaller,
): Promise<unknown> {
const inst = instances.get(pluginId);
if (!inst) {
return Promise.reject(new Error("Plugin runtime not running"));
}
const callOnReady = () =>
new Promise((resolve, reject) => {
const id = uuidv4();
const timer = setTimeout(() => {
if (inst.pending.delete(id)) {
reject(new Error(`RPC timeout: ${method}`));
}
}, RPC_TIMEOUT_MS);
inst.pending.set(id, { resolve, reject, timer });
const msg: WorkerInbound = { type: "rpc", id, method, params, caller };
try {
inst.worker.postMessage(msg);
} catch (err) {
clearTimeout(timer);
inst.pending.delete(id);
reject(err instanceof Error ? err : new Error(String(err)));
}
});
return inst.isReady ? callOnReady() : inst.ready.then(callOnReady);
}
function subscribe(
pluginId: string,
send: (sse: string) => void,
close: () => void,
): (() => void) | null {
const inst = instances.get(pluginId);
if (!inst) return null;
const sub: Subscriber = { id: uuidv4(), send, close };
inst.subscribers.add(sub);
return () => {
inst.subscribers.delete(sub);
};
}
function handleWorkerMessage(inst: PluginInstance, msg: WorkerOutbound) {
if (msg.type === "ready") {
inst.resolveReady();
return;
}
if (msg.type === "boot_error") {
opts.setLastError(inst.pluginId, msg.error);
inst.rejectReady(new Error(msg.error));
return;
}
if (msg.type === "rpc_reply") {
const pending = inst.pending.get(msg.id);
if (!pending) return;
clearTimeout(pending.timer);
inst.pending.delete(msg.id);
if (msg.ok) pending.resolve(msg.result);
else pending.reject(new Error(msg.error));
return;
}
if (msg.type === "broadcast") {
const safeChannel = String(msg.channel || "message").replace(/[\r\n]/g, "");
const dataLine = JSON.stringify(msg.data ?? null);
const sse = `event: ${safeChannel}\ndata: ${dataLine}\n\n`;
for (const sub of inst.subscribers) {
try { sub.send(sse); } catch {}
}
return;
}
if (msg.type === "log") {
const tag = `[plugin:${inst.pluginId}]`;
if (msg.level === "info") logger.info(`${tag} ${msg.message}`);
else if (msg.level === "warn") logger.warn(`${tag} ${msg.message}`);
else if (msg.level === "error") logger.error(`${tag} ${msg.message}`);
else logger.debug(`${tag} ${msg.message}`);
}
}
async function shutdownAll(): Promise<void> {
const ids = Array.from(instances.keys());
await Promise.all(ids.map((id) => stopPlugin(id)));
}
return {
startPlugin,
stopPlugin,
restartPlugin,
dispatchClientEvent,
rpc,
subscribe,
isRunning,
hasServerCode,
shutdownAll,
};
}
@@ -0,0 +1,37 @@
export type PluginRpcCaller = {
id: number;
role: string;
};
export type WorkerInbound =
| {
type: "boot";
pluginId: string;
serverScript: string;
dbPath: string;
pluginRoot: string;
}
| {
type: "event";
clientId: string;
event: string;
payload: unknown;
}
| {
type: "rpc";
id: string;
method: string;
params: unknown;
caller: PluginRpcCaller;
}
| {
type: "shutdown";
};
export type WorkerOutbound =
| { type: "ready" }
| { type: "boot_error"; error: string }
| { type: "rpc_reply"; id: string; ok: true; result: unknown }
| { type: "rpc_reply"; id: string; ok: false; error: string }
| { type: "broadcast"; channel: string; data: unknown }
| { type: "log"; level: "debug" | "info" | "warn" | "error"; message: string };
@@ -0,0 +1,134 @@
import { Database } from "bun:sqlite";
import path from "path";
import { pathToFileURL } from "url";
import type { WorkerInbound, WorkerOutbound } from "./types";
declare const self: {
postMessage: (msg: WorkerOutbound) => void;
onmessage: ((e: MessageEvent<WorkerInbound>) => void) | null;
};
type PluginExports = {
setup?: (ctx: PluginContext) => unknown | Promise<unknown>;
onEvent?: (ctx: PluginContext, clientId: string, event: string, payload: unknown) => unknown | Promise<unknown>;
rpc?: Record<string, (ctx: PluginContext, params: unknown, meta: { caller: { id: number; role: string } }) => unknown | Promise<unknown>>;
teardown?: (ctx: PluginContext) => unknown | Promise<unknown>;
};
type PluginContext = {
pluginId: string;
db: Database;
dataDir: string;
log: {
debug: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
};
broadcast: (channel: string, data: unknown) => void;
};
let plugin: PluginExports | null = null;
let db: Database | null = null;
let ctx: PluginContext | null = null;
let pluginId = "";
function send(msg: WorkerOutbound) {
self.postMessage(msg);
}
function makeLogger(): PluginContext["log"] {
return {
debug: (m) => send({ type: "log", level: "debug", message: m }),
info: (m) => send({ type: "log", level: "info", message: m }),
warn: (m) => send({ type: "log", level: "warn", message: m }),
error: (m) => send({ type: "log", level: "error", message: m }),
};
}
self.onmessage = async (e: MessageEvent<WorkerInbound>) => {
const msg = e.data;
if (msg.type === "boot") {
try {
pluginId = msg.pluginId;
db = new Database(msg.dbPath, { create: true });
try {
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA synchronous = NORMAL");
db.exec("PRAGMA foreign_keys = ON");
} catch {}
const dataDir = path.join(msg.pluginRoot, msg.pluginId, "data");
ctx = {
pluginId,
db,
dataDir,
log: makeLogger(),
broadcast: (channel, data) => send({ type: "broadcast", channel, data }),
};
const moduleUrl = `${pathToFileURL(msg.serverScript).href}?v=${Date.now()}`;
const mod = await import(/* @vite-ignore */ moduleUrl);
plugin = (mod.default || mod) as PluginExports;
if (typeof plugin.setup === "function") {
await plugin.setup(ctx);
}
send({ type: "ready" });
} catch (err) {
const error = err instanceof Error ? `${err.message}\n${err.stack || ""}` : String(err);
send({ type: "boot_error", error });
}
return;
}
if (msg.type === "event") {
if (!plugin || !ctx) return;
if (typeof plugin.onEvent !== "function") return;
try {
await plugin.onEvent(ctx, msg.clientId, msg.event, msg.payload);
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
send({ type: "log", level: "error", message: `onEvent(${msg.event}) failed: ${error}` });
}
return;
}
if (msg.type === "rpc") {
if (!plugin || !ctx) {
send({ type: "rpc_reply", id: msg.id, ok: false, error: "Plugin not initialised" });
return;
}
const handler = plugin.rpc?.[msg.method];
if (typeof handler !== "function") {
send({ type: "rpc_reply", id: msg.id, ok: false, error: `Unknown RPC method: ${msg.method}` });
return;
}
try {
const result = await handler(ctx, msg.params, { caller: msg.caller });
send({ type: "rpc_reply", id: msg.id, ok: true, result: result ?? null });
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
send({ type: "rpc_reply", id: msg.id, ok: false, error });
}
return;
}
if (msg.type === "shutdown") {
if (plugin && ctx && typeof plugin.teardown === "function") {
try {
await plugin.teardown(ctx);
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
send({ type: "log", level: "warn", message: `teardown failed: ${error}` });
}
}
try { db?.close(); } catch {}
db = null;
plugin = null;
ctx = null;
return;
}
};