# Wanaku Plugin Development Guide Plugins extend the Wanaku admin UI by adding pages, navigation entries, and backend service integration without modifying the core application. This guide shows you how to build one. This guide is for plugin developers and platform operators. It explains the runtime contract, manifest, host API, backend mappings, local tests, and cleanup rules. ## Contents - [Overview](#overview) - [Quick Start](#quick-start) - [Manifest Reference](#manifest-reference-pluginjson) - [Plugin Lifecycle](#plugin-lifecycle) - [PluginHost API Reference](#pluginhost-api-reference) - [Backend Configuration](#backend-configuration) - [Complete Example](#complete-example) - [Using Carbon Design System](#using-carbon-design-system) - [Testing Locally](#testing-locally) - [Best Practices](#best-practices) ## Overview Plugins are ES modules loaded at runtime. A plugin can: - Add navigation entries to the sidebar - Register new pages under custom routes - Call backend services through an authenticated proxy - Show notifications to users The supported plugin contract is the host API. Plugins must keep DOM changes inside their page container. They must not read internal host state or call service URLs outside the configured proxy. Plugins currently run as same-origin JavaScript. Wanaku does not sandbox them or enforce manifest permissions. Install only trusted plugins. The host API contract does not expose React internals or Carbon component instances. ## Quick Start Five steps to see a plugin running: 1. **Create a plugin directory** under your plugins path (e.g., `/data/plugins/my-plugin/`) 2. **Create `plugin.json`** with id, name, version, and entrypoint 3. **Create `plugin.js`** with `activate` and `deactivate` exports 4. **Start the server with `--plugins-path /data/plugins`** 5. **Open the admin UI** — your plugin page appears in the navigation The following example is the smallest working plugin: **plugin.json:** ```json { "id": "my-plugin", "name": "My Plugin", "version": "1.0.0", "entrypoint": "plugin.js" } ``` **plugin.js:** ```javascript export async function activate(host) { host.navigation.add({ id: "my-page", label: "My Page", route: "/my-page" }); host.pages.register({ route: "/my-page", mount(container) { container.innerHTML = "

Hello from plugin

"; } }); } export function deactivate() {} ``` Restart the server. The "My Page" link appears in the sidebar. ## Plugin Structure A plugin lives in its own directory under the path specified by `--plugins-path`. The typical layout: ``` my-plugin/ ├── plugin.json # Manifest (required) ├── plugin.js # Entry point (required) ├── plugin.css # Optional styles └── assets/ # Optional images, fonts, etc. └── logo.svg ``` The manifest (`plugin.json`) tells the host what files to load. The entry point exports `activate()` and `deactivate()` functions. Styles and assets are optional — the host loads stylesheets declared in the manifest and serves assets at `/plugins/{pluginId}/{path}`. ## Manifest Reference (plugin.json) The manifest is a JSON file with these fields: | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | Unique plugin identifier (kebab-case, no spaces) | | `name` | string | yes | Human-readable name shown in error messages | | `version` | string | yes | Semver version (e.g., "1.2.3") | | `entrypoint` | string | yes | Path to the JavaScript module (relative to plugin dir) | | `styles` | string[] | no | CSS files to load (relative to plugin dir) | | `requires.hostApi` | string | no | Required host API version (semver range, e.g., ">=1.0 <2.0") | | `requires.services` | object[] | no | Backend services this plugin needs (see Backend Configuration) | | `permissions` | string[] | no | Declared capabilities (not enforced yet, reserved for future use) | Example with all fields: ```json { "id": "customer-management", "name": "Customer Management", "version": "1.4.0", "entrypoint": "plugin.js", "styles": ["plugin.css"], "requires": { "hostApi": ">=1.0 <2.0", "services": [ {"id": "customer-api", "version": "1.0"} ] }, "permissions": [ "navigation", "pages", "notifications" ] } ``` The manifest must be valid JSON. Missing `id`, `name`, `version`, or `entrypoint` will prevent the plugin from loading. ## Plugin Lifecycle Plugins have two lifecycle hooks: ### activate(host) Called when the plugin loads. The `host` parameter is the `PluginHost` object — your gateway to all platform capabilities. Use this function to register navigation entries, pages, and set up any runtime state. Returns `void` or `Promise`. Errors thrown here prevent the plugin from loading. The host logs the error but continues loading other plugins. ### deactivate() Called when the plugin is unloaded (currently only on page refresh). Clean up timers, event listeners, subscriptions, or other resources here. Returns `void` or `Promise`. If you omit this export, the host assumes that the plugin has no resources to release. **Critical:** A plugin that does not release resources in `deactivate()` can leak memory or leave timers active. Dispose each resource that you register in `activate()`. ## PluginHost API Reference The `PluginHost` object has five capabilities. All registration methods return a `Disposable` — an object with a `dispose()` method you can call to remove the registration early (before `deactivate()` is called). ### host.version A string identifying the host API version (currently `"1.0"`). Use this to log compatibility info or implement fallback behavior for different host versions. ```javascript console.log(`Running on host API ${host.version}`); ``` ### host.navigation.add(entry) Adds a navigation entry to the sidebar. The `order` value controls its position. Lower values appear first. If multiple plugins use the same value, the host uses registration order. **Parameters:** | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | Unique identifier for this nav entry | | `label` | string | yes | Text shown in the sidebar | | `route` | string | yes | Route to navigate to (must start with `/`) | | `icon` | string | no | Icon name (reserved for future use) | | `section` | string | no | Grouping hint (reserved for future use) | | `order` | number | no | Sort order (default: 0) | **Returns:** `Disposable` **Example:** ```javascript const navDisposable = host.navigation.add({ id: "customers", label: "Customers", route: "/customers", order: 100 }); // Later, remove the nav entry: // navDisposable.dispose(); ``` ### host.pages.register(page) Registers a page that renders when the route matches. The host calls your `mount(container)` function with an `HTMLElement` — you own everything inside that element. Render your UI however you like: vanilla DOM manipulation, a framework, whatever. **Parameters:** | Field | Type | Description | |---|---|---| | `route` | string | Route pattern (must start with `/`) | | `mount` | function | `(container: HTMLElement) => void \| Disposable` | The `mount` function receives a container element. You can return: - `undefined` — the host assumes that the plugin releases resources in `deactivate()` - A `Disposable` object — the host calls `dispose()` when the route unmounts **Returns:** `Disposable` **Example:** ```javascript host.pages.register({ route: "/customers", mount(container) { container.innerHTML = `

Customers

Customer list here...

`; return { dispose() { container.innerHTML = ""; } }; } }); ``` If you use a framework such as React, call its mount function inside `mount()`. Return a disposable that unmounts the page: ```javascript import { createRoot } from "react-dom/client"; import { CustomerPage } from "./CustomerPage.jsx"; host.pages.register({ route: "/customers", mount(container) { const root = createRoot(container); root.render(); return { dispose() { root.unmount(); } }; } }); ``` ### host.http.get / post / put / delete Makes HTTP requests to backend services. These methods route through the Rust backend at `/api/plugins/{pluginId}/{serviceId}/{path}`. The backend resolves the logical service ID to a physical backend URL (configured in `wanaku.yaml` or the management API). **Why use this instead of `fetch()`?** - Authentication headers are injected automatically - CORS is handled (single origin) - The backend URL is configuration, not hardcoded in your plugin - Errors trigger redirect to login when auth expires **Signatures:** ```typescript host.http.get(service: string, path: string): Promise host.http.post(service: string, path: string, body?: unknown): Promise host.http.put(service: string, path: string, body?: unknown): Promise host.http.delete(service: string, path: string): Promise ``` **Parameters:** - `service` — logical service identifier (e.g., `"customer-api"`, `"chat"`) - `path` — the path to append (must start with `/`) - `body` — JSON-serializable object (for POST/PUT) **Returns:** `Promise` — the parsed JSON response **Example:** ```javascript try { const customers = await host.http.get("customer-api", "/customers"); console.log("Loaded", customers.length, "customers"); } catch (err) { console.error("Failed to load customers:", err); } await host.http.post("customer-api", "/customers", { name: "Acme Corp", email: "contact@acme.example" }); ``` The backend routes this as: ``` GET /api/plugins/my-plugin/customer-api/customers → resolves "customer-api" service → proxies to http://customer-service:8080/customers ``` You configure the mapping in the backend (see Backend Configuration below). ### host.notifications.show(message) Displays a toast notification at the top-right of the screen. Notifications auto-dismiss after a few seconds. **Parameters:** | Field | Type | Required | Description | |---|---|---|---| | `title` | string | no | Bold heading | | `text` | string | yes | Notification body | | `kind` | string | no | `"info"`, `"success"`, `"warning"`, or `"error"` (default: `"info"`) | **Example:** ```javascript host.notifications.show({ title: "Customer Created", text: "Acme Corp has been added to the system.", kind: "success" }); host.notifications.show({ text: "Failed to connect to backend.", kind: "error" }); ``` ## Backend Configuration To call backend services via `host.http`, you need to map logical service IDs to physical URLs. Two options: ### Option 1: Environment Variable + YAML Config Start the server with `--plugins-path` and create a `wanaku.yaml` file with a `plugins` section: ```yaml plugins: - id: my-plugin services: customer-api: target: http://customer-service:8080 chat: target: http://localhost:11434 ``` The backend reads this on startup and registers the mappings. ### Option 2: Management API You can dynamically register service mappings via the management API (not yet implemented — this is a placeholder for future capability). **Key point:** The plugin does not know the backend URL. The platform resolves it. The same plugin can run in development, staging, and production without a code change. ## Complete Example The following example shows the `hello-world` plugin from the examples directory: **plugin.json:** ```json { "id": "hello-world", "name": "Hello World", "version": "1.0.0", "entrypoint": "./plugin.js", "requires": { "hostApi": ">=1.0 <2.0" }, "permissions": [ "navigation", "pages" ] } ``` **plugin.js:** ```javascript // The activate function is called when the plugin loads. // It receives the PluginHost object. export async function activate(host) { // Register a navigation entry in the sidebar. // This appears as "Hello Plugin" in the nav. host.navigation.add({ id: "hello", label: "Hello Plugin", route: "/hello", order: 100, }); // Register the page that renders at /hello. // The mount function receives an HTMLElement container. host.pages.register({ route: "/hello", mount(container) { // Render whatever you want inside the container. // This example uses plain HTML. container.innerHTML = `

Hello from Plugin!

This page was contributed by the hello-world plugin.

Host API version: ${host.version}

`; // Return a disposable to clean up when the route unmounts. return { dispose() { container.innerHTML = ""; }, }; }, }); } // The deactivate function is called when the plugin unloads. // Clean up timers, listeners, subscriptions, etc. here. export function deactivate() { // Nothing to clean up in this example. } ``` To run it: ```bash cargo run -- --plugins-path /path/to/examples ``` Open `http://localhost:8080` (or your admin UI URL). Click "Hello Plugin" in the sidebar. ## Using Carbon Design System The admin UI uses IBM Carbon Design System. Use Carbon components in first-party plugins for visual consistency. The plugin API does not require or enforce Carbon. ### Bundling Approach Plugins are ES modules that load at runtime. Bundle npm dependencies with a tool such as esbuild, Rollup, or Vite. **Example with esbuild:** ```bash npm install @carbon/web-components ``` **src/plugin.js:** ```javascript import "@carbon/web-components/es/components/button/index.js"; export async function activate(host) { host.pages.register({ route: "/demo", mount(container) { container.innerHTML = ` Click Me `; } }); } export function deactivate() {} ``` **Build:** ```bash esbuild src/plugin.js --bundle --format=esm --outfile=plugin.js ``` Now `plugin.js` includes the Carbon button component. The browser can load it as a single ES module. **Caveat:** Carbon React components require React and ReactDOM. Include these dependencies in the plugin bundle. This increases the plugin size. For React-based plugins, use a build tool such as Vite to produce optimized ES modules with code splitting. ## Testing Locally Step-by-step process to test a plugin: 1. Create the plugin directory: ```bash mkdir -p /tmp/plugins/my-plugin ``` 2. Enter the plugin directory: ```bash cd /tmp/plugins/my-plugin ``` 3. Write the manifest: ```bash cat > plugin.json < plugin.js < console.log("ping"), 1000); } export function deactivate() { // Timer keeps running — memory leak } ``` **Good:** ```javascript let timer; export async function activate(host) { timer = setInterval(() => console.log("ping"), 1000); } export function deactivate() { clearInterval(timer); } ``` ### Use host.http Instead of Raw fetch Always use `host.http` for backend calls. It handles authentication, CORS, and service resolution. **Bad:** ```javascript await fetch("http://customer-service:8080/customers"); // Hard-coded URL, breaks in different environments // No auth headers, fails if backend requires authentication // CORS issues if backend is on a different origin ``` **Good:** ```javascript await host.http.get("customer-api", "/customers"); // Resolves to the right backend for this environment // Automatically includes auth headers // Routes through the host (same origin, no CORS issues) ``` ### Prefix CSS Classes with Plugin ID The host and other plugins share the same document. Avoid CSS class name collisions by prefixing your classes. **Bad:** ```css .button { background: red; } ``` Now every button on the page is red. **Good:** ```css .my-plugin-button { background: red; } ``` Only your plugin's buttons are red. Or use CSS modules if your bundler supports them. ### Keep DOM Access Inside the Container The `mount(container)` function gives the plugin a container element. Change only elements inside this container. **Bad:** ```javascript mount(container) { document.body.appendChild(createModal()); // Now the modal outlives the route // The host cannot clean it up } ``` **Good:** ```javascript mount(container) { const modal = createModal(); container.appendChild(modal); return { dispose() { modal.remove(); } }; } ``` ### Use the Disposable Pattern for All Registrations Each `host.navigation.add()` and `host.pages.register()` call returns a `Disposable`. Store it. Call `dispose()` when the registration is no longer necessary. **Why?** If you need to unregister something before the plugin unloads (e.g., a dynamic nav entry based on user permissions), you can: ```javascript const disposable = host.navigation.add({ id: "admin", label: "Admin", route: "/admin" }); // Later, when the user logs out: disposable.dispose(); ``` The navigation entry disappears immediately. The plugin does not have to unload first. --- **That's the guide.** You now know how to build, configure, and test a plugin for Wanaku. Start with the Quick Start example, experiment with the host APIs, and check the `examples/hello-plugin/` directory for a working reference implementation.