Files
NetMesh/electron/plugins/hostRpcRegistry.cjs

317 lines
12 KiB
JavaScript
Raw Normal View History

"use strict";
const RESERVED_METHODS = new Set([
"plugin.initialize",
"plugin.activate",
"plugin.deactivate",
"$/cancelRequest",
"$/progress",
]);
function assertHostMethod(method) {
if (
typeof method !== "string"
|| method.length < 1
|| method.length > 256
|| method.startsWith("$/")
|| RESERVED_METHODS.has(method)
) {
throw new TypeError(`Invalid or reserved plugin host RPC method: ${String(method)}`);
}
return method;
}
function assertHandler(handler, label) {
if (typeof handler !== "function") throw new TypeError(`${label} must be a function`);
return handler;
}
function cloneAndFreezeMetadata(value, seen = new Set()) {
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new TypeError("Plugin host RPC metadata numbers must be finite");
return value;
}
if (!value || typeof value !== "object" || seen.has(value)) {
throw new TypeError("Plugin host RPC metadata must be an acyclic JSON-like value");
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null && !Array.isArray(value)) {
throw new TypeError("Plugin host RPC metadata must use plain objects and arrays");
}
seen.add(value);
let clone;
if (Array.isArray(value)) {
clone = value.map((item) => cloneAndFreezeMetadata(item, seen));
} else {
clone = {};
for (const key of Reflect.ownKeys(value)) {
if (typeof key !== "string") throw new TypeError("Plugin host RPC metadata keys must be strings");
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
throw new TypeError("Plugin host RPC metadata must contain enumerable data properties");
}
Object.defineProperty(clone, key, {
value: cloneAndFreezeMetadata(descriptor.value, seen),
enumerable: true,
configurable: false,
writable: false,
});
}
}
seen.delete(value);
return Object.freeze(clone);
}
function normalizeRuntimeIdentity(identity) {
if (!identity || typeof identity !== "object" || Array.isArray(identity)) {
throw new TypeError("Plugin runtime identity must be an object");
}
for (const key of ["pluginId", "pluginVersion", "runtimeId", "runtimeKind"]) {
if (typeof identity[key] !== "string" || identity[key].length < 1) {
throw new TypeError(`Plugin runtime identity ${key} is required`);
}
}
return Object.freeze({
pluginId: identity.pluginId,
pluginVersion: identity.pluginVersion,
runtimeId: identity.runtimeId,
runtimeKind: identity.runtimeKind,
...(identity.manifest === undefined ? {} : { manifest: identity.manifest }),
...(identity.securityPrincipal === undefined ? {} : { securityPrincipal: identity.securityPrincipal }),
...(identity.packageRoot === undefined ? {} : { packageRoot: identity.packageRoot }),
...(identity.logger === undefined ? {} : { logger: identity.logger }),
});
}
function createDisposable(dispose) {
let disposed = false;
return Object.freeze({
dispose() {
if (disposed) return;
disposed = true;
dispose();
},
});
}
class PluginHostRpcRegistry {
constructor() {
this.requests = new Map();
this.notifications = new Map();
this.middleware = new Set();
this.incomingStreams = new Set();
this.revision = 0;
}
#register(collection, kind, method, handler, options = {}) {
const normalizedMethod = assertHostMethod(method);
assertHandler(handler, `Plugin host ${kind} handler`);
if (options.validateParams != null) {
assertHandler(options.validateParams, `Plugin host ${kind} parameter validator`);
}
if (typeof options.authorization === "function") {
assertHandler(options.authorization, `Plugin host ${kind} authorization resolver`);
}
if (this.requests.has(normalizedMethod) || this.notifications.has(normalizedMethod)) {
throw new Error(`Plugin host RPC method is already registered: ${normalizedMethod}`);
}
const registration = Object.freeze({
handler,
metadata: cloneAndFreezeMetadata(options.metadata ?? {}),
validateParams: options.validateParams ?? null,
authorization: typeof options.authorization === "function"
? options.authorization
: options.authorization == null
? null
: cloneAndFreezeMetadata(options.authorization),
});
collection.set(normalizedMethod, registration);
this.revision += 1;
return createDisposable(() => {
if (collection.get(normalizedMethod) !== registration) return;
collection.delete(normalizedMethod);
this.revision += 1;
});
}
registerRequest(method, handler, options) {
return this.#register(this.requests, "request", method, handler, options);
}
registerNotification(method, handler, options) {
return this.#register(this.notifications, "notification", method, handler, options);
}
use(middleware) {
assertHandler(middleware, "Plugin host RPC middleware");
this.middleware.add(middleware);
this.revision += 1;
return createDisposable(() => {
if (!this.middleware.delete(middleware)) return;
this.revision += 1;
});
}
registerIncomingStream(handler) {
assertHandler(handler, "Plugin incoming stream handler");
this.incomingStreams.add(handler);
this.revision += 1;
return createDisposable(() => {
if (!this.incomingStreams.delete(handler)) return;
this.revision += 1;
});
}
createRoutes(runtimeIdentity) {
const assertCurrent = runtimeIdentity?.assertCurrent;
if (assertCurrent != null) assertHandler(assertCurrent, "Plugin runtime identity guard");
const identity = normalizeRuntimeIdentity(runtimeIdentity);
const middleware = [...this.middleware];
const streamHandlers = [...this.incomingStreams];
const createHandler = (kind, method, registration) => async (params, transportContext) => {
const assertActive = async () => {
transportContext.signal?.throwIfAborted();
await assertCurrent?.();
transportContext.signal?.throwIfAborted();
};
await assertActive();
const validatedParams = registration.validateParams
? registration.validateParams(params)
: params;
if (validatedParams && typeof validatedParams.then === "function") {
throw new TypeError("Plugin host RPC parameter validators must be synchronous");
}
const authorizationContext = Object.freeze({
...transportContext,
...identity,
assertActive,
kind,
method,
metadata: registration.metadata,
params: validatedParams,
});
const resolvedAuthorization = typeof registration.authorization === "function"
? await registration.authorization(validatedParams, authorizationContext)
: registration.authorization;
const authorization = resolvedAuthorization == null
? null
: cloneAndFreezeMetadata(resolvedAuthorization);
await assertActive();
const context = Object.freeze({
...authorizationContext,
authorization,
});
let index = -1;
const dispatch = async (nextIndex) => {
if (nextIndex <= index) throw new Error("Plugin host RPC middleware called next() more than once");
index = nextIndex;
const current = middleware[nextIndex];
if (current) return current(context, () => dispatch(nextIndex + 1));
await assertActive();
return registration.handler(validatedParams, context);
};
const result = await dispatch(0);
await assertActive();
return result;
};
const requestHandlers = Object.freeze(Object.fromEntries(
[...this.requests].map(([method, registration]) => [
method,
createHandler("request", method, registration),
]),
));
const notificationHandlers = Object.freeze(Object.fromEntries(
[...this.notifications].map(([method, registration]) => [
method,
createHandler("notification", method, registration),
]),
));
const onIncomingStream = async (stream) => {
stream.signal?.throwIfAborted();
await assertCurrent?.();
const context = Object.freeze({
...identity,
signal: stream.signal,
assertActive: async () => {
stream.signal?.throwIfAborted();
await assertCurrent?.();
stream.signal?.throwIfAborted();
},
kind: "stream",
});
for (const handler of streamHandlers) {
const accepted = await handler(stream, context);
if (accepted === true) {
await context.assertActive();
return true;
}
if (accepted !== false && accepted !== undefined) {
throw new TypeError("Plugin incoming stream handler must return true, false, or undefined");
}
}
await context.assertActive();
return false;
};
return Object.freeze({
notificationHandlers,
onIncomingStream,
requestHandlers,
revision: this.revision,
});
}
}
function createDefaultPluginHostRpcRegistry(options) {
const registry = new PluginHostRpcRegistry();
registry.registerRequest("storage.get", async ({ key }, context) => {
const value = options.database.getValue(context.pluginId, key);
return value === undefined ? { found: false } : { found: true, value };
}, {
metadata: { capability: "storage", mutating: false, permission: "storage" },
authorization: { permission: "storage", resources: ["*"], reason: "Access plugin storage" },
validateParams: (params) => options.assertStorageParams(params),
});
registry.registerRequest("storage.set", async ({ key, value }, context) => {
options.database.setValue(context.pluginId, key, value);
return null;
}, {
metadata: { capability: "storage", mutating: true, permission: "storage" },
authorization: { permission: "storage", resources: ["*"], reason: "Modify plugin storage" },
validateParams: (params) => options.assertStorageParams(params, { value: true }),
});
registry.registerRequest("storage.delete", async ({ key }, context) => {
options.database.deleteValue(context.pluginId, key);
return null;
}, {
metadata: { capability: "storage", mutating: true, permission: "storage" },
authorization: { permission: "storage", resources: ["*"], reason: "Modify plugin storage" },
validateParams: (params) => options.assertStorageParams(params),
});
registry.registerRequest("storage.keys", async (params, context) => {
return { keys: options.database.listKeys(context.pluginId) };
}, {
metadata: { capability: "storage", mutating: false, permission: "storage" },
authorization: { permission: "storage", resources: ["*"], reason: "List plugin storage keys" },
validateParams: (params) => {
if (params && (typeof params !== "object" || Array.isArray(params) || Object.keys(params).length > 0)) {
throw new TypeError("storage.keys does not accept parameters");
}
return params;
},
});
registry.registerNotification("log.write", async (params, context) => {
if (!params || typeof params !== "object" || Array.isArray(params)) return;
await context.logger.write(params.level, params.message, params.fields);
}, { metadata: { capability: "logging", mutating: false, public: true } });
return registry;
}
module.exports = {
PluginHostRpcRegistry,
assertHostMethod,
cloneAndFreezeMetadata,
createDefaultPluginHostRpcRegistry,
normalizeRuntimeIdentity,
};