mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
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:
1 parent
a1805cb7ae
commit
248698f62d
19 files changed
+30035
No files matched your search
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user