diff --git a/ui/src/extensions.config.ts b/ui/src/extensions.config.ts index efa338c7..91852739 100644 --- a/ui/src/extensions.config.ts +++ b/ui/src/extensions.config.ts @@ -9,6 +9,7 @@ */ import { extensionRegistry } from './extensions/registry'; +import buildExtensions from 'virtual:zrok-extensions'; // ============================================================== // Import your extensions here (top-level imports) @@ -39,12 +40,8 @@ export function loadExtensions(): void { // Example: Register demo extension // extensionRegistry.register(demoExtension); - // Example: Conditional loading based on environment variable - // if (import.meta.env.VITE_ENABLE_BILLING === 'true') { - // import('@acme/zrok-billing-extension').then(({ default: ext }) => { - // extensionRegistry.register(ext); - // }); - // } + // Extensions selected at build time via ZROK_UI_EXTENSIONS (see vite-plugin-extensions.ts) + buildExtensions.forEach(ext => extensionRegistry.register(ext)); // ============================================================== // End of extension registration diff --git a/ui/src/extensions/virtual.d.ts b/ui/src/extensions/virtual.d.ts new file mode 100644 index 00000000..a7250962 --- /dev/null +++ b/ui/src/extensions/virtual.d.ts @@ -0,0 +1,8 @@ +/** + * Type declaration for the build-time generated extension list + * (see vite-plugin-extensions.ts). + */ +declare module 'virtual:zrok-extensions' { + const extensions: import('./types').ExtensionManifest[]; + export default extensions; +} diff --git a/ui/vite-plugin-extensions.ts b/ui/vite-plugin-extensions.ts new file mode 100644 index 00000000..b0500d9c --- /dev/null +++ b/ui/vite-plugin-extensions.ts @@ -0,0 +1,84 @@ +/** + * Vite Plugin: Extensions + * + * Selects which extension packages are bundled into the UI at build time. The + * list comes from the ZROK_UI_EXTENSIONS environment variable (comma-separated + * npm package names); unset or empty means no extensions, which is the default + * build. + * + * The selected packages are exposed to the app as the `virtual:zrok-extensions` + * module, whose default export is the array of extension manifests. They are + * registered in src/extensions.config.ts. + * + * Usage: + * + * ```bash + * npm install --no-save @myorg/zrok-my-extension@1.0.0 + * ZROK_UI_EXTENSIONS=@myorg/zrok-my-extension npm run build + * ``` + */ + +import type { Plugin } from 'vite'; +import type { ExtensionManifestWithScripts } from './vite-plugin-extension-scripts'; + +const VIRTUAL_MODULE_ID = 'virtual:zrok-extensions'; +const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID; + +/** + * Split a comma-separated list, dropping blanks. + */ +export function parseList(value: string | undefined): string[] { + return (value ?? '') + .split(',') + .map(s => s.trim()) + .filter(Boolean); +} + +/** + * Import each extension package's manifest, so build-time features (such as + * script injection) can read it. Fails the build if a package is missing. + */ +export async function loadExtensionManifests( + packages: string[] +): Promise { + return Promise.all( + packages.map(async pkg => { + let manifest: ExtensionManifestWithScripts | undefined; + try { + manifest = (await import(pkg)).default; + } catch (err) { + throw new Error( + `unable to load extension '${pkg}' listed in ZROK_UI_EXTENSIONS (is it installed?): ${err}` + ); + } + if (!manifest || typeof manifest.id !== 'string') { + throw new Error(`extension '${pkg}' has no default export with an 'id'`); + } + return manifest; + }) + ); +} + +/** + * Vite plugin that generates the `virtual:zrok-extensions` module. + */ +export function extensionsPlugin(packages: string[]): Plugin { + return { + name: 'vite-plugin-zrok-extensions', + + resolveId(id: string) { + if (id === VIRTUAL_MODULE_ID) { + return RESOLVED_VIRTUAL_MODULE_ID; + } + }, + + load(id: string) { + if (id !== RESOLVED_VIRTUAL_MODULE_ID) { + return; + } + const imports = packages.map((pkg, i) => `import ext${i} from ${JSON.stringify(pkg)};`); + const names = packages.map((_, i) => `ext${i}`); + return `${imports.join('\n')}\nexport default [${names.join(', ')}];\n`; + }, + }; +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 2cc83c70..ca200386 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,72 +1,91 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv, type ProxyOptions } from 'vite' import react from '@vitejs/plugin-react' import { extensionScriptsPlugin } from './vite-plugin-extension-scripts' +import { extensionsPlugin, loadExtensionManifests, parseList } from './vite-plugin-extensions' // ============================================================== -// Extension Script Injection Configuration -// ============================================================== -// -// To inject scripts from extensions at build time, import your -// extension manifests and pass them to extensionScriptsPlugin: -// -// import billingExtension from '@acme/zrok-billing-extension'; -// import demoExtension from './examples/demo-extension/src'; -// -// Then add to plugins array: -// extensionScriptsPlugin({ -// extensions: [billingExtension, demoExtension], -// verbose: true, // Enable to see injection logs during build -// }), +// Build/dev settings, read from the environment or a git-ignored +// ui/.env.local file (all optional): // +// ZROK_UI_EXTENSIONS comma-separated extension packages to bundle +// (must be installed in node_modules) +// ZROK_UI_API_TARGET dev server target for /api/v2 +// (default http://localhost:18080) +// ZROK_UI_DEV_PROXY extra dev proxy rules, comma-separated +// path=target pairs, e.g. +// /my-backend=http://localhost:8106 +// ZROK_UI_ALLOWED_HOSTS extra dev server allowed hosts, comma-separated // ============================================================== +function devProxy(env: Record): Record { + const proxy: Record = { + '/api/v2': { + target: env.ZROK_UI_API_TARGET || 'http://localhost:18080', + changeOrigin: true, + }, + }; + for (const rule of parseList(env.ZROK_UI_DEV_PROXY)) { + // split at the first '=' only, so targets may contain '=' + const eq = rule.indexOf('='); + const path = rule.slice(0, eq).trim(); + const target = rule.slice(eq + 1).trim(); + if (eq < 0 || !path || !target) { + throw new Error(`invalid ZROK_UI_DEV_PROXY rule '${rule}', expected path=target`); + } + proxy[path] = { target, changeOrigin: true }; + } + return proxy; +} + // https://vite.dev/config/ -export default defineConfig({ - plugins: [ - react(), - // Uncomment and configure to enable build-time script injection: - // extensionScriptsPlugin({ - // extensions: [], - // verbose: true, - // }), - ], - build: { - rollupOptions: { - output: { - manualChunks(id) { - if (id.includes("node_modules")) { - if (id.includes("react-dom") || id.includes("react-router") || id.includes("@emotion") || id.match(/\/react\//)) { - return "vendor-react"; - } - if (id.includes("@mui/x-charts") || id.includes("@mui/x-charts-vendor")) { - return "vendor-mui-charts"; - } - if (id.includes("@mui/x-date-pickers")) { - return "vendor-mui-pickers"; - } - if (id.includes("@mui")) { - return "vendor-mui"; - } - if (id.includes("@xyflow") || id.includes("d3-hierarchy")) { - return "vendor-visualizer"; - } - if (id.includes("material-react-table")) { - return "vendor-table"; +export default defineConfig(async ({ mode }) => { + const env = loadEnv(mode, '.', 'ZROK_UI_'); + const extensionPackages = parseList(env.ZROK_UI_EXTENSIONS); + const extensions = await loadExtensionManifests(extensionPackages); + + return { + plugins: [ + react(), + extensionsPlugin(extensionPackages), + extensionScriptsPlugin({ + extensions, + verbose: extensions.length > 0, + }), + ], + build: { + rollupOptions: { + output: { + manualChunks(id: string) { + if (id.includes("node_modules")) { + if (id.includes("react-dom") || id.includes("react-router") || id.includes("@emotion") || id.match(/\/react\//)) { + return "vendor-react"; + } + if (id.includes("@mui/x-charts") || id.includes("@mui/x-charts-vendor")) { + return "vendor-mui-charts"; + } + if (id.includes("@mui/x-date-pickers")) { + return "vendor-mui-pickers"; + } + if (id.includes("@mui")) { + return "vendor-mui"; + } + if (id.includes("@xyflow") || id.includes("d3-hierarchy")) { + return "vendor-visualizer"; + } + if (id.includes("material-react-table")) { + return "vendor-table"; + } } - } + }, }, }, }, - }, - server: { - proxy: { - '/api/v2': { - target: 'http://localhost:18080', - changeOrigin: true, - } + server: { + proxy: devProxy(env), + allowedHosts: [ + ".share.zrok.io", + ...parseList(env.ZROK_UI_ALLOWED_HOSTS), + ], }, - allowedHosts: [ - ".share.zrok.io" - ] } })