From 7921223bcb379e8ac92d1c9175b24c1f12078ac7 Mon Sep 17 00:00:00 2001 From: Rawand Date: Thu, 5 Mar 2026 16:11:33 +0100 Subject: [PATCH 01/13] feat(plugins): add plugin system with manifest-driven loader Add plugin-loader package that reads plugins.json, validates manifests, and exposes hooks for api-server (models, routes, middleware, migrations) and admin-server (pages, menu items, widget definitions). Integration points: - api-server: db models, middleware (5 positions), routes, init/migrate database scripts - admin-server: dynamic sidenav items, catch-all plugin pages, widget definition merging, next.config transpilation - widget-settings: merges plugin widget definitions into core Safety: broken/missing plugins are logged and skipped, never crash the server. --- apps/admin-server/.gitignore | 3 + apps/admin-server/next.config.js | 208 +- apps/admin-server/package.json | 4 +- .../src/components/dialog-widget-create.tsx | 5 +- .../src/components/ui/sidenav.tsx | 39 + .../src/components/widget-preview.tsx | 3 +- .../src/hooks/use-widget-definitions.ts | 16 + apps/admin-server/src/hooks/use-widgets.tsx | 3 +- .../src/lib/widget-definitions.tsx | 7 +- apps/admin-server/src/middleware.ts | 1 + .../src/pages/[...pluginPath].tsx | 61 + .../src/pages/api/plugin-menu-items.ts | 6 + .../src/pages/api/widget-definitions.ts | 40 + .../[project]/resources/[id]/preview.tsx | 17 +- .../[project]/widgets/[...widgetPath].tsx | 74 + .../[project]/widgets/create/index.tsx | 5 +- .../projects/[project]/widgets/index.tsx | 5 +- .../widgets/test-resources/[id]/general.tsx | 75 + .../widgets/test-resources/[id]/index.tsx | 90 + apps/admin-server/tsconfig.json | 1 + apps/admin-server/types/plugins.d.ts | 19 + apps/api-server/scripts/init-database.js | 17 +- apps/api-server/scripts/migrate-database.js | 17 +- apps/api-server/src/Server.js | 115 + apps/api-server/src/db.js | 34 + .../src/routes/widget/widget-settings.js | 43 + docker-compose.yml | 8 + package-lock.json | 25476 ++++------------ packages/plugin-loader/index.js | 261 + packages/plugin-loader/index.test.js | 536 + packages/plugin-loader/package.json | 14 + packages/plugin-loader/validate.js | 126 + packages/plugin-loader/validate.test.js | 257 + packages/plugin-loader/vitest.config.ts | 5 + plugins.json | 9 + 35 files changed, 8271 insertions(+), 19329 deletions(-) create mode 100644 apps/admin-server/src/hooks/use-widget-definitions.ts create mode 100644 apps/admin-server/src/pages/[...pluginPath].tsx create mode 100644 apps/admin-server/src/pages/api/plugin-menu-items.ts create mode 100644 apps/admin-server/src/pages/api/widget-definitions.ts create mode 100644 apps/admin-server/src/pages/projects/[project]/widgets/[...widgetPath].tsx create mode 100644 apps/admin-server/src/pages/projects/[project]/widgets/test-resources/[id]/general.tsx create mode 100644 apps/admin-server/src/pages/projects/[project]/widgets/test-resources/[id]/index.tsx create mode 100644 apps/admin-server/types/plugins.d.ts create mode 100644 packages/plugin-loader/index.js create mode 100644 packages/plugin-loader/index.test.js create mode 100644 packages/plugin-loader/package.json create mode 100644 packages/plugin-loader/validate.js create mode 100644 packages/plugin-loader/validate.test.js create mode 100644 packages/plugin-loader/vitest.config.ts create mode 100644 plugins.json diff --git a/apps/admin-server/.gitignore b/apps/admin-server/.gitignore index 1dface6507..060ba7943d 100644 --- a/apps/admin-server/.gitignore +++ b/apps/admin-server/.gitignore @@ -35,3 +35,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# auto-generated plugin registry +src/lib/generated-plugin-registry.ts diff --git a/apps/admin-server/next.config.js b/apps/admin-server/next.config.js index 126bc40ea2..5e060315b9 100644 --- a/apps/admin-server/next.config.js +++ b/apps/admin-server/next.config.js @@ -1,7 +1,195 @@ +const path = require('path'); +const fs = require('fs'); +const vm = require('vm'); + +/** + * Read plugins.json and return enabled plugin entries. + */ +function getEnabledPlugins() { + let dir = __dirname; + while (dir !== path.dirname(dir)) { + const candidate = path.join(dir, 'plugins.json'); + if (fs.existsSync(candidate)) { + try { + const config = JSON.parse(fs.readFileSync(candidate, 'utf-8')); + return (config.plugins || []).filter((p) => p.enabled); + } catch { + return []; + } + } + dir = path.dirname(dir); + } + return []; +} + +/** + * Load a plugin's manifest by evaluating its main file in a sandbox. + * Only console is exposed — no process/require to limit what plugin + * code can do during manifest evaluation. + */ +function loadManifest(packageName) { + const searchDirs = [ + path.join(__dirname, 'node_modules'), + path.resolve(__dirname, '../../node_modules'), + ]; + + for (const searchDir of searchDirs) { + const pkgJsonPath = path.join(searchDir, packageName, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) continue; + + try { + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); + const mainFile = pkgJson.main || 'index.js'; + const mainPath = path.resolve(path.dirname(pkgJsonPath), mainFile); + if (!fs.existsSync(mainPath)) continue; + + const code = fs.readFileSync(mainPath, 'utf-8'); + const moduleObj = { exports: {} }; + const sandbox = { + module: moduleObj, + exports: moduleObj.exports, + require: () => ({}), + __dirname: path.dirname(mainPath), + __filename: mainPath, + console, + }; + vm.runInNewContext(code, sandbox, { filename: mainPath }); + return moduleObj.exports; + } catch (err) { + console.error( + `[next.config] Failed to load manifest for "${packageName}":`, + err.message + ); + } + } + return null; +} + +/** + * Generate src/lib/generated-plugin-registry.ts with static dynamic imports + * so webpack can bundle plugin components for both server and client. + */ +function generatePluginRegistry(plugins) { + const componentEntries = []; + const metaEntries = []; + const menuItems = []; + + for (const plugin of plugins) { + const packageName = plugin.packageName || plugin.package; + if (!packageName) continue; + + const manifest = loadManifest(packageName); + if (!manifest) continue; + + const pluginName = manifest.name || packageName; + + // Admin pages — collect both component imports and metadata in one pass + if (manifest.admin?.pages) { + for (const page of manifest.admin.pages) { + if (!page.path) continue; + + if (page.componentPath) { + const importPath = `${packageName}/${page.componentPath.replace(/^\.\//, '')}`; + componentEntries.push({ key: page.path, importPath }); + } + + let pageHeader = + pluginName.charAt(0).toUpperCase() + pluginName.slice(1); + if (manifest.admin?.menuItems) { + const menuItem = manifest.admin.menuItems.find( + (m) => m.href === `/${page.path}` + ); + if (menuItem) pageHeader = menuItem.label; + } + + metaEntries.push({ + key: `__meta:${page.path}`, + meta: { pluginName, packageName, pageHeader, pagePath: page.path }, + }); + } + } + + // Widget admin components + if (manifest.widgets) { + for (const [widgetKey, widgetDef] of Object.entries(manifest.widgets)) { + if (!widgetDef.adminComponentPath) continue; + const importPath = `${packageName}/${widgetDef.adminComponentPath.replace(/^\.\//, '')}`; + componentEntries.push({ key: `widget:${widgetKey}`, importPath }); + } + } + + // Menu items + if (manifest.admin?.menuItems) { + menuItems.push(...manifest.admin.menuItems); + } + } + + // Build the generated file content + const dynamicImports = componentEntries + .map( + (e) => + ` '${e.key}': dynamic(() => import('${e.importPath}'), { ssr: false }),` + ) + .join('\n'); + + const metaLines = metaEntries + .map((e) => ` '${e.key}': ${JSON.stringify(e.meta)},`) + .join('\n'); + + const content = `// AUTO-GENERATED by next.config.js — DO NOT EDIT +import dynamic from 'next/dynamic'; +import type { ComponentType } from 'react'; + +type PluginPageMeta = { + pluginName: string; + packageName: string; + pageHeader: string; + pagePath: string; +}; + +const components: Record> = { +${dynamicImports} +}; + +const pageMeta: Record = { +${metaLines} +}; + +export const menuItems = ${JSON.stringify(menuItems, null, 2)} as const; + +export function getPluginComponent(key: string): ComponentType | undefined { + return components[key]; +} + +export function getPluginPageMeta(requestPath: string): PluginPageMeta | undefined { + return pageMeta[\`__meta:\${requestPath}\`]; +} +`; + + const outPath = path.join(__dirname, 'src/lib/generated-plugin-registry.ts'); + const existing = fs.existsSync(outPath) + ? fs.readFileSync(outPath, 'utf-8') + : ''; + + if (existing !== content) { + fs.writeFileSync(outPath, content, 'utf-8'); + console.log( + `[next.config] Generated plugin registry with ${componentEntries.length} component(s)` + ); + } +} + +const enabledPlugins = getEnabledPlugins(); +const pluginPackageNames = enabledPlugins + .map((p) => p.packageName || p.package) + .filter(Boolean); + +generatePluginRegistry(enabledPlugins); + /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, - transpilePackages: ['@openstad-headless/*'], + transpilePackages: ['@openstad-headless/*', ...pluginPackageNames], images: { domains: ['localhost', 'localhost:31470'] }, async rewrites() { return [ @@ -11,6 +199,24 @@ const nextConfig = { }, ]; }, + webpack(config) { + // Resolve @openstad-headless packages from the monorepo packages dir, + // which is mounted at ../../packages in both local dev and Docker. + config.resolve.alias['@openstad-headless/plugin-loader'] = path.resolve( + __dirname, + '../../packages/plugin-loader' + ); + + // Ensure plugin packages can resolve react/next from admin-server's node_modules. + // Plugins are transpiled in-place but don't have their own react installation. + config.resolve.modules = [ + path.resolve(__dirname, 'node_modules'), + 'node_modules', + ...(config.resolve.modules || []), + ]; + + return config; + }, }; module.exports = nextConfig; diff --git a/apps/admin-server/package.json b/apps/admin-server/package.json index 8f0ab1dbd1..f961a56300 100644 --- a/apps/admin-server/package.json +++ b/apps/admin-server/package.json @@ -9,7 +9,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "generate:plugin-registry": "node -e \"require('./next.config.js')\"" }, "dependencies": { "@codemirror/lang-javascript": "^6.2.2", @@ -31,6 +32,7 @@ "@openstad-headless/leaflet-map": "*", "@openstad-headless/likes": "*", "@openstad-headless/multi-project-resource-overview": "*", + "@openstad-headless/plugin-loader": "*", "@openstad-headless/raw-resource": "*", "@openstad-headless/resource-detail": "*", "@openstad-headless/resource-detail-with-map": "*", diff --git a/apps/admin-server/src/components/dialog-widget-create.tsx b/apps/admin-server/src/components/dialog-widget-create.tsx index a0a63bc5bf..7a09b0769a 100644 --- a/apps/admin-server/src/components/dialog-widget-create.tsx +++ b/apps/admin-server/src/components/dialog-widget-create.tsx @@ -1,6 +1,6 @@ import InfoDialog from '@/components/ui/info-hover'; +import { useWidgetDefinitions } from '@/hooks/use-widget-definitions'; import { useWidgetsHook } from '@/hooks/use-widgets'; -import { WidgetDefinitions } from '@/lib/widget-definitions'; import { zodResolver } from '@hookform/resolvers/zod'; import { Plus } from 'lucide-react'; import { useRouter } from 'next/router'; @@ -61,7 +61,8 @@ type FormData = z.infer; export function CreateWidgetDialog({ projectId }: Props) { const [open, setOpen] = useState(false); const router = useRouter(); - const widgetTypes = Object.entries(WidgetDefinitions); + const widgetDefinitions = useWidgetDefinitions(); + const widgetTypes = Object.entries(widgetDefinitions); const { createWidget } = useWidgetsHook(projectId); async function onSubmit(values: FormData) { diff --git a/apps/admin-server/src/components/ui/sidenav.tsx b/apps/admin-server/src/components/ui/sidenav.tsx index 4bda60158a..b7df0f331c 100644 --- a/apps/admin-server/src/components/ui/sidenav.tsx +++ b/apps/admin-server/src/components/ui/sidenav.tsx @@ -6,6 +6,7 @@ import { AlertTriangle, FolderOpen, LogOut, + Plug, Settings, Users, } from 'lucide-react'; @@ -14,6 +15,8 @@ import { useRouter } from 'next/router'; import { useContext, useEffect, useState } from 'react'; import { SessionContext } from '../../auth'; +import hasRole from '../../lib/hasRole'; +import type { Role } from '../../lib/roles'; import { Logo } from './logo'; export function Sidenav({ @@ -26,11 +29,21 @@ export function Sidenav({ const router = useRouter(); const [location, setLocation] = useState(''); const sessionData = useContext(SessionContext); + const [pluginMenuItems, setPluginMenuItems] = useState< + Array<{ label: string; href: string; role?: string }> + >([]); useEffect(() => { setLocation(router.pathname); }, [router]); + useEffect(() => { + fetch('/api/plugin-menu-items') + .then((res) => res.json()) + .then((items) => setPluginMenuItems(items)) + .catch(() => {}); + }, []); + return (