update at 2026-03-03 21:19:52

This commit is contained in:
douboer@gmail.com
2026-03-03 21:19:52 +08:00
parent 3dc4144007
commit e4987a2d77
139 changed files with 21522 additions and 43 deletions

View 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"
}
}

View 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()));
}
}

View 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();
});
});

View 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;
}

View File

@@ -0,0 +1,3 @@
export * from "./types/plugin";
export * from "./core/pluginManager";
export * from "./core/validator";

View 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;
}

View 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"]
}