From 3e336d459c5976c926b79e5cb80f4fe454286c84 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 5 May 2026 18:05:17 +0200 Subject: [PATCH] feat: plugin hot-reload + dev-folder loading --- app/api/admin/plugins/[id]/bundle/route.ts | 42 ++++-- app/api/plugins/route.ts | 45 +++--- lib/admin/plugin-dev.ts | 156 +++++++++++++++++++++ lib/admin/plugin-registry.ts | 24 +++- lib/plugin-types.ts | 5 + stores/plugin-store.ts | 28 +++- 6 files changed, 267 insertions(+), 33 deletions(-) create mode 100644 lib/admin/plugin-dev.ts diff --git a/app/api/admin/plugins/[id]/bundle/route.ts b/app/api/admin/plugins/[id]/bundle/route.ts index 44923d20..d4fa1610 100644 --- a/app/api/admin/plugins/[id]/bundle/route.ts +++ b/app/api/admin/plugins/[id]/bundle/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; +import { readFile } from 'node:fs/promises'; 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 @@ -8,7 +10,7 @@ import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry'; * Only serves plugins that exist in the registry and are enabled. */ export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { @@ -19,6 +21,21 @@ export async function GET( 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); if (!plugin) { 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 new NextResponse(code, { - headers: { - 'Content-Type': 'application/javascript; charset=utf-8', - 'Cache-Control': 'public, max-age=3600, must-revalidate', - 'Content-Length': String(Buffer.byteLength(code, 'utf-8')), - }, - }); + // Use the registry's bundleHash as the ETag so the browser can revalidate + // cheaply. Cache-Control: no-cache forces revalidation on every request, + // but a matching If-None-Match returns 304 with no body. + const etag = plugin.bundleHash ? `"${plugin.bundleHash}"` : undefined; + const headers: Record = { + '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 { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index f0c96d9b..80498639 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry'; +import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { logger } from '@/lib/logger'; /** @@ -10,26 +11,38 @@ import { logger } from '@/lib/logger'; */ export async function GET() { try { - const [pluginRegistry, themeRegistry] = await Promise.all([ + const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([ getPluginRegistry(), getThemeRegistry(), + listDevPlugins(), ]); - // Only serve enabled plugins - const plugins = pluginRegistry.plugins - .filter(p => p.enabled) - .map(p => ({ - id: p.id, - name: p.name, - version: p.version, - author: p.author, - description: p.description, - type: p.type, - permissions: p.permissions, - entrypoint: p.entrypoint, - forceEnabled: p.forceEnabled || false, - settingsSchema: undefined, // Will be read from the bundle's manifest - })); + // Dev plugins win on id collision so a developer can shadow an installed + // plugin without uninstalling it first. + const devIds = new Set(devEntries.map(e => e.plugin.id)); + const installedEnabled = pluginRegistry.plugins.filter(p => p.enabled && !devIds.has(p.id)); + + const plugins = [ + ...devEntries.map(e => ({ ...e.plugin, dev: true })), + ...installedEnabled.map(p => ({ ...p, dev: false })), + ].map(p => ({ + id: p.id, + name: p.name, + version: p.version, + author: p.author, + 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 const themes = themeRegistry.themes diff --git a/lib/admin/plugin-dev.ts b/lib/admin/plugin-dev.ts new file mode 100644 index 00000000..5e3f7da8 --- /dev/null +++ b/lib/admin/plugin-dev.ts @@ -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 | 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 { + // 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 { + 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 { + if (!PLUGIN_ID_RE.test(id)) return null; + const list = await listDevPlugins(); + return list.find(e => e.plugin.id === id) ?? null; +} diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index 788aa732..13fab06c 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -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; 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); } diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 8f58778d..365778c2 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -168,6 +168,11 @@ export interface InstalledPlugin { settings: Record; /** Bundled translations, carried over from the manifest on install. */ locales?: Record>; + /** + * 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 ──────────────────────────────────────────────── diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 55499fcd..6b455826 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -303,6 +303,11 @@ interface ServerPluginInfo { permissions: string[]; entrypoint: string; 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'; @@ -382,7 +387,7 @@ async function syncServerPlugins( if (!local) { // New server plugin - download and install - const code = await downloadPluginBundle(sp.id); + const code = await downloadPluginBundle(sp.id, sp.bundleHash); if (!code) continue; await pluginStorage.saveCode(sp.id, code); @@ -402,6 +407,7 @@ async function syncServerPlugins( forceEnabled: sp.forceEnabled, adminApproved: true, // Server-managed plugins are always approved settings: {}, + bundleHash: sp.bundleHash, }; set(state => { @@ -410,9 +416,15 @@ async function syncServerPlugins( } return { plugins: [...state.plugins, plugin] }; }); - } else if (local.version !== sp.version) { - // Version changed - re-download bundle - const code = await downloadPluginBundle(sp.id); + } else if ( + local.version !== sp.version || + // 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; await pluginStorage.saveCode(sp.id, code); @@ -430,6 +442,7 @@ async function syncServerPlugins( entrypoint: sp.entrypoint, managed: true, forceEnabled: sp.forceEnabled, + bundleHash: sp.bundleHash, } : p ), @@ -483,9 +496,12 @@ async function syncServerPlugins( } } -async function downloadPluginBundle(pluginId: string): Promise { +async function downloadPluginBundle(pluginId: string, bundleHash?: string): Promise { 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; return await res.text(); } catch {