feat: add plugin worker protocol, bootstrap and plugin_storage table

Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
This commit is contained in:
LukeGus committed 2026-09-21 07:19:01 -05:00
1 parent a1805cb7ae
commit 248698f62d
19 files changed
+30035

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
import path from "node:path";
/**
* Where unpacked plugins live. Resolved per call rather than captured at module
* scope: tests set DATA_DIR in beforeEach, and a module-scope constant would
* freeze whatever the first import saw.
*/
export function getPluginsDir(): string {
return path.join(process.env.DATA_DIR || "./db/data", "plugins");
}
export function getPluginDir(pluginId: string): string {
return path.join(getPluginsDir(), pluginId);
}
export function getPluginBackendEntry(pluginDir: string): string {
return path.join(pluginDir, "backend", "index.mjs");
}
export function getPluginManifestPath(pluginDir: string): string {
return path.join(pluginDir, "manifest.json");
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Wire format between a plugin worker and the main-thread broker.
*
* Everything crossing this boundary must be structured-cloneable. That is not
* just a serialisation detail: it is the reason a plugin cannot be handed a db
* handle, an ssh2 Client or a live socket by accident. Live objects stay in the
* main thread and the worker refers to them by opaque handle strings.
*/
export interface PluginRequest {
/** Correlates a reply to its request. Worker-generated, monotonic. */
id: number;
/** Dotted ctx path, e.g. "hosts.get" or "storage.set". */
method: string;
args: unknown[];
}
export interface PluginResponseOk {
id: number;
ok: true;
value: unknown;
}
export interface PluginResponseErr {
id: number;
ok: false;
error: { message: string; code?: string };
}
export type PluginResponse = PluginResponseOk | PluginResponseErr;
/** Main thread -> worker, pushed rather than requested. */
export interface PluginPush {
kind: "event" | "http" | "timer";
/** Event topic, route key, or timer id depending on kind. */
key: string;
payload: unknown;
/** Set when the push expects a reply (an HTTP route invocation). */
replyTo?: number;
}
/** First message the worker receives, before any plugin code runs. */
export interface PluginBootstrapData {
pluginId: string;
pluginDir: string;
entryPath: string;
manifest: unknown;
}
export function isPluginRequest(value: unknown): value is PluginRequest {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return (
typeof v.id === "number" &&
typeof v.method === "string" &&
Array.isArray(v.args)
);
}
export function isPluginResponse(value: unknown): value is PluginResponse {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return typeof v.id === "number" && typeof v.ok === "boolean";
}
+243
View File
@@ -0,0 +1,243 @@
/**
* The worker thread's entry point. Runs before any plugin code.
*
* Builds the ctx object, imports the plugin's backend/index.mjs, and calls its
* exported activate(ctx). The plugin gets ctx and nothing else from us -- no
* db, no fs, no net handle is ever passed in. See loader.ts for an honest
* account of what that does and does not guarantee.
*/
import { parentPort, workerData } from "node:worker_threads";
import type {
PluginBootstrapData,
PluginPush,
PluginResponse,
} from "./protocol.js";
import { isPluginResponse } from "./protocol.js";
const port = parentPort;
if (!port) {
throw new Error("Plugin worker started without a parent port");
}
const boot = workerData as PluginBootstrapData;
let nextRequestId = 1;
const pending = new Map<
number,
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
>();
/** Handlers the plugin registered, keyed the same way the broker pushes them. */
const httpRoutes = new Map<string, (req: unknown) => unknown>();
const eventListeners = new Map<string, Set<(payload: unknown) => void>>();
const timers = new Map<string, () => void>();
function call(method: string, ...args: unknown[]): Promise<unknown> {
const id = nextRequestId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
port!.postMessage({ id, method, args });
});
}
function handleResponse(message: PluginResponse): void {
const waiter = pending.get(message.id);
if (!waiter) return;
pending.delete(message.id);
if (message.ok === true) {
waiter.resolve(message.value);
return;
}
const failure = message.error;
const error = new Error(failure.message);
if (failure.code) {
(error as Error & { code?: string }).code = failure.code;
}
waiter.reject(error);
}
async function handlePush(push: PluginPush): Promise<void> {
if (push.kind === "event") {
const listeners = eventListeners.get(push.key);
if (!listeners) return;
for (const listener of listeners) {
try {
listener(push.payload);
} catch {
// A throwing listener must not take down the worker.
}
}
return;
}
if (push.kind === "timer") {
const handler = timers.get(push.key);
if (handler) {
try {
handler();
} catch {
// same
}
}
return;
}
if (push.kind === "http") {
const handler = httpRoutes.get(push.key);
if (push.replyTo === undefined) return;
if (!handler) {
port!.postMessage({
id: push.replyTo,
ok: false,
error: { message: `No handler registered for route ${push.key}` },
});
return;
}
try {
const value = await handler(push.payload);
port!.postMessage({ id: push.replyTo, ok: true, value });
} catch (error) {
port!.postMessage({
id: push.replyTo,
ok: false,
error: {
message: error instanceof Error ? error.message : String(error),
},
});
}
}
}
port.on("message", (message: unknown) => {
if (isPluginResponse(message)) {
handleResponse(message);
return;
}
const push = message as PluginPush;
if (push && typeof push.kind === "string") {
void handlePush(push);
}
});
function notImplemented(name: string) {
return () =>
Promise.reject(
new Error(
`ctx.${name} is not implemented in plugin SDK version 1. It is reserved and will be added in a later version.`,
),
);
}
const ctx = {
pluginId: boot.pluginId,
manifest: boot.manifest,
log: {
debug: (message: string, context?: unknown) =>
call("log.debug", message, context),
info: (message: string, context?: unknown) =>
call("log.info", message, context),
warn: (message: string, context?: unknown) =>
call("log.warn", message, context),
error: (message: string, context?: unknown) =>
call("log.error", message, context),
},
audit: (action: string, details?: unknown) => call("audit", action, details),
hosts: {
list: () => call("hosts.list"),
get: (hostId: number) => call("hosts.get", hostId),
},
ssh: {
connect: (hostId: number) => call("ssh.connect", hostId),
exec: (handle: string, command: string, timeoutMs?: number) =>
call("ssh.exec", handle, command, timeoutMs),
close: (handle: string) => call("ssh.close", handle),
},
storage: {
get: (key: string) => call("storage.get", key),
set: (key: string, value: unknown) => call("storage.set", key, value),
delete: (key: string) => call("storage.delete", key),
list: () => call("storage.list"),
},
http: {
route: (
method: string,
routePath: string,
handler: (req: unknown) => unknown,
) => {
const key = `${method.toUpperCase()} ${routePath}`;
httpRoutes.set(key, handler);
return call("http.route", method.toUpperCase(), routePath);
},
},
events: {
emit: (topic: string, payload: unknown) =>
call("events.emit", topic, payload),
on: async (topic: string, listener: (payload: unknown) => void) => {
let listeners = eventListeners.get(topic);
if (!listeners) {
listeners = new Set();
eventListeners.set(topic, listeners);
await call("events.subscribe", topic);
}
listeners.add(listener);
return () => {
listeners!.delete(listener);
};
},
},
schedule: {
every: async (intervalMs: number, handler: () => void) => {
const id = (await call("schedule.every", intervalMs)) as string;
timers.set(id, handler);
return id;
},
cancel: async (id: string) => {
timers.delete(id);
await call("schedule.cancel", id);
},
},
secrets: {
get: notImplemented("secrets.get"),
set: notImplemented("secrets.set"),
},
notify: notImplemented("notify"),
fetch: notImplemented("fetch"),
};
async function start(): Promise<void> {
const module = await import(boot.entryPath);
const activate = module.activate ?? module.default?.activate;
if (typeof activate !== "function") {
throw new Error(
`Plugin ${boot.pluginId} backend entry does not export an activate(ctx) function`,
);
}
await activate(ctx);
port!.postMessage({ id: 0, ok: true, value: "activated" });
}
start().catch((error) => {
port!.postMessage({
id: 0,
ok: false,
error: {
message: error instanceof Error ? error.message : String(error),
},
});
});