first commit
This commit is contained in:
19
packages/plugin-runtime/package.json
Normal file
19
packages/plugin-runtime/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@remoteconn/plugin-runtime",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remoteconn/shared": "1.0.0"
|
||||
}
|
||||
}
|
||||
311
packages/plugin-runtime/src/core/pluginManager.ts
Normal file
311
packages/plugin-runtime/src/core/pluginManager.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import type { PluginHostApis, PluginPackage, PluginRecord, PluginFsAdapter, PluginCommand } from "../types/plugin";
|
||||
import { validatePluginPackage } from "./validator";
|
||||
|
||||
interface RuntimeSlot {
|
||||
pluginId: string;
|
||||
cleanupFns: Array<() => void>;
|
||||
styleDisposer?: () => void;
|
||||
api?: {
|
||||
onload?: (ctx: unknown) => Promise<void> | void;
|
||||
onunload?: () => Promise<void> | void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生产可用插件管理器:
|
||||
* 1. 安装校验
|
||||
* 2. 生命周期
|
||||
* 3. 单插件熔断
|
||||
* 4. 权限最小化
|
||||
*/
|
||||
export class PluginManager {
|
||||
private readonly records = new Map<string, PluginRecord>();
|
||||
private readonly runtime = new Map<string, RuntimeSlot>();
|
||||
private readonly commands = new Map<string, PluginCommand>();
|
||||
|
||||
public constructor(
|
||||
private readonly fs: PluginFsAdapter,
|
||||
private readonly apis: PluginHostApis,
|
||||
private readonly options: {
|
||||
appVersion: string;
|
||||
mountStyle: (pluginId: string, css: string) => () => void;
|
||||
logger: (level: "info" | "warn" | "error", pluginId: string, message: string) => void;
|
||||
}
|
||||
) {}
|
||||
|
||||
public async bootstrap(): Promise<void> {
|
||||
const stored = await this.fs.readStore<PluginRecord[]>("plugin_records", []);
|
||||
for (const record of stored) {
|
||||
this.records.set(record.id, record);
|
||||
}
|
||||
|
||||
const packages = await this.fs.listPackages();
|
||||
for (const item of packages) {
|
||||
if (!this.records.has(item.manifest.id)) {
|
||||
this.records.set(item.manifest.id, this.createRecord(item.manifest.id));
|
||||
}
|
||||
}
|
||||
|
||||
await this.persistRecords();
|
||||
for (const record of this.records.values()) {
|
||||
if (record.enabled) {
|
||||
try {
|
||||
await this.enable(record.id);
|
||||
} catch (error) {
|
||||
this.options.logger("error", record.id, `自动启用失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async installPackage(payload: PluginPackage): Promise<void> {
|
||||
const valid = validatePluginPackage(payload);
|
||||
await this.fs.upsertPackage(valid);
|
||||
const record = this.records.get(valid.manifest.id) ?? this.createRecord(valid.manifest.id);
|
||||
record.status = "validated";
|
||||
record.updatedAt = new Date().toISOString();
|
||||
record.lastError = "";
|
||||
this.records.set(valid.manifest.id, record);
|
||||
await this.persistRecords();
|
||||
}
|
||||
|
||||
public async enable(pluginId: string): Promise<void> {
|
||||
const pluginPackage = await this.fs.getPackage(pluginId);
|
||||
if (!pluginPackage) {
|
||||
throw new Error("插件不存在");
|
||||
}
|
||||
|
||||
const validPackage = validatePluginPackage(pluginPackage);
|
||||
const record = this.records.get(pluginId) ?? this.createRecord(pluginId);
|
||||
|
||||
if (record.errorCount >= 3) {
|
||||
throw new Error("插件已熔断,请先重载后再启用");
|
||||
}
|
||||
|
||||
if (this.runtime.has(pluginId)) {
|
||||
await this.disable(pluginId);
|
||||
}
|
||||
|
||||
record.status = "loading";
|
||||
record.lastError = "";
|
||||
this.records.set(pluginId, record);
|
||||
await this.persistRecords();
|
||||
|
||||
const runtime: RuntimeSlot = {
|
||||
pluginId,
|
||||
cleanupFns: []
|
||||
};
|
||||
|
||||
try {
|
||||
runtime.styleDisposer = this.options.mountStyle(pluginId, validPackage.stylesCss);
|
||||
const ctx = this.createContext(validPackage.manifest, runtime);
|
||||
runtime.api = this.loadPluginApi(validPackage.mainJs, ctx);
|
||||
|
||||
if (runtime.api?.onload) {
|
||||
await this.runWithTimeout(Promise.resolve(runtime.api.onload(ctx)), 3000, "onload 超时");
|
||||
}
|
||||
|
||||
record.enabled = true;
|
||||
record.status = "active";
|
||||
record.lastLoadedAt = new Date().toISOString();
|
||||
this.runtime.set(pluginId, runtime);
|
||||
await this.persistRecords();
|
||||
this.options.logger("info", pluginId, "插件已启用");
|
||||
} catch (error) {
|
||||
runtime.styleDisposer?.();
|
||||
record.enabled = false;
|
||||
record.status = "failed";
|
||||
record.errorCount += 1;
|
||||
record.lastError = (error as Error).message;
|
||||
this.records.set(pluginId, record);
|
||||
await this.persistRecords();
|
||||
this.options.logger("error", pluginId, `启用失败: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async disable(pluginId: string): Promise<void> {
|
||||
const runtime = this.runtime.get(pluginId);
|
||||
const record = this.records.get(pluginId) ?? this.createRecord(pluginId);
|
||||
|
||||
if (runtime) {
|
||||
try {
|
||||
if (runtime.api?.onunload) {
|
||||
await this.runWithTimeout(Promise.resolve(runtime.api.onunload()), 3000, "onunload 超时");
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.logger("warn", pluginId, `onunload 异常: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
for (const cleanup of runtime.cleanupFns) {
|
||||
cleanup();
|
||||
}
|
||||
runtime.styleDisposer?.();
|
||||
this.runtime.delete(pluginId);
|
||||
}
|
||||
|
||||
for (const id of Array.from(this.commands.keys())) {
|
||||
if (id.startsWith(`${pluginId}:`)) {
|
||||
this.commands.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
record.enabled = false;
|
||||
if (record.status !== "failed") {
|
||||
record.status = "stopped";
|
||||
}
|
||||
this.records.set(pluginId, record);
|
||||
await this.persistRecords();
|
||||
}
|
||||
|
||||
public async reload(pluginId: string): Promise<void> {
|
||||
const record = this.records.get(pluginId) ?? this.createRecord(pluginId);
|
||||
record.errorCount = 0;
|
||||
record.lastError = "";
|
||||
this.records.set(pluginId, record);
|
||||
await this.persistRecords();
|
||||
|
||||
await this.disable(pluginId);
|
||||
await this.enable(pluginId);
|
||||
}
|
||||
|
||||
public async remove(pluginId: string): Promise<void> {
|
||||
await this.disable(pluginId);
|
||||
await this.fs.removePackage(pluginId);
|
||||
this.records.delete(pluginId);
|
||||
await this.persistRecords();
|
||||
}
|
||||
|
||||
public listRecords(): PluginRecord[] {
|
||||
return Array.from(this.records.values());
|
||||
}
|
||||
|
||||
public listCommands(sessionState: "connected" | "disconnected"): PluginCommand[] {
|
||||
return Array.from(this.commands.values()).filter((command) => {
|
||||
if (command.when === "connected") {
|
||||
return sessionState === "connected";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public async runCommand(commandId: string): Promise<void> {
|
||||
const command = this.commands.get(commandId);
|
||||
if (!command) {
|
||||
throw new Error("命令不存在");
|
||||
}
|
||||
await Promise.resolve(command.handler());
|
||||
}
|
||||
|
||||
private createContext(manifest: PluginPackage["manifest"], runtime: RuntimeSlot): unknown {
|
||||
const assertPermission = (permission: string): void => {
|
||||
if (!manifest.permissions.includes(permission as never)) {
|
||||
throw new Error(`权限不足: ${permission}`);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
app: this.apis.getAppMeta(),
|
||||
commands: {
|
||||
register: (command: PluginCommand) => {
|
||||
assertPermission("commands.register");
|
||||
const id = `${manifest.id}:${command.id}`;
|
||||
this.commands.set(id, {
|
||||
...command,
|
||||
id
|
||||
});
|
||||
runtime.cleanupFns.push(() => this.commands.delete(id));
|
||||
}
|
||||
},
|
||||
session: {
|
||||
send: async (input: string) => {
|
||||
assertPermission("session.write");
|
||||
await this.apis.session.send(input);
|
||||
},
|
||||
on: (eventName: "connected" | "disconnected" | "stdout" | "stderr" | "latency", handler: (payload: unknown) => void) => {
|
||||
assertPermission("session.read");
|
||||
const off = this.apis.session.on(eventName, handler);
|
||||
runtime.cleanupFns.push(off);
|
||||
return off;
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
get: async (key: string) => {
|
||||
assertPermission("storage.read");
|
||||
const value = await this.fs.readStore<Record<string, unknown>>(`plugin_data_${manifest.id}`, {});
|
||||
return value[key];
|
||||
},
|
||||
set: async (key: string, value: unknown) => {
|
||||
assertPermission("storage.write");
|
||||
const previous = await this.fs.readStore<Record<string, unknown>>(`plugin_data_${manifest.id}`, {});
|
||||
previous[key] = value;
|
||||
await this.fs.writeStore(`plugin_data_${manifest.id}`, previous);
|
||||
}
|
||||
},
|
||||
ui: {
|
||||
showNotice: (message: string, level: "info" | "warn" | "error" = "info") => {
|
||||
assertPermission("ui.notice");
|
||||
this.apis.showNotice(message, level);
|
||||
}
|
||||
},
|
||||
logger: {
|
||||
info: (...args: string[]) => this.options.logger("info", manifest.id, args.join(" ")),
|
||||
warn: (...args: string[]) => this.options.logger("warn", manifest.id, args.join(" ")),
|
||||
error: (...args: string[]) => this.options.logger("error", manifest.id, args.join(" "))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private loadPluginApi(mainJs: string, ctx: unknown): RuntimeSlot["api"] {
|
||||
const module = { exports: {} as RuntimeSlot["api"] };
|
||||
const exportsRef = module.exports;
|
||||
|
||||
const fn = new Function(
|
||||
"ctx",
|
||||
"module",
|
||||
"exports",
|
||||
`"use strict";
|
||||
const window = undefined;
|
||||
const document = undefined;
|
||||
const localStorage = undefined;
|
||||
const globalThis = undefined;
|
||||
${mainJs}
|
||||
return module.exports;`
|
||||
);
|
||||
|
||||
return (fn(ctx, module, exportsRef) as RuntimeSlot["api"]) ?? module.exports;
|
||||
}
|
||||
|
||||
private async runWithTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createRecord(id: string): PluginRecord {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
enabled: false,
|
||||
status: "discovered",
|
||||
errorCount: 0,
|
||||
lastError: "",
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
lastLoadedAt: ""
|
||||
};
|
||||
}
|
||||
|
||||
private async persistRecords(): Promise<void> {
|
||||
await this.fs.writeStore("plugin_records", Array.from(this.records.values()));
|
||||
}
|
||||
}
|
||||
23
packages/plugin-runtime/src/core/validator.test.ts
Normal file
23
packages/plugin-runtime/src/core/validator.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validatePluginPackage } from "./validator";
|
||||
|
||||
describe("plugin validator", () => {
|
||||
it("通过最小合法插件", () => {
|
||||
expect(() =>
|
||||
validatePluginPackage({
|
||||
manifest: {
|
||||
id: "codex-shortcuts",
|
||||
name: "Codex Shortcuts",
|
||||
version: "0.1.0",
|
||||
minAppVersion: "0.1.0",
|
||||
description: "test",
|
||||
entry: "main.js",
|
||||
style: "styles.css",
|
||||
permissions: ["commands.register"]
|
||||
},
|
||||
mainJs: "module.exports = {};",
|
||||
stylesCss: ".x { color: red; }"
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
55
packages/plugin-runtime/src/core/validator.ts
Normal file
55
packages/plugin-runtime/src/core/validator.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { PERMISSION_WHITELIST, type PluginPackage } from "../types/plugin";
|
||||
|
||||
/**
|
||||
* 插件包静态校验。
|
||||
*/
|
||||
export function validatePluginPackage(pluginPackage: PluginPackage): PluginPackage {
|
||||
const manifest = pluginPackage?.manifest;
|
||||
if (!manifest) {
|
||||
throw new Error("缺少 manifest");
|
||||
}
|
||||
|
||||
const required = ["id", "name", "version", "minAppVersion", "description", "entry", "style", "permissions"];
|
||||
for (const key of required) {
|
||||
if (!(key in manifest)) {
|
||||
throw new Error(`manifest 缺少字段: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]{1,62}$/.test(manifest.id)) {
|
||||
throw new Error("插件 id 不符合规范");
|
||||
}
|
||||
|
||||
if (!/^\d+\.\d+\.\d+$/.test(manifest.version)) {
|
||||
throw new Error("version 必须是 SemVer,例如 0.1.0");
|
||||
}
|
||||
|
||||
if (!/^\d+\.\d+\.\d+$/.test(manifest.minAppVersion)) {
|
||||
throw new Error("minAppVersion 必须是 SemVer");
|
||||
}
|
||||
|
||||
if (manifest.entry !== "main.js" || manifest.style !== "styles.css") {
|
||||
throw new Error("entry/style 目前固定为 main.js / styles.css");
|
||||
}
|
||||
|
||||
const allowed = new Set(PERMISSION_WHITELIST);
|
||||
for (const permission of manifest.permissions || []) {
|
||||
if (!allowed.has(permission)) {
|
||||
throw new Error(`未知权限: ${permission}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!pluginPackage.mainJs?.trim()) {
|
||||
throw new Error("mainJs 不能为空");
|
||||
}
|
||||
|
||||
if (pluginPackage.stylesCss == null) {
|
||||
throw new Error("stylesCss 不能为空");
|
||||
}
|
||||
|
||||
if (/^\s*(\*|body|html)\s*[{,]/m.test(pluginPackage.stylesCss)) {
|
||||
throw new Error("styles.css 禁止全局选择器(* / body / html)");
|
||||
}
|
||||
|
||||
return pluginPackage;
|
||||
}
|
||||
3
packages/plugin-runtime/src/index.ts
Normal file
3
packages/plugin-runtime/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types/plugin";
|
||||
export * from "./core/pluginManager";
|
||||
export * from "./core/validator";
|
||||
69
packages/plugin-runtime/src/types/plugin.ts
Normal file
69
packages/plugin-runtime/src/types/plugin.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export const PERMISSION_WHITELIST = [
|
||||
"commands.register",
|
||||
"session.read",
|
||||
"session.write",
|
||||
"ui.notice",
|
||||
"ui.statusbar",
|
||||
"storage.read",
|
||||
"storage.write",
|
||||
"logs.read"
|
||||
] as const;
|
||||
|
||||
export type PluginPermission = (typeof PERMISSION_WHITELIST)[number];
|
||||
|
||||
export interface PluginManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
minAppVersion: string;
|
||||
description: string;
|
||||
entry: "main.js";
|
||||
style: "styles.css";
|
||||
permissions: PluginPermission[];
|
||||
author?: string;
|
||||
homepage?: string;
|
||||
}
|
||||
|
||||
export interface PluginPackage {
|
||||
manifest: PluginManifest;
|
||||
mainJs: string;
|
||||
stylesCss: string;
|
||||
}
|
||||
|
||||
export interface PluginRecord {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
status: "discovered" | "validated" | "loading" | "active" | "stopped" | "failed";
|
||||
errorCount: number;
|
||||
lastError?: string;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
lastLoadedAt?: string;
|
||||
}
|
||||
|
||||
export interface PluginCommand {
|
||||
id: string;
|
||||
title: string;
|
||||
when?: "always" | "connected";
|
||||
handler: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface PluginFsAdapter {
|
||||
listPackages(): Promise<PluginPackage[]>;
|
||||
getPackage(pluginId: string): Promise<PluginPackage | null>;
|
||||
upsertPackage(pluginPackage: PluginPackage): Promise<void>;
|
||||
removePackage(pluginId: string): Promise<void>;
|
||||
readStore<T>(key: string, fallback: T): Promise<T>;
|
||||
writeStore<T>(key: string, value: T): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginSessionApi {
|
||||
send(input: string): Promise<void>;
|
||||
on(eventName: "connected" | "disconnected" | "stdout" | "stderr" | "latency", handler: (payload: unknown) => void): () => void;
|
||||
}
|
||||
|
||||
export interface PluginHostApis {
|
||||
getAppMeta(): { version: string; platform: "web" | "ios" | "miniapp" };
|
||||
session: PluginSessionApi;
|
||||
showNotice(message: string, level: "info" | "warn" | "error"): void;
|
||||
}
|
||||
12
packages/plugin-runtime/tsconfig.json
Normal file
12
packages/plugin-runtime/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@remoteconn/shared": ["../shared/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user