feat: 增加通用插件worker运行时

This commit is contained in:
sunlei 2026-06-18 03:54:29 +08:00
parent 46f9152248
commit 1c6659def5
7 changed files with 1113 additions and 3 deletions

View File

@ -16,7 +16,9 @@ export class QqbotPluginPackageSourceService {
* Creates a manifest-only package descriptor source.
* @param pathPolicy - Root and entry policy that keeps package discovery inside controlled directories.
*/
constructor(private readonly pathPolicy: QqbotPluginPackagePathPolicyService) {}
constructor(
private readonly pathPolicy: QqbotPluginPackagePathPolicyService,
) {}
/**
* Discovers package descriptors from one-level package directories under controlled roots.
@ -54,6 +56,21 @@ export class QqbotPluginPackageSourceService {
}
const manifestLike = JSON.parse(readFileSync(manifestFile, 'utf8'));
return this.resolveDescriptor(controlledPackageRoot, manifestLike);
}
/**
* Resolves a descriptor from an already available manifest without re-reading `plugin.json`.
* @param packageRoot - Installed or discovered QQBot plugin package directory.
* @param manifestLike - Manifest JSON stored for the package version or read from disk.
* @returns Descriptor with a policy-normalized package root and entry file.
*/
resolveDescriptor(
packageRoot: string,
manifestLike: unknown,
): QqbotPluginPackageDescriptor {
const controlledPackageRoot =
this.pathPolicy.assertControlledPackageRoot(packageRoot);
const manifest = parseQqbotPluginManifest(manifestLike, {
pluginRoot: controlledPackageRoot,
});
@ -61,14 +78,13 @@ export class QqbotPluginPackageSourceService {
controlledPackageRoot,
manifest.entry,
);
const pluginKey = manifest.key;
return {
entry: manifest.entry,
entryFile,
manifest,
packageRoot: controlledPackageRoot,
pluginKey,
pluginKey: manifest.key,
};
}

View File

@ -2,3 +2,6 @@ export * from './worker-runtime';
export * from './worker-runtime.types';
export * from './builtin-plugin-worker-runtime.factory';
export * from './bullmq-plugin-worker-request.queue';
export * from './plugin-host-bridge.service';
export * from './plugin-host-bridge.types';
export * from './plugin-worker-runtime.factory';

View File

@ -0,0 +1,304 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { join } from 'node:path';
import { Worker } from 'node:worker_threads';
import { QqbotPluginPackageSourceService } from '../package/plugin-package-source.service';
import type {
QqbotPluginPackageDescriptor,
QqbotPluginRuntimeConfigSnapshot,
} from '../package/plugin-package.types';
import { QqbotPluginHostBridgeService } from './plugin-host-bridge.service';
import {
createQqbotBullmqWorkerQueueOptions,
QqbotBullmqPluginWorkerRequestQueue,
} from './bullmq-plugin-worker-request.queue';
import {
QqbotPluginWorkerResponseError,
QqbotPluginWorkerRuntime,
serializePluginWorkerResponseError,
} from './worker-runtime';
import type {
QqbotPluginWorkerDriver,
QqbotPluginWorkerRequest,
} from './worker-runtime.types';
import type { QqbotPluginRuntimeFactory } from '@/modules/qqbot/plugin-platform/application/plugin-platform.service';
import type {
QqbotPluginInstallation,
QqbotPluginVersion,
} from '@/modules/qqbot/plugin-platform/infrastructure/persistence';
type WorkerBridgeMessage =
| {
error?: { message?: string; name?: string; stack?: string };
ok: boolean;
requestId: string;
result?: unknown;
type: 'response';
}
| {
args?: Record<string, unknown>;
method: string;
requestId: string;
type: 'hostCall';
};
type PendingRequest = {
reject: (reason?: unknown) => void;
resolve: (value: unknown) => void;
};
type QqbotPluginWorkerThreadDriverOptions = {
configSnapshot: QqbotPluginRuntimeConfigSnapshot;
descriptor: QqbotPluginPackageDescriptor;
installationId: string;
pluginKey: string;
};
@Injectable()
export class QqbotPluginWorkerRuntimeFactoryService implements QqbotPluginRuntimeFactory {
/**
* Creates the descriptor-based worker runtime factory.
* @param configService - Nest config source used for BullMQ queue options and runtime config snapshots.
* @param packageSource - Package descriptor resolver that applies controlled-root and entry policies.
* @param hostBridge - Generic host bridge used by worker host-call RPC messages.
*/
constructor(
private readonly configService: ConfigService,
private readonly packageSource: QqbotPluginPackageSourceService,
private readonly hostBridge: QqbotPluginHostBridgeService,
) {}
/**
* Creates a worker runtime for one plugin installation and version descriptor.
* @param installation - Installation row whose `installedPath` identifies the package root.
* @param version - Version row whose `manifestJson` is the source of runtime manifest semantics.
* @returns Worker runtime backed by a descriptor-based worker thread and BullMQ serialization queue.
*/
create(
installation: QqbotPluginInstallation,
version: QqbotPluginVersion,
): QqbotPluginWorkerRuntime {
const descriptor = this.packageSource.resolveDescriptor(
installation.installedPath,
version.manifestJson,
);
const configSnapshot = this.createConfigSnapshot(descriptor);
const driver = new QqbotPluginWorkerThreadDriver(this.hostBridge, {
configSnapshot,
descriptor,
installationId: installation.id,
pluginKey: descriptor.pluginKey,
});
return new QqbotPluginWorkerRuntime(
new QqbotBullmqPluginWorkerRequestQueue(
driver,
createQqbotBullmqWorkerQueueOptions(
this.configService,
descriptor.pluginKey,
installation.id,
),
),
{
configSnapshot,
defaultTimeoutMs: descriptor.manifest.runtime.timeoutMs,
descriptor,
installationId: installation.id,
pluginKey: descriptor.pluginKey,
},
);
}
/**
* Captures manifest-declared runtime config keys without hard-coding plugin-specific names.
* @param descriptor - Package descriptor whose manifest owns the config key declarations.
* @returns String snapshot preserving missing keys as `undefined`.
*/
private createConfigSnapshot(
descriptor: QqbotPluginPackageDescriptor,
): QqbotPluginRuntimeConfigSnapshot {
return Object.fromEntries(
descriptor.manifest.runtime.configKeys.map((key) => {
const value = this.configService.get<string | number | boolean | null>(
key,
);
return [
key,
value === undefined || value === null ? undefined : `${value}`,
];
}),
);
}
}
export class QqbotPluginWorkerThreadDriver implements QqbotPluginWorkerDriver {
private readonly pendingRequests = new Map<string, PendingRequest>();
private worker?: Worker;
/**
* Creates a thread driver for one descriptor-based plugin runtime.
* @param hostBridge - Generic platform host bridge used to satisfy worker host calls.
* @param options - Descriptor, installation id, plugin key, and config snapshot passed to workerData.
*/
constructor(
private readonly hostBridge: QqbotPluginHostBridgeService,
private readonly options: QqbotPluginWorkerThreadDriverOptions,
) {}
/**
* Sends one runtime request to the worker thread and waits for its response.
* @param message - Lifecycle, operation, task, or event request produced by QqbotPluginWorkerRuntime.
* @returns Worker response payload for the requested plugin action.
*/
async request(message: QqbotPluginWorkerRequest): Promise<unknown> {
const worker = this.ensureWorker();
return new Promise((resolve, reject) => {
this.pendingRequests.set(message.correlationId, { reject, resolve });
worker.postMessage({
message,
requestId: message.correlationId,
type: 'request',
});
});
}
/**
* Terminates the worker thread and rejects in-flight requests for this runtime instance.
*/
async dispose(): Promise<void> {
const worker = this.worker;
this.worker = undefined;
this.rejectPending(new Error('QQBot 插件 worker 已关闭'));
if (worker) {
await worker.terminate();
}
}
/**
* Lazily starts the worker thread with descriptor, installation, plugin key, and config snapshot workerData.
* @returns Active worker thread for the current descriptor runtime.
*/
private ensureWorker() {
if (this.worker) return this.worker;
const worker = new Worker(resolveWorkerEntrypoint(), {
execArgv: resolveWorkerExecArgv(),
workerData: {
configSnapshot: this.options.configSnapshot,
descriptor: this.options.descriptor,
installationId: this.options.installationId,
pluginKey: this.options.pluginKey,
},
});
worker.on('message', (message: WorkerBridgeMessage) => {
void this.handleWorkerMessage(message);
});
worker.on('error', (error) => {
this.worker = undefined;
this.rejectPending(error);
});
worker.on('exit', (code) => {
this.worker = undefined;
if (code !== 0) {
this.rejectPending(new Error(`QQBot 插件 worker 异常退出:${code}`));
}
});
this.worker = worker;
return worker;
}
/**
* Handles worker responses and host-call requests emitted by the child thread.
* @param message - Worker bridge message containing either a runtime response or a host capability request.
*/
private async handleWorkerMessage(message: WorkerBridgeMessage) {
if (message.type === 'response') {
this.settleWorkerResponse(message);
return;
}
try {
const response = await this.hostBridge.handleHostCall(
this.options.descriptor,
{
args: message.args || {},
method: message.method,
pluginKey: this.options.pluginKey,
},
);
if (response.ok === true) {
this.worker?.postMessage({
ok: true,
requestId: message.requestId,
result: response.value,
type: 'hostResponse',
});
return;
}
this.worker?.postMessage({
error: {
message:
response.ok === false ? response.message : 'Host call failed',
name: 'QqbotPluginHostCallError',
},
ok: false,
requestId: message.requestId,
type: 'hostResponse',
});
} catch (error) {
this.worker?.postMessage({
error: serializePluginWorkerResponseError(error),
ok: false,
requestId: message.requestId,
type: 'hostResponse',
});
}
}
/**
* Resolves or rejects one pending runtime request from a worker response message.
* @param message - Worker response carrying the original request id and serialized result or error.
*/
private settleWorkerResponse(
message: Extract<WorkerBridgeMessage, { type: 'response' }>,
) {
const pending = this.pendingRequests.get(message.requestId);
if (!pending) return;
this.pendingRequests.delete(message.requestId);
if (message.ok) {
pending.resolve(message.result);
return;
}
pending.reject(new QqbotPluginWorkerResponseError(message.error || {}));
}
/**
* Rejects all in-flight requests when the worker exits or is disposed.
* @param error - Runtime boundary error propagated to all pending callers.
*/
private rejectPending(error: Error) {
for (const pending of this.pendingRequests.values()) {
pending.reject(error);
}
this.pendingRequests.clear();
}
}
/**
* Resolves the generic worker thread entrypoint next to the compiled factory file.
* @returns Absolute worker entry file path with the current TypeScript or JavaScript extension.
*/
function resolveWorkerEntrypoint() {
const extension = __filename.endsWith('.ts') ? '.ts' : '.js';
return join(__dirname, `plugin-worker.thread${extension}`);
}
/**
* Resolves worker exec arguments needed when tests or local dev run TypeScript sources directly.
* @returns Node exec arguments that register ts-node only for `.ts` runtime files.
*/
function resolveWorkerExecArgv() {
if (!__filename.endsWith('.ts')) return [];
return ['-r', 'ts-node/register', '-r', 'tsconfig-paths/register'];
}

View File

@ -0,0 +1,640 @@
import { createRequire } from 'node:module';
import { readFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { parentPort, workerData } from 'node:worker_threads';
import { pathToFileURL } from 'node:url';
import type {
QqbotPluginPackageDescriptor,
QqbotPluginRuntimeConfigSnapshot,
} from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package.types';
import type { QqbotPluginWorkerRequest } from './worker-runtime.types';
import type { QqbotPluginWorkerRequestType } from './worker-runtime.types';
export type QqbotWorkerCreatePluginOptions = {
configSnapshot: QqbotPluginRuntimeConfigSnapshot;
descriptor: QqbotPluginPackageDescriptor;
host: Record<string, unknown>;
installationId: string;
};
export type QqbotWorkerPluginInstance = {
activate?: () => Promise<unknown> | unknown;
dispose?: () => Promise<unknown> | unknown;
executeOperation?: (
operationKey: string,
input: unknown,
) => Promise<unknown> | unknown;
executeTask?: (
taskKey: string,
input: unknown,
context?: unknown,
) => Promise<unknown> | unknown;
handleEvent?: (
eventKey: string,
event: unknown,
) => Promise<unknown> | unknown;
health?: () => Promise<unknown> | unknown;
healthCheck?: () => Promise<unknown> | unknown;
operations?: unknown[];
tasks?: unknown[];
};
type PluginEntryModule = {
createPlugin?: (input: {
host: Record<string, unknown>;
manifest: QqbotPluginPackageDescriptor['manifest'];
normalizeError: (error: unknown, fallback?: string) => string;
now: () => Date;
runtime: {
configSnapshot: QqbotPluginRuntimeConfigSnapshot;
installationId: string;
};
}) => Promise<QqbotWorkerPluginInstance> | QqbotWorkerPluginInstance;
default?: {
createPlugin?: PluginEntryModule['createPlugin'];
};
};
type ParentMessage =
| {
message: QqbotPluginWorkerRequest;
requestId: string;
type: 'request';
}
| {
error?: { message?: string; name?: string; stack?: string };
ok: boolean;
requestId: string;
result?: unknown;
type: 'hostResponse';
};
type PendingHostCall = {
reject: (reason?: unknown) => void;
resolve: (value: unknown) => void;
};
type HostArgumentMapper = (...args: unknown[]) => Record<string, unknown>;
const HOST_ARGUMENT_MAPPERS: Record<string, HostArgumentMapper> = {
bindEventPlugin: (selfId, pluginKey) => ({ pluginKey, selfId }),
getBoundEventPluginKeys: (selfId) => ({ selfId }),
getConfig: (key) => ({ key }),
getConfigMany: (keys) => ({ keys }),
getDictByKey: (dictCode) => ({ dictCode }),
getDictItemsByKey: (dictCode) => ({ dictCode }),
readAssetFile: (path) => ({ path }),
readJsonFile: (path) => ({ path }),
relationTree: (input) => ({ input }),
renameFile: (from, to) => ({ from, to }),
requestBuffer: (options) => ({ options }),
requestJson: (options) => ({ options }),
sendText: (input) => ({ input }),
sleep: (ms) => ({ ms }),
unbindEventPlugin: (selfId, pluginKey) => ({ pluginKey, selfId }),
warn: (message) => ({ message }),
writeJsonFile: (path, data) => ({ data, path }),
};
const port = parentPort;
const pendingHostCalls = new Map<string, PendingHostCall>();
const requireEntryModule = createRequire(__filename);
let plugin: QqbotWorkerPluginInstance | undefined;
const WORKER_REQUEST_HANDLERS: Record<
QqbotPluginWorkerRequestType,
(message: QqbotPluginWorkerRequest) => Promise<unknown> | unknown
> = {
/**
* Loads a descriptor entry and stores the plugin instance for later requests.
* @param message - Load request carrying installation fallback metadata.
*/
load: (message) => loadPlugin(message),
/**
* Activates the loaded plugin instance.
*/
activate: async () => {
await requirePlugin().activate?.();
return { ok: true };
},
/**
* Runs the loaded plugin health hook.
*/
health: () => healthPlugin(),
/**
* Executes one manifest operation.
* @param message - Operation request with operation key and input.
*/
executeOperation: (message) => executeOperation(message),
/**
* Executes one manifest task.
* @param message - Task request with task key and input.
*/
executeTask: (message) => executeTask(message),
/**
* Dispatches one manifest event.
* @param message - Event request with event key and payload.
*/
handleEvent: (message) => handleEvent(message),
/**
* Marks the worker inactive without disposing the plugin instance.
*/
deactivate: () => ({ ok: true }),
/**
* Disposes the loaded plugin instance and clears worker state.
*/
dispose: async () => {
await plugin?.dispose?.();
plugin = undefined;
return { ok: true };
},
};
/**
* Dynamically imports a plugin package entry and creates its runtime instance.
* @param options - Descriptor, host facade, installation id, and config snapshot supplied by the worker factory.
* @returns Plugin instance returned by the package `createPlugin` entry.
*/
export async function createPluginFromDescriptor(
options: QqbotWorkerCreatePluginOptions,
): Promise<QqbotWorkerPluginInstance> {
const moduleUrl = pathToFileURL(options.descriptor.entryFile).href;
const entryModule = await importPluginEntryModule(
moduleUrl,
options.descriptor.entryFile,
);
const createPlugin =
entryModule.createPlugin || entryModule.default?.createPlugin;
if (typeof createPlugin !== 'function') {
throw new Error('Plugin entry must export createPlugin(options)');
}
return createPlugin({
host: options.host,
manifest: options.descriptor.manifest,
normalizeError,
now: () => new Date(),
runtime: {
configSnapshot: options.configSnapshot,
installationId: options.installationId,
},
});
}
if (port) {
port.on('message', (message: ParentMessage) => {
void handleParentMessage(message);
});
}
/**
* Handles a parent RPC or host-call response message inside the worker thread.
* @param message - Parent message carrying a worker request or the response for a pending host call.
*/
async function handleParentMessage(message: ParentMessage) {
if (message.type === 'hostResponse') {
settleHostResponse(message);
return;
}
try {
const result = await handleWorkerRequest(message.message);
port?.postMessage({
ok: true,
requestId: message.requestId,
result,
type: 'response',
});
} catch (error) {
port?.postMessage({
error: serializeError(error),
ok: false,
requestId: message.requestId,
type: 'response',
});
}
}
/**
* Dispatches a lifecycle or execution request to the loaded plugin instance.
* @param message - Worker request produced by QqbotPluginWorkerRuntime.
* @returns Plugin lifecycle, operation, task, or event result.
*/
async function handleWorkerRequest(message: QqbotPluginWorkerRequest) {
return WORKER_REQUEST_HANDLERS[message.type](message);
}
/**
* Creates and stores the plugin instance for subsequent worker requests.
* @param message - Load request carrying the installation id fallback used by the worker runtime.
* @returns Safe load summary for the parent runtime.
*/
async function loadPlugin(message: QqbotPluginWorkerRequest) {
const descriptor = getWorkerDescriptor();
plugin = await createPluginFromDescriptor({
configSnapshot: getWorkerConfigSnapshot(),
descriptor,
host: createHostFacade(),
installationId: getWorkerInstallationId(message),
});
return {
ok: true,
pluginKey: descriptor.pluginKey,
};
}
/**
* Runs the plugin health hook using either modern `health` or legacy `healthCheck`.
* @returns Health payload produced by the plugin, or a generic healthy status.
*/
async function healthPlugin() {
const loadedPlugin = requirePlugin();
if (loadedPlugin.health) return loadedPlugin.health();
if (loadedPlugin.healthCheck) return loadedPlugin.healthCheck();
return { ok: true, status: 'healthy' };
}
/**
* Executes one plugin operation through the generic instance contract.
* @param message - Operation request with manifest operation key and user input payload.
* @returns Operation output produced by the plugin.
*/
async function executeOperation(message: QqbotPluginWorkerRequest) {
const loadedPlugin = requirePlugin();
const operationKey = requireRequestKey(
message.operationKey || message.operationId,
'QQBot 插件能力缺少 operationKey',
);
if (loadedPlugin.executeOperation) {
return loadedPlugin.executeOperation(operationKey, message.input);
}
const operation = findRuntimeCallable(loadedPlugin.operations, operationKey);
if (operation) {
return operation.execute(message.input || {});
}
throw new Error(`QQBot 插件能力不存在:${operationKey}`);
}
/**
* Executes one plugin task through the generic instance contract.
* @param message - Task request with task key, handler name, trigger type, and input payload.
* @returns Task output produced by the plugin.
*/
async function executeTask(message: QqbotPluginWorkerRequest) {
const loadedPlugin = requirePlugin();
const taskKey = requireRequestKey(
message.taskKey || message.taskHandlerName,
'QQBot 插件定时任务缺少 taskKey',
);
const context = {
taskHandlerName: message.taskHandlerName,
taskId: message.taskId,
triggerType: message.triggerType,
};
if (loadedPlugin.executeTask) {
return loadedPlugin.executeTask(taskKey, message.input, context);
}
const task = findRuntimeCallable(
loadedPlugin.tasks,
taskKey,
message.taskHandlerName,
);
if (task) {
return task.execute(message.input || {}, context);
}
throw new Error(`QQBot 插件定时任务不存在:${taskKey}`);
}
/**
* Dispatches one plugin event through the generic instance contract.
* @param message - Event request with event key and normalized event payload.
* @returns Event handling result produced by the plugin.
*/
async function handleEvent(message: QqbotPluginWorkerRequest) {
const loadedPlugin = requirePlugin();
const eventKey = requireRequestKey(
message.eventKey,
'QQBot 插件事件缺少 eventKey',
);
if (!loadedPlugin.handleEvent) return false;
return loadedPlugin.handleEvent(eventKey, message.event);
}
/**
* Creates a host facade whose methods proxy generic host capabilities to the parent process.
* @returns Record of host capability functions passed to plugin package entries.
*/
function createHostFacade(): Record<string, unknown> {
const host: Record<string, unknown> = {};
for (const method of Object.keys(HOST_ARGUMENT_MAPPERS)) {
host[method] = (...args: unknown[]) =>
callHost(method, HOST_ARGUMENT_MAPPERS[method](...args));
}
return new Proxy(host, {
/**
* Resolves unknown host methods to generic RPC calls while keeping promise introspection stable.
* @param target - Known host capability map.
* @param property - Host method name requested by plugin package code.
* @returns Existing method, generic method proxy, or `undefined` for non-method symbols.
*/
get(target, property) {
if (typeof property !== 'string') return undefined;
if (property in target) return target[property];
if (property === 'then') return undefined;
return (...args: unknown[]) =>
callHost(property, normalizeHostArgs(args));
},
});
}
/**
* Sends one host capability request to the parent process and waits for its response.
* @param method - Generic host capability name.
* @param args - Structured argument payload for the host bridge.
* @returns Host bridge result value.
*/
function callHost<TResult = unknown>(
method: string,
args: Record<string, unknown>,
): Promise<TResult> {
if (!port) {
return Promise.reject(
new Error('QQBot plugin worker host port unavailable'),
);
}
const requestId = `host-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return new Promise<TResult>((resolve, reject) => {
pendingHostCalls.set(requestId, {
reject,
/**
* Resolves a host response while preserving the generic result type requested by plugin code.
* @param value - Raw host bridge response value sent by the parent process.
*/
resolve: (value) => resolve(value as TResult),
});
port.postMessage({
args,
method,
requestId,
type: 'hostCall',
});
});
}
/**
* Resolves or rejects a pending host call from a parent host response message.
* @param message - Host response carrying the original host-call request id.
*/
function settleHostResponse(
message: Extract<ParentMessage, { type: 'hostResponse' }>,
) {
const pending = pendingHostCalls.get(message.requestId);
if (!pending) return;
pendingHostCalls.delete(message.requestId);
if (message.ok) {
pending.resolve(message.result);
return;
}
pending.reject(deserializeError(message.error));
}
/**
* Reads the workerData descriptor and rejects malformed worker startup state.
* @returns Package descriptor supplied by QqbotPluginWorkerThreadDriver.
*/
function getWorkerDescriptor(): QqbotPluginPackageDescriptor {
const descriptor = workerData?.descriptor;
if (!descriptor || typeof descriptor !== 'object') {
throw new Error('QQBot 插件 worker 缺少 descriptor');
}
return descriptor as QqbotPluginPackageDescriptor;
}
/**
* Reads the workerData config snapshot as a simple string map.
* @returns Runtime config snapshot supplied by QqbotPluginWorkerThreadDriver.
*/
function getWorkerConfigSnapshot(): QqbotPluginRuntimeConfigSnapshot {
const snapshot = workerData?.configSnapshot;
return snapshot && typeof snapshot === 'object'
? (snapshot as QqbotPluginRuntimeConfigSnapshot)
: {};
}
/**
* Reads the installation id from workerData, falling back to the load request for recovery calls.
* @param message - Load request that may carry the installation id from QqbotPluginWorkerRuntime.
* @returns Installation id passed to the plugin runtime contract.
*/
function getWorkerInstallationId(message: QqbotPluginWorkerRequest) {
const installationId = workerData?.installationId || message.installationId;
return typeof installationId === 'string' ? installationId : '';
}
/**
* Returns the currently loaded plugin instance or throws a worker-safe error.
* @returns Plugin instance created during the load request.
*/
function requirePlugin() {
if (!plugin) {
throw new Error('QQBot 插件运行时未加载');
}
return plugin;
}
/**
* Reads a required request key from a worker request.
* @param value - Candidate request key from operation, task, or event payload.
* @param message - Error message used when the key is absent.
* @returns Non-empty request key.
*/
function requireRequestKey(value: unknown, message: string) {
if (typeof value === 'string' && value.trim()) return value.trim();
throw new Error(message);
}
/**
* Finds an executable operation or task from a manifest-shaped runtime summary.
* @param items - Runtime operation or task list returned by plugin entry code.
* @param key - Operation or task key requested by the platform.
* @param handlerName - Optional handler name fallback for task execution.
* @returns Callable runtime item when found.
*/
function findRuntimeCallable(
items: unknown[] | undefined,
key: string,
handlerName?: string,
) {
return items
?.filter(isRuntimeCallable)
.find(
(item) =>
item.key === key ||
item.handlerName === key ||
(!!handlerName && item.handlerName === handlerName),
);
}
/**
* Checks whether a runtime summary item exposes an executable function.
* @param item - Unknown operation or task summary returned by plugin code.
* @returns `true` when the item can execute worker input.
*/
function isRuntimeCallable(item: unknown): item is {
execute: (input: unknown, context?: unknown) => Promise<unknown> | unknown;
handlerName?: string;
key?: string;
} {
return (
!!item &&
typeof item === 'object' &&
typeof (item as { execute?: unknown }).execute === 'function'
);
}
/**
* Normalizes unknown host arguments for unlisted host methods.
* @param args - Positional arguments supplied by plugin package code.
* @returns Record payload accepted by the parent host bridge.
*/
function normalizeHostArgs(args: unknown[]): Record<string, unknown> {
if (args.length === 1 && isRecord(args[0])) return args[0];
return { args };
}
/**
* Checks whether a value can be passed as a structured host-call argument record.
* @param value - Candidate host-call argument value.
* @returns `true` when the value is a non-null object and not an array.
*/
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Imports a plugin entry with a URL-based dynamic import and falls back to CommonJS loading in Jest/dev hooks.
* @param moduleUrl - File URL produced from the descriptor entry path.
* @param entryFile - Absolute descriptor entry file used only when the dynamic import host cannot resolve file URLs.
* @returns Plugin entry module namespace.
*/
async function importPluginEntryModule(moduleUrl: string, entryFile: string) {
try {
return await dynamicImportPluginEntry(moduleUrl);
} catch (error) {
if (!shouldFallbackToRequire(error, moduleUrl)) {
throw error;
}
if (entryFile.endsWith('.js') || entryFile.endsWith('.cjs')) {
return loadCommonJsEntryModule(entryFile);
}
return requireEntryModule(entryFile) as PluginEntryModule;
}
}
/**
* Uses native dynamic import so Jest CommonJS transforms do not rewrite file URL imports.
* @param moduleUrl - File URL pointing at the descriptor entry module.
* @returns Plugin entry module namespace loaded by Node.
*/
function dynamicImportPluginEntry(
moduleUrl: string,
): Promise<PluginEntryModule> {
const importer = new Function('moduleUrl', 'return import(moduleUrl)') as (
value: string,
) => Promise<PluginEntryModule>;
return importer(moduleUrl);
}
/**
* Detects Jest/ts-node resolution failures for URL imports while preserving real plugin errors.
* @param error - Dynamic import error thrown by the runtime host.
* @param moduleUrl - File URL attempted by the dynamic import.
* @returns Whether CommonJS fallback is appropriate for the same entry file.
*/
function shouldFallbackToRequire(error: unknown, moduleUrl: string) {
if (!moduleUrl.startsWith('file:')) return false;
const message = error instanceof Error ? error.message : `${error}`;
return (
message.includes('Cannot find module') ||
message.includes('dynamic import callback') ||
message.includes('Unknown file extension')
);
}
/**
* Loads a CommonJS plugin entry directly from disk for Jest environments that cannot execute file URL imports.
* @param entryFile - Absolute descriptor entry file compiled as CommonJS.
* @returns Plugin entry module exports.
*/
function loadCommonJsEntryModule(entryFile: string): PluginEntryModule {
const nodeModule = requireEntryModule(
'node:module',
) as typeof import('node:module');
const moduleConstructor = nodeModule.Module as typeof nodeModule.Module & {
_nodeModulePaths: (from: string) => string[];
};
const entryModule = new moduleConstructor(entryFile);
entryModule.filename = entryFile;
entryModule.paths = moduleConstructor._nodeModulePaths(dirname(entryFile));
(
entryModule as typeof entryModule & {
_compile: (content: string, filename: string) => void;
}
)._compile(readFileSync(entryFile, 'utf8'), entryFile);
return entryModule.exports as PluginEntryModule;
}
/**
* Normalizes plugin errors into stable text for package entry adapters.
* @param error - Error or arbitrary thrown value from package code.
* @param fallback - Message used when the thrown value has no useful text.
* @returns Stable error message for plugin-visible health or execution output.
*/
function normalizeError(error: unknown, fallback = '插件执行失败') {
if (error instanceof Error && error.message) return error.message;
const message = `${error || ''}`.trim();
return message || fallback;
}
/**
* Serializes an error for parent-process worker responses.
* @param error - Error or arbitrary thrown value from worker or plugin code.
* @returns Worker-safe error payload with message, name, and stack when available.
*/
function serializeError(error: unknown) {
return {
message: error instanceof Error ? error.message : `${error}`,
name: error instanceof Error ? error.name : 'Error',
stack: error instanceof Error ? error.stack : undefined,
};
}
/**
* Deserializes a parent host-call error into an Error instance.
* @param error - Serialized error payload from the parent host bridge.
* @returns Error instance rejected to plugin host facade callers.
*/
function deserializeError(error?: {
message?: string;
name?: string;
stack?: string;
}) {
const output = new Error(error?.message || '插件 Host 调用失败');
if (error?.name) output.name = error.name;
if (error?.stack) output.stack = error.stack;
return output;
}

View File

@ -59,6 +59,7 @@ export type QqbotPluginWorkerRequestQueue = {
};
export type QqbotPluginWorkerRuntimeOptions = {
configSnapshot?: QqbotPluginRuntimeConfigSnapshot;
defaultTimeoutMs: number;
descriptor?: QqbotPluginPackageDescriptor;
installationId: string;

View File

@ -8,7 +8,10 @@ import { QqbotEventPluginRegistryService } from '../../../../src/modules/qqbot/p
import { QqbotPluginRegistryService } from '../../../../src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service';
import { QqbotPluginPlatformModule } from '../../../../src/modules/qqbot/plugin-platform/plugin-platform.module';
import { QqbotBuiltinPluginPackageLoaderService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service';
import { QqbotPluginPackageSourceService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-source.service';
import { QqbotPluginHostBridgeService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service';
import { QqbotBuiltinPluginWorkerRuntimeFactoryService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime';
import { QqbotPluginWorkerRuntimeFactoryService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime';
const repoRoot = join(__dirname, '../../../..');
@ -62,6 +65,35 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
expect(dependencies?.[1]).toBe(ConfigService);
});
it('keeps the generic worker runtime factory dependency visible to Nest DI', () => {
const dependencies = Reflect.getMetadata(
'design:paramtypes',
QqbotPluginWorkerRuntimeFactoryService,
);
expect(dependencies?.[0]).toBe(ConfigService);
expect(dependencies?.[1]).toBe(QqbotPluginPackageSourceService);
expect(dependencies?.[2]).toBe(QqbotPluginHostBridgeService);
});
it('keeps the generic worker runtime descriptor-based and plugin-agnostic', () => {
const source = [
readSource(
'src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-worker-runtime.factory.ts',
),
readSource(
'src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-worker.thread.ts',
),
].join('\n');
expect(source).toContain('node:worker_threads');
expect(source).toContain('descriptor');
expect(source).not.toContain('QqbotBuiltinPluginPackageLoaderService');
expect(source).not.toMatch(
/@\/modules\/qqbot\/plugins|src\/modules\/qqbot\/plugins|createBangDreamPlugin|createFf14MarketPlugin|createFflogsPlugin|createRepeaterPlugin|pluginKey\s*===|case\s+['"]/,
);
});
it('uses a real worker-thread boundary for built-in plugin runtimes', () => {
const source = readSource(
'src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker-runtime.factory.ts',

View File

@ -0,0 +1,114 @@
import {
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { QqbotPluginManifest } from '@/modules/qqbot/plugin-platform/domain/manifest';
import { createPluginFromDescriptor } from '@/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-worker.thread';
describe('generic plugin worker entry', () => {
let tempRoot: string;
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), 'qqbot-plugin-worker-'));
});
afterEach(() => {
rmSync(tempRoot, { force: true, recursive: true });
});
it('loads a package entry from the descriptor instead of a plugin key switch', async () => {
const packageRoot = join(tempRoot, 'sample');
mkdirSync(join(packageRoot, 'src'), { recursive: true });
const entryFile = join(packageRoot, 'src', 'index.js');
writeFileSync(
entryFile,
`
exports.createPlugin = (options) => ({
activate: async () => undefined,
health: async () => ({
configSnapshot: options.runtime.configSnapshot,
installationId: options.runtime.installationId,
ok: true,
pluginKey: options.manifest.key,
}),
operations: [],
});
`,
'utf8',
);
const plugin = await createPluginFromDescriptor({
configSnapshot: { SAMPLE_TOKEN: 'local-token' },
descriptor: {
entry: 'src/index.js',
entryFile,
manifest: createManifest('sample', 'src/index.js'),
packageRoot,
pluginKey: 'sample',
},
host: {},
installationId: 'install-1',
});
await expect(plugin.health?.()).resolves.toEqual({
configSnapshot: { SAMPLE_TOKEN: 'local-token' },
installationId: 'install-1',
ok: true,
pluginKey: 'sample',
});
});
it('keeps the generic worker entry free of concrete plugin branches', () => {
const source = readFileSync(
join(
process.cwd(),
'src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-worker.thread.ts',
),
'utf8',
);
expect(source).toContain('node:worker_threads');
expect(source).toContain('descriptor');
expect(source).not.toMatch(
/createBangDreamPlugin|createFf14MarketPlugin|createFflogsPlugin|createRepeaterPlugin|pluginKey\s*===|case\s+['"]/,
);
});
});
/**
* Builds a minimal parsed-manifest-shaped descriptor manifest for direct worker entry tests.
* @param key - Plugin package key used by the temp descriptor and health assertion.
* @param entry - Package-relative entry file that the descriptor resolves to the temp module.
* @returns Manifest payload accepted by descriptor-based worker creation.
*/
function createManifest(key: string, entry: string): QqbotPluginManifest {
return {
assets: [],
configSchema: {},
entry,
events: [],
key,
legacyAliases: [],
migrations: [],
minApiSdkVersion: '1.0.0',
name: 'Sample',
operations: [],
permissions: [],
pluginKey: key,
runtime: {
configKeys: ['SAMPLE_TOKEN'],
maxConcurrency: 1,
memoryMb: 128,
timeoutMs: 5000,
workerType: 'thread',
},
tasks: [],
version: '1.0.0',
};
}