feat: 增加通用插件worker运行时
This commit is contained in:
parent
46f9152248
commit
1c6659def5
@ -16,7 +16,9 @@ export class QqbotPluginPackageSourceService {
|
|||||||
* Creates a manifest-only package descriptor source.
|
* Creates a manifest-only package descriptor source.
|
||||||
* @param pathPolicy - Root and entry policy that keeps package discovery inside controlled directories.
|
* @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.
|
* 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'));
|
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, {
|
const manifest = parseQqbotPluginManifest(manifestLike, {
|
||||||
pluginRoot: controlledPackageRoot,
|
pluginRoot: controlledPackageRoot,
|
||||||
});
|
});
|
||||||
@ -61,14 +78,13 @@ export class QqbotPluginPackageSourceService {
|
|||||||
controlledPackageRoot,
|
controlledPackageRoot,
|
||||||
manifest.entry,
|
manifest.entry,
|
||||||
);
|
);
|
||||||
const pluginKey = manifest.key;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entry: manifest.entry,
|
entry: manifest.entry,
|
||||||
entryFile,
|
entryFile,
|
||||||
manifest,
|
manifest,
|
||||||
packageRoot: controlledPackageRoot,
|
packageRoot: controlledPackageRoot,
|
||||||
pluginKey,
|
pluginKey: manifest.key,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,3 +2,6 @@ export * from './worker-runtime';
|
|||||||
export * from './worker-runtime.types';
|
export * from './worker-runtime.types';
|
||||||
export * from './builtin-plugin-worker-runtime.factory';
|
export * from './builtin-plugin-worker-runtime.factory';
|
||||||
export * from './bullmq-plugin-worker-request.queue';
|
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';
|
||||||
|
|||||||
@ -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'];
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -59,6 +59,7 @@ export type QqbotPluginWorkerRequestQueue = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type QqbotPluginWorkerRuntimeOptions = {
|
export type QqbotPluginWorkerRuntimeOptions = {
|
||||||
|
configSnapshot?: QqbotPluginRuntimeConfigSnapshot;
|
||||||
defaultTimeoutMs: number;
|
defaultTimeoutMs: number;
|
||||||
descriptor?: QqbotPluginPackageDescriptor;
|
descriptor?: QqbotPluginPackageDescriptor;
|
||||||
installationId: string;
|
installationId: string;
|
||||||
|
|||||||
@ -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 { 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 { 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 { 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 { 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, '../../../..');
|
const repoRoot = join(__dirname, '../../../..');
|
||||||
|
|
||||||
@ -62,6 +65,35 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
|
|||||||
expect(dependencies?.[1]).toBe(ConfigService);
|
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', () => {
|
it('uses a real worker-thread boundary for built-in plugin runtimes', () => {
|
||||||
const source = readSource(
|
const source = readSource(
|
||||||
'src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker-runtime.factory.ts',
|
'src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker-runtime.factory.ts',
|
||||||
|
|||||||
114
test/modules/qqbot/plugin-platform/plugin-worker-entry.spec.ts
Normal file
114
test/modules/qqbot/plugin-platform/plugin-worker-entry.spec.ts
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user