feat: plugin hot-reload + dev-folder loading
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
|
import { getDevPlugin } from '@/lib/admin/plugin-dev';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||||
@@ -8,7 +10,7 @@ import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
|||||||
* Only serves plugins that exist in the registry and are enabled.
|
* Only serves plugins that exist in the registry and are enabled.
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -19,6 +21,21 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dev plugins are read straight from disk and served with no caching so
|
||||||
|
// every refresh picks up the latest build.
|
||||||
|
const devEntry = await getDevPlugin(id);
|
||||||
|
if (devEntry) {
|
||||||
|
const code = await readFile(devEntry.bundlePath, 'utf-8');
|
||||||
|
return new NextResponse(code, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/javascript; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
||||||
|
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const plugin = await getPlugin(id);
|
const plugin = await getPlugin(id);
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
@@ -33,13 +50,22 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Bundle not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Bundle not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NextResponse(code, {
|
// Use the registry's bundleHash as the ETag so the browser can revalidate
|
||||||
headers: {
|
// cheaply. Cache-Control: no-cache forces revalidation on every request,
|
||||||
'Content-Type': 'application/javascript; charset=utf-8',
|
// but a matching If-None-Match returns 304 with no body.
|
||||||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
const etag = plugin.bundleHash ? `"${plugin.bundleHash}"` : undefined;
|
||||||
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
const headers: Record<string, string> = {
|
||||||
},
|
'Content-Type': 'application/javascript; charset=utf-8',
|
||||||
});
|
'Cache-Control': 'private, no-cache, must-revalidate',
|
||||||
|
};
|
||||||
|
if (etag) headers['ETag'] = etag;
|
||||||
|
|
||||||
|
if (etag && request.headers.get('if-none-match') === etag) {
|
||||||
|
return new NextResponse(null, { status: 304, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
headers['Content-Length'] = String(Buffer.byteLength(code, 'utf-8'));
|
||||||
|
return new NextResponse(code, { headers });
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-16
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
||||||
|
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,26 +11,38 @@ import { logger } from '@/lib/logger';
|
|||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const [pluginRegistry, themeRegistry] = await Promise.all([
|
const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([
|
||||||
getPluginRegistry(),
|
getPluginRegistry(),
|
||||||
getThemeRegistry(),
|
getThemeRegistry(),
|
||||||
|
listDevPlugins(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Only serve enabled plugins
|
// Dev plugins win on id collision so a developer can shadow an installed
|
||||||
const plugins = pluginRegistry.plugins
|
// plugin without uninstalling it first.
|
||||||
.filter(p => p.enabled)
|
const devIds = new Set(devEntries.map(e => e.plugin.id));
|
||||||
.map(p => ({
|
const installedEnabled = pluginRegistry.plugins.filter(p => p.enabled && !devIds.has(p.id));
|
||||||
id: p.id,
|
|
||||||
name: p.name,
|
const plugins = [
|
||||||
version: p.version,
|
...devEntries.map(e => ({ ...e.plugin, dev: true })),
|
||||||
author: p.author,
|
...installedEnabled.map(p => ({ ...p, dev: false })),
|
||||||
description: p.description,
|
].map(p => ({
|
||||||
type: p.type,
|
id: p.id,
|
||||||
permissions: p.permissions,
|
name: p.name,
|
||||||
entrypoint: p.entrypoint,
|
version: p.version,
|
||||||
forceEnabled: p.forceEnabled || false,
|
author: p.author,
|
||||||
settingsSchema: undefined, // Will be read from the bundle's manifest
|
description: p.description,
|
||||||
}));
|
type: p.type,
|
||||||
|
permissions: p.permissions,
|
||||||
|
entrypoint: p.entrypoint,
|
||||||
|
forceEnabled: p.forceEnabled || false,
|
||||||
|
// Content hash + updatedAt let clients detect re-uploads even when
|
||||||
|
// the manifest version is unchanged.
|
||||||
|
bundleHash: p.bundleHash,
|
||||||
|
updatedAt: p.updatedAt,
|
||||||
|
// Marks plugins loaded from PLUGIN_DEV_DIR. Surface in UI as a badge.
|
||||||
|
dev: p.dev,
|
||||||
|
settingsSchema: undefined, // Will be read from the bundle's manifest
|
||||||
|
}));
|
||||||
|
|
||||||
// Only serve enabled themes
|
// Only serve enabled themes
|
||||||
const themes = themeRegistry.themes
|
const themes = themeRegistry.themes
|
||||||
|
|||||||
@@ -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 { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
@@ -41,6 +42,12 @@ export interface ServerPlugin {
|
|||||||
configSchema?: Record<string, PluginConfigField>;
|
configSchema?: Record<string, PluginConfigField>;
|
||||||
installedAt: string;
|
installedAt: string;
|
||||||
updatedAt: 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
|
* Validated CSP origins (https-only, single-origin form) the plugin may
|
||||||
* embed. Merged into the host frame-src by the proxy.
|
* 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`);
|
const bundlePath = path.join(dir, `${plugin.id}.js`);
|
||||||
await writeFile(bundlePath, code, 'utf-8');
|
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 registry = await getPluginRegistry();
|
||||||
const idx = registry.plugins.findIndex(p => p.id === plugin.id);
|
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) {
|
if (idx >= 0) {
|
||||||
registry.plugins[idx] = plugin;
|
registry.plugins[idx] = next;
|
||||||
} else {
|
} else {
|
||||||
registry.plugins.push(plugin);
|
registry.plugins.push(next);
|
||||||
}
|
}
|
||||||
await writeJsonFile(pluginRegistryPath(), registry);
|
await writeJsonFile(pluginRegistryPath(), registry);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,11 @@ export interface InstalledPlugin {
|
|||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
/** Bundled translations, carried over from the manifest on install. */
|
/** Bundled translations, carried over from the manifest on install. */
|
||||||
locales?: Record<string, Record<string, string>>;
|
locales?: Record<string, Record<string, string>>;
|
||||||
|
/**
|
||||||
|
* Content hash of the installed bundle, mirrored from the server. Used to
|
||||||
|
* detect re-uploads of the same version so clients re-download the JS.
|
||||||
|
*/
|
||||||
|
bundleHash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── UI Slots ────────────────────────────────────────────────
|
// ─── UI Slots ────────────────────────────────────────────────
|
||||||
|
|||||||
+22
-6
@@ -303,6 +303,11 @@ interface ServerPluginInfo {
|
|||||||
permissions: string[];
|
permissions: string[];
|
||||||
entrypoint: string;
|
entrypoint: string;
|
||||||
forceEnabled: boolean;
|
forceEnabled: boolean;
|
||||||
|
/** Content hash of the bundle - changes whenever code changes, even if the version doesn't */
|
||||||
|
bundleHash?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
/** True when the plugin was loaded from the server's PLUGIN_DEV_DIR */
|
||||||
|
dev?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SERVER_MANAGED_KEY = 'server-managed-plugin-ids';
|
const SERVER_MANAGED_KEY = 'server-managed-plugin-ids';
|
||||||
@@ -382,7 +387,7 @@ async function syncServerPlugins(
|
|||||||
|
|
||||||
if (!local) {
|
if (!local) {
|
||||||
// New server plugin - download and install
|
// New server plugin - download and install
|
||||||
const code = await downloadPluginBundle(sp.id);
|
const code = await downloadPluginBundle(sp.id, sp.bundleHash);
|
||||||
if (!code) continue;
|
if (!code) continue;
|
||||||
|
|
||||||
await pluginStorage.saveCode(sp.id, code);
|
await pluginStorage.saveCode(sp.id, code);
|
||||||
@@ -402,6 +407,7 @@ async function syncServerPlugins(
|
|||||||
forceEnabled: sp.forceEnabled,
|
forceEnabled: sp.forceEnabled,
|
||||||
adminApproved: true, // Server-managed plugins are always approved
|
adminApproved: true, // Server-managed plugins are always approved
|
||||||
settings: {},
|
settings: {},
|
||||||
|
bundleHash: sp.bundleHash,
|
||||||
};
|
};
|
||||||
|
|
||||||
set(state => {
|
set(state => {
|
||||||
@@ -410,9 +416,15 @@ async function syncServerPlugins(
|
|||||||
}
|
}
|
||||||
return { plugins: [...state.plugins, plugin] };
|
return { plugins: [...state.plugins, plugin] };
|
||||||
});
|
});
|
||||||
} else if (local.version !== sp.version) {
|
} else if (
|
||||||
// Version changed - re-download bundle
|
local.version !== sp.version ||
|
||||||
const code = await downloadPluginBundle(sp.id);
|
// bundleHash mismatch covers re-uploads of the same version with new
|
||||||
|
// code. Falsy local hash (older installs that never carried one) also
|
||||||
|
// forces a refresh so we capture the hash on the next sync.
|
||||||
|
(sp.bundleHash && local.bundleHash !== sp.bundleHash)
|
||||||
|
) {
|
||||||
|
// Version or content changed - re-download bundle
|
||||||
|
const code = await downloadPluginBundle(sp.id, sp.bundleHash);
|
||||||
if (!code) continue;
|
if (!code) continue;
|
||||||
|
|
||||||
await pluginStorage.saveCode(sp.id, code);
|
await pluginStorage.saveCode(sp.id, code);
|
||||||
@@ -430,6 +442,7 @@ async function syncServerPlugins(
|
|||||||
entrypoint: sp.entrypoint,
|
entrypoint: sp.entrypoint,
|
||||||
managed: true,
|
managed: true,
|
||||||
forceEnabled: sp.forceEnabled,
|
forceEnabled: sp.forceEnabled,
|
||||||
|
bundleHash: sp.bundleHash,
|
||||||
}
|
}
|
||||||
: p
|
: p
|
||||||
),
|
),
|
||||||
@@ -483,9 +496,12 @@ async function syncServerPlugins(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadPluginBundle(pluginId: string): Promise<string | null> {
|
async function downloadPluginBundle(pluginId: string, bundleHash?: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/bundle`);
|
// Append the hash as a query string so any intermediary HTTP cache
|
||||||
|
// (browser, service worker, CDN) treats each version as a distinct URL.
|
||||||
|
const suffix = bundleHash ? `?v=${encodeURIComponent(bundleHash)}` : '';
|
||||||
|
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/bundle${suffix}`);
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return await res.text();
|
return await res.text();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user