feat(plugin): add local plugin discovery and management with cache busting

This commit is contained in:
HouYunFei
2026-07-15 12:55:59 +08:00
parent eef4d96787
commit 88510223eb
5 changed files with 92 additions and 12 deletions
+6 -2
View File
@@ -29,13 +29,17 @@ npm run dev # watch,改动自动构建并同步
## 本地开发
`npm run dev` 起 watch, `web/.env.local` 声明开发插件(逗号分隔多个):
`npm run dev` 起 watch,产物会同步到 `web/public/plugins/<name>.js`。此后有两种方式在画布里用到它:
**方式一(推荐):自动发现。** 画布启动时会扫描 `web/public/plugins/` 下的插件,自动加入「节点插件」管理器列表,**默认关闭**;打开开关即启用。无需手动填 URL,启用时会按文件重新拉取,配合 watch 改完刷新即最新。
**方式二:`VITE_DEV_PLUGINS`。**`web/.env.local` 声明(逗号分隔多个),这些插件每次刷新页面都**重新拉取并直接激活**(不缓存、不落库、无开关):
```env
VITE_DEV_PLUGINS=/plugins/markdown.js,/plugins/svg.js
```
再起画布 `web`(`npm run dev`)。`VITE_DEV_PLUGINS` 里的插件每次刷新页面都会**重新拉取**(不缓存、不落库),流程即:改 `src/index.jsx` → watch 自动构建 → 刷新画布看到最新效果,无需反复安装。
再起画布 `web`(`npm run dev`)。流程即:改 `src/index.jsx` → watch 自动构建 → 刷新画布看到最新效果,无需反复安装。
## 插件文件构成
@@ -80,6 +80,11 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
<span className="rounded-full px-1.5 py-0.5 text-[10px]" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
v{record.version}
</span>
{record.local && (
<span className="rounded-full px-1.5 py-0.5 text-[10px]" style={{ background: "#22c55e22", color: "#16a34a" }}>
</span>
)}
</div>
<div className="truncate text-xs" style={{ color: theme.node.muted }}>
{record.description || record.url}
@@ -91,10 +96,14 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
loading={busyId === record.id}
onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), checked ? "已启用" : "已禁用")}
/>
<Button type="text" size="small" icon={<RefreshCw className="size-4" />} loading={busyId === record.id} title="从来源更新" onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")} />
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}>
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
</Popconfirm>
{!record.local && (
<>
<Button type="text" size="small" icon={<RefreshCw className="size-4" />} loading={busyId === record.id} title="从来源更新" onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")} />
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}>
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
</Popconfirm>
</>
)}
</div>
))
)}
+41 -3
View File
@@ -50,6 +50,11 @@ async function fetchPluginSource(url: string) {
return response.text();
}
// 加缓存穿透参数,配合 watch 构建拿到最新产物
function withCacheBust(url: string) {
return `${url}${url.includes("?") ? "&" : "?"}t=${Date.now()}`;
}
// 从 URL 安装(或覆盖更新)一个插件,成功后立即启用
export async function installPluginFromUrl(url: string) {
const source = await fetchPluginSource(url);
@@ -70,7 +75,9 @@ export async function setPluginEnabled(record: InstalledPlugin, enabled: boolean
deactivatePlugin(record.id);
return;
}
const plugin = await evaluatePluginSource(record.source);
// 本地插件启用时按 url 重新拉取,拿到最新构建(缓存 source 可能已过期)
const source = record.local ? await fetchPluginSource(withCacheBust(record.url)) : record.source;
const plugin = await evaluatePluginSource(source);
activatePlugin(plugin);
}
@@ -86,11 +93,14 @@ export async function ensurePluginsLoaded() {
if (loaded) return;
loaded = true;
await usePluginStore.persist.rehydrate();
await loadLocalPlugins(); // 先发现本地插件(默认关闭),再统一按 enabled 激活
const records = usePluginStore.getState().plugins.filter((record) => record.enabled);
await Promise.all(
records.map(async (record) => {
try {
activatePlugin(await evaluatePluginSource(record.source));
// 本地插件用最新产物,其余用缓存的源码
const source = record.local ? await fetchPluginSource(withCacheBust(record.url)) : record.source;
activatePlugin(await evaluatePluginSource(source));
} catch (error) {
console.error(`[plugin] 加载失败: ${record.id}`, error);
}
@@ -99,6 +109,34 @@ export async function ensurePluginsLoaded() {
await loadDevPlugins();
}
// 自动发现 web/public/plugins 下的本地插件:加入列表但默认关闭,
// 本地开发放好插件文件即可在管理器里看到并一键启用,无需手动填 URL。
// 已在列表中的(用户装过/发现过)不覆盖,尊重其现有开关。
async function loadLocalPlugins() {
let urls: unknown;
try {
const response = await fetch("/plugins/index.json");
if (!response.ok) return;
urls = await response.json();
} catch {
return; // 无本地清单(如生产环境未构建插件)则跳过
}
if (!Array.isArray(urls) || !urls.length) return;
const store = usePluginStore.getState();
await Promise.all(
urls.map(async (url: string) => {
try {
const source = await fetchPluginSource(withCacheBust(url));
const plugin = await evaluatePluginSource(source);
if (store.plugins.some((item) => item.id === plugin.id)) return;
store.upsert({ id: plugin.id, name: plugin.name || plugin.id, version: plugin.version || "0.0.0", description: plugin.description, url, source, enabled: false, local: true });
} catch (error) {
console.error(`[plugin] 本地插件发现失败: ${url}`, error);
}
}),
);
}
// 本地开发:VITE_DEV_PLUGINS 里的 URL 每次启动都重新拉取(不缓存、不落库),
// 配合 watch 构建即可「改代码→刷新页面」看到最新插件,无需反复安装。
async function loadDevPlugins() {
@@ -108,7 +146,7 @@ async function loadDevPlugins() {
await Promise.all(
urls.map(async (url) => {
try {
const source = await fetchPluginSource(`${url}${url.includes("?") ? "&" : "?"}t=${Date.now()}`);
const source = await fetchPluginSource(withCacheBust(url));
const plugin = await evaluatePluginSource(source);
deactivatePlugin(plugin.id);
activatePlugin(plugin);
@@ -11,6 +11,7 @@ export type InstalledPlugin = {
url: string; // 安装来源,可用于更新
source: string; // 缓存的插件源码,离线可用、版本固定
enabled: boolean;
local?: boolean; // 自动发现于 web/public/plugins 的本地插件(默认关闭,启用时按 url 重新拉取)
installedAt: string;
};
+31 -3
View File
@@ -1,8 +1,8 @@
import { readFileSync } from "node:fs";
import { readdirSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { defineConfig, type Plugin } from "vite";
import { parseChangelog } from "./src/lib/release";
@@ -10,9 +10,37 @@ const webDir = dirname(fileURLToPath(import.meta.url));
const localVersion = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim() || "dev";
const localChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8");
// 暴露 /plugins/index.json:列出 public/plugins 下的本地插件文件,
// 供前端自动发现并加入插件列表(默认关闭)。dev 下实时读目录,构建时产出静态清单。
function localPluginsManifest(): Plugin {
const pluginsDir = resolve(webDir, "public/plugins");
const listLocalPlugins = () => {
try {
return readdirSync(pluginsDir)
.filter((file) => file.endsWith(".js"))
.sort()
.map((file) => `/plugins/${file}`);
} catch {
return [];
}
};
return {
name: "local-plugins-manifest",
configureServer(server) {
server.middlewares.use("/plugins/index.json", (_req, res) => {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(listLocalPlugins()));
});
},
generateBundle() {
this.emitFile({ type: "asset", fileName: "plugins/index.json", source: JSON.stringify(listLocalPlugins()) });
},
};
}
export default defineConfig({
base: process.env.VITE_BASE || "/",
plugins: [react()],
plugins: [react(), localPluginsManifest()],
resolve: {
alias: {
"@": resolve(webDir, "src"),