feat: plugin hot-reload + dev-folder loading
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { ServerPlugin } from './plugin-registry';
|
||||
|
||||
/**
|
||||
* Dev-mode plugin loading.
|
||||
*
|
||||
* When the `PLUGIN_DEV_DIR` env var points at a directory, every immediate
|
||||
* subfolder is treated as a candidate plugin and merged into the registry
|
||||
* served to clients.
|
||||
*
|
||||
* PLUGIN_DEV_DIR=/path/to/repos/plugins
|
||||
*
|
||||
* Each subfolder must contain `manifest.json` and the entrypoint file. If a
|
||||
* `dist/` subdirectory exists with its own `manifest.json` (typical for
|
||||
* plugins built via esbuild) we use that instead — so no extra copy step is
|
||||
* needed during development.
|
||||
*
|
||||
* Dev plugins always win on id collision with admin-installed plugins, the
|
||||
* bundle is served with `Cache-Control: no-store`, and the bundle hash is
|
||||
* recomputed on every request so that any save propagates to all connected
|
||||
* clients on their next page refresh.
|
||||
*/
|
||||
|
||||
export interface DevPluginEntry {
|
||||
plugin: ServerPlugin;
|
||||
bundlePath: string;
|
||||
manifestPath: string;
|
||||
}
|
||||
|
||||
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
||||
|
||||
export function getPluginDevDir(): string | null {
|
||||
const dir = process.env.PLUGIN_DEV_DIR;
|
||||
if (!dir) return null;
|
||||
const resolved = path.resolve(dir);
|
||||
if (!existsSync(resolved)) {
|
||||
logger.warn(`PLUGIN_DEV_DIR is set but does not exist: ${resolved}`);
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function asString(v: unknown, fallback = ''): string {
|
||||
return typeof v === 'string' ? v : fallback;
|
||||
}
|
||||
|
||||
async function readManifest(manifestPath: string): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const raw = await readFile(manifestPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null> {
|
||||
// Prefer dist/ when present (bundled output) so devs don't have to copy
|
||||
// manifest.json around.
|
||||
const distDir = path.join(pluginDir, 'dist');
|
||||
let manifestPath = path.join(distDir, 'manifest.json');
|
||||
let baseDir = distDir;
|
||||
if (!existsSync(manifestPath)) {
|
||||
manifestPath = path.join(pluginDir, 'manifest.json');
|
||||
baseDir = pluginDir;
|
||||
}
|
||||
if (!existsSync(manifestPath)) return null;
|
||||
|
||||
const manifest = await readManifest(manifestPath);
|
||||
if (!manifest) return null;
|
||||
const id = asString(manifest.id);
|
||||
if (!PLUGIN_ID_RE.test(id)) return null;
|
||||
|
||||
const entrypoint = asString(manifest.entrypoint, 'index.js');
|
||||
const bundlePath = path.join(baseDir, entrypoint);
|
||||
if (!existsSync(bundlePath)) return null;
|
||||
|
||||
let bundleHash: string;
|
||||
try {
|
||||
const code = await readFile(bundlePath);
|
||||
bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let installedAt = new Date().toISOString();
|
||||
try {
|
||||
const stats = await stat(bundlePath);
|
||||
installedAt = stats.mtime.toISOString();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const permissions = Array.isArray(manifest.permissions)
|
||||
? manifest.permissions.filter((p): p is string => typeof p === 'string')
|
||||
: [];
|
||||
|
||||
const plugin: ServerPlugin = {
|
||||
id,
|
||||
name: asString(manifest.name, id),
|
||||
version: asString(manifest.version, '0.0.0-dev'),
|
||||
author: asString(manifest.author),
|
||||
description: asString(manifest.description),
|
||||
type: asString(manifest.type, 'hook'),
|
||||
permissions,
|
||||
entrypoint,
|
||||
enabled: true,
|
||||
forceEnabled: false,
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
installedAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
bundleHash,
|
||||
};
|
||||
return { plugin, bundlePath, manifestPath };
|
||||
}
|
||||
|
||||
export async function listDevPlugins(): Promise<DevPluginEntry[]> {
|
||||
const dir = getPluginDevDir();
|
||||
if (!dir) return [];
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read PLUGIN_DEV_DIR', {
|
||||
dir,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const out: DevPluginEntry[] = [];
|
||||
for (const name of entries) {
|
||||
if (name.startsWith('.') || name === 'node_modules') continue;
|
||||
const fullPath = path.join(dir, name);
|
||||
let isDir = false;
|
||||
try { isDir = (await stat(fullPath)).isDirectory(); } catch { continue; }
|
||||
if (!isDir) continue;
|
||||
|
||||
const entry = await loadDevPlugin(fullPath);
|
||||
if (entry) out.push(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function getDevPlugin(id: string): Promise<DevPluginEntry | null> {
|
||||
if (!PLUGIN_ID_RE.test(id)) return null;
|
||||
const list = await listDevPlugins();
|
||||
return list.find(e => e.plugin.id === id) ?? null;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
@@ -41,6 +42,12 @@ export interface ServerPlugin {
|
||||
configSchema?: Record<string, PluginConfigField>;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
/**
|
||||
* SHA-256 hex of the bundle code (first 16 chars). Refreshed every save so
|
||||
* the same version re-uploaded with new code still appears as a change to
|
||||
* the client. Also doubles as the HTTP ETag for the bundle endpoint.
|
||||
*/
|
||||
bundleHash?: string;
|
||||
/**
|
||||
* Validated CSP origins (https-only, single-origin form) the plugin may
|
||||
* embed. Merged into the host frame-src by the proxy.
|
||||
@@ -120,13 +127,24 @@ export async function savePlugin(
|
||||
const bundlePath = path.join(dir, `${plugin.id}.js`);
|
||||
await writeFile(bundlePath, code, 'utf-8');
|
||||
|
||||
// Update registry
|
||||
// Stamp content hash + updatedAt so clients can detect re-uploads even
|
||||
// when the manifest version hasn't changed. Preserve the original
|
||||
// installedAt across re-uploads.
|
||||
const bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const registry = await getPluginRegistry();
|
||||
const idx = registry.plugins.findIndex(p => p.id === plugin.id);
|
||||
const next: ServerPlugin = {
|
||||
...plugin,
|
||||
bundleHash,
|
||||
updatedAt: now,
|
||||
installedAt: idx >= 0 ? registry.plugins[idx].installedAt : plugin.installedAt,
|
||||
};
|
||||
if (idx >= 0) {
|
||||
registry.plugins[idx] = plugin;
|
||||
registry.plugins[idx] = next;
|
||||
} else {
|
||||
registry.plugins.push(plugin);
|
||||
registry.plugins.push(next);
|
||||
}
|
||||
await writeJsonFile(pluginRegistryPath(), registry);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user