Skip to content

Plugin and Service Model

Zelavis currently benefits from using two names for two different layers:

  • service for the internal runtime-mounting unit
  • plugin for the public extension concept developers and operators interact with

Use this language split consistently:

  • Services are the internal transport/runtime primitive.
  • Plugins are the external product concept.

That means the dashboard, docs, and developer-facing product language can talk about plugins without forcing the runtime internals to stop using the service contract that already exists.

Sometimes yes, but not always in the same way.

  • Auth provider plugins extend the auth API surface through plugin registration.
  • Ecommerce provider plugins extend the ecommerce API surface through plugin registration.
  • Installable dashboard/runtime features may eventually expose one runtime-mounted service as part of how the plugin is activated.

That lower-level provider layer benefits from explicit extension points.

Current example:

  • zelavis-ecommerce is the top-level marketplace/runtime plugin
  • payment providers such as Stripe or PayPal are child plugins targeting the ecommerce payments extension point

So the safe model is:

A plugin may register or compose one or more internal services, but the plugin itself is the user-facing extension unit.

  • The runtime already has a clear internal service contract.
  • Plugin is the more intuitive outside-world term for installable capabilities.
  • Operators think in terms of installing, enabling, and using plugins, not mounting service graphs.
  • This avoids leaking internal transport language into the UX.

The dashboard should reflect that split:

  • Marketplace is a top-level discovery/install area.
  • Installed plugins do not get first-slide root items.
  • Each installed plugin gets exactly one root entry under Workspace.
  • Each plugin may own unlimited nested sidebar slides inside its own workspace area.
  • Plugin-owned dashboard navigation should be declared through a plain menu object such as menu: { ... }, not by reaching into sidebar internals directly.
  • A plugin menu item may declare page: { id, title, render } when that menu item owns dashboard content.
  • Core services may declare a service-only menu surface such as root, core, workspace, or settings.
  • Plugin menus must not declare a surface; Zelavis always mounts them under Workspace.

This keeps the first slide stable and prevents dashboard sprawl.

Plugin pages are served as full HTML documents and mounted by the dashboard inside the zelavis-plugin-frame iframe web component. That lets plugin authors use plain HTML, React, Vue, web components, or any other browser-side approach without coupling plugin settings or workspaces to the internal dashboard React tree. Zelavis serializes those page definitions to dashboard-safe URLs such as /zelavis/api/v1/dashboard/plugin-pages/:plugin/:page.

A good current TypeScript direction is:

  • keep defineService(...) for the internal runtime contract
  • expose one shared public plugin builder: definePlugin(...)
  • version that contract explicitly with ZELAVIS_PLUGIN_V1
  • let plugin definitions carry declarative dashboard metadata such as menu: { ... } so plugins are not locked to one dashboard implementation detail
  • let child plugins declare extends: { plugin, extensionPoint } instead of inventing package-local plugin builders

That builder should be about developer ergonomics and metadata, not about replacing the internal service contract.

The current DX direction should lean declarative:

import { definePlugin, ZELAVIS_PLUGIN_V1 } from "zelavis";
definePlugin({
name: "zelavis-ecommerce",
contractVersion: ZELAVIS_PLUGIN_V1,
extensionPoints: [
{
name: "payments",
policy: "reviewed",
allowedPlugins: ["stripe", "paypal"],
},
],
menu: {
title: "Ecommerce",
path: "/commerce",
page: {
id: "dashboard",
title: "Commerce",
render() {
return {
html: "<!doctype html><html><body>Commerce</body></html>",
};
},
},
items: [
{
title: "Orders",
path: "/commerce/orders",
page: {
id: "orders",
title: "Orders",
render() {
return "<!doctype html><html><body>Orders</body></html>";
},
},
},
{
title: "More",
items: [
{
title: "Customers",
path: "/commerce/customers",
},
],
},
],
},
setup(plugin) {
// register services, routes, providers, and plugin capabilities
},
});

Child plugins use the same builder but target a parent extension point:

import { definePlugin } from "zelavis/plugin";
import type { EcommerceApi } from "@zelavis/ecommerce";
definePlugin<EcommerceApi>({
name: "stripe",
extends: {
plugin: "zelavis-ecommerce",
extensionPoint: "payments",
},
setup(api) {
api.payments.registerProvider("stripe", provider);
},
});

Installed child plugins are collected for their parent. They do not activate as independent top-level workspace plugins.

Parent plugins own their extension-point policy:

  • open accepts any installed child plugin targeting that extension point
  • reviewed accepts only child plugins listed by the parent
  • private is the default and also accepts only listed child plugins

For ecommerce payments, the current policy is reviewed, with Stripe and PayPal allowed by the official ecommerce package. A future XYZ Payments child plugin would need the parent plugin to add it to that list before activation.

Important point:

  • the menu object is plugin-owned metadata
  • menu.page is the content contract for plugin-owned dashboard pages
  • Zelavis decides how to render that metadata in the current dashboard shell
  • if the dashboard changes later, the plugin contract can stay stable while Zelavis adapts the rendering layer

Plugins should also be representable through one neutral registry shape:

createPluginRegistry([
{
plugin: ecommercePlugin,
status: "installed",
source: "official",
},
]);

That gives Marketplace, installed plugin navigation, and future runtime activation a shared model instead of separate ad hoc lists.

Zelavis should keep plugin loading 100% inside standard JavaScript and ESM:

  • plugin modules should be loaded through dynamic import()
  • plugin modules may export a plugin definition directly, as plugin, or as default
  • loading and registry updates should stay runtime-neutral and avoid Node-only APIs

That means a future runtime can do things like:

const plugin = await loadPlugin("zelavis-ecommerce");
const registry = await loadPluginRegistry([
{ specifier: "zelavis-ecommerce", status: "installed", source: "official" },
]);

Important boundary:

  • load/install can be modeled with ESM imports and registry state
  • remove/uninstall should mean removing the plugin from Zelavis registry/config state
  • JavaScript does not offer a standard way to unload an already-imported ESM module from memory, so Zelavis should not pretend otherwise

The runtime model should also stay explicit:

  • plugin catalog metadata lives in Marketplace catalog entries
  • plugin runtime metadata lives in plugin registry entries
  • plugin install state lives in a registry store
  • plugin activation order is an explicit order number
  • plugin setup runs in registry order and can register additional services through the setup context
  • plugin activation is adapter-owned: Node-style adapters can recompose the local runtime graph, while serverless adapters can attach a worker/function boundary or another live host capability

That means install state and activation are related, but not the same thing:

  • a plugin can exist in the catalog as available
  • a plugin becomes active only when its registry state is installed
  • setup should run only for installed plugins once the host has activated the plugin module

The current runtime direction now reflects that split with:

  • createPluginRegistry(...)
  • definePluginCatalogEntry(...)
  • definePluginCatalog(...)
  • applyPluginRegistryState(...)
  • activatePluginRegistry(...)
  • runtime plugin registry stores for memory, database, key/value, and file storage
  • parent-owned extension point policies for child plugins
  • plugin setup context carrying only standard data such as root path, API paths, platform summary, and collected services
  • a plugin activation controller with declared host capabilities:
    • strategy: runtime-graph, worker-boundary, function-boundary, or custom
    • supportsRuntimeInstall
    • supportsUploadedSpecifiers
    • supportsIsolatedExecution

The dashboard exposes these capabilities so users can tell whether a host can truly apply uploaded or marketplace plugins at runtime. Core does not assume a filesystem, a deploy API, or a provider-specific worker model.

For Node or Bun adapters, the likely production shape is a local plugin cache such as .zelavis/plugins/<plugin>/<version>/index.js, plus a registry entry that points to that ESM entry point. Zelavis can then dynamic-import the specifier and recompose the runtime graph without restarting the process.

For serverless adapters, the correct shape is provider-specific. A Cloudflare adapter cannot mutate the already-running Worker in place. The plausible runtime-install model is a worker boundary: upload plugin code as a user Worker, route through a dispatch namespace or service binding, and pass Zelavis context through standard request/binding contracts. That keeps Zelavis core portable while letting the Cloudflare adapter implement the Cloudflare-specific deployment and isolation mechanics.

That platform summary should stay intentionally small:

  • presets: platform preset names such as node or cloudflare
  • resources: booleans for resource availability such as key/value or file storage
  • metadata: plain serializable platform hints

That gives plugins useful context without leaking host-specific APIs into the plugin contract.

For product language:

  • prefer Plugin over Service

For runtime internals:

  • keep Service

For possible alternatives:

  • Apps feels more end-user/productized, but less precise for provider-style extensions
  • Extensions is good, but broader and slightly less concrete than Plugin
  • Plugins is the clearest choice for the current Zelavis direction