Skip to content

Service and Plugin Authoring

Use this guide when creating a new Zelavis core package, runtime-mounted service, or plugin package.

The goal is simple:

  • one obvious file for the real definition
  • one small index.ts that re-exports it
  • no hunting through nested folders to find the important entrypoint

Rule 1: Put the real definition in a named top-level file

Section titled “Rule 1: Put the real definition in a named top-level file”

For core packages, the service definition should live in a named file near the top of src/.

Examples:

For plugin packages, the plugin definition should also live in a named file near the top of the package source.

Examples:

Avoid hiding the real definition under paths like:

  • src/server/service.ts
  • src/core/define-plugin.ts

Those files work mechanically, but they make the package harder to read.

The package index.ts should re-export the named definition file instead of re-implementing anything.

Example service package index:

export * from "./auth-service.js";
export * from "./core/create-auth.js";
export * from "./core/define-auth-plugin.js";
export * from "./core/types.js";

Example plugin package index:

export * from "./stripe-plugin.js";

That gives package authors one obvious place to open first, while keeping imports ergonomic.

Rule 3: Use defineService(...) for mounted runtime services

Section titled “Rule 3: Use defineService(...) for mounted runtime services”

defineService(...) is the low-level builder for runtime-mounted services.

The package-level definition file should wrap that builder with the package’s real semantics:

import { defineService, type ZelavisService } from "@zelavis/server";
import type { AuthApi } from "./core/types.js";
export type AuthServiceDefinition = ZelavisService<AuthApi>;
export function defineAuthService(auth: AuthApi): AuthServiceDefinition {
return defineService({
name: "auth",
basePath: "/auth",
service: auth,
api: {
v1: [
// routes
],
},
});
}

That wrapper is not accidental extra abstraction.

It is the package’s concrete service-definition entrypoint:

  • binds the domain API object
  • sets the service name
  • sets base path and menu metadata
  • defines routes
  • composes nested services when needed

Rule 4: Use definePlugin(...) for top-level and child plugins

Section titled “Rule 4: Use definePlugin(...) for top-level and child plugins”

For runtime, marketplace, and child plugins, use the high-level Zelavis definePlugin(...).

A package-level plugin definition file should look like this:

import { definePlugin } from "zelavis/plugin";
import type { EcommerceApi } from "@zelavis/ecommerce";
export function stripePlugin() {
return definePlugin<EcommerceApi>({
name: "stripe",
extends: {
plugin: "zelavis-ecommerce",
extensionPoint: "payments",
},
setup(api) {
// register provider behavior
},
});
}

The named file should show the real plugin options and setup behavior immediately.

Official marketplace/runtime plugins use the same builder without extends:

import { definePlugin, ZELAVIS_PLUGIN_V1 } from "zelavis";
export const zelavisEcommercePlugin = definePlugin({
name: "zelavis-ecommerce",
contractVersion: ZELAVIS_PLUGIN_V1,
setup(context) {
// register runtime-mounted services and plugin metadata
},
});

Child plugins declare extends metadata. They are installed through the same registry, but the parent plugin decides how to consume them. Zelavis does not run child plugins as independent top-level workspace plugins.

A top-level plugin is a normal ESM module. Export the plugin definition as default, plugin, or directly from the module so Zelavis can resolve it through dynamic import().

import { definePlugin, ZELAVIS_PLUGIN_V1 } from "zelavis";
export default definePlugin({
name: "acme-search",
contractVersion: ZELAVIS_PLUGIN_V1,
version: "0.1.0",
menu: {
title: "Search",
path: "/search",
page: {
id: "dashboard",
title: "Search",
render({ plugin, api }) {
return {
html: `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>${plugin}</title>
</head>
<body>
<main data-api="${api.basePath}">Search plugin</main>
</body>
</html>`,
};
},
},
},
setup(context) {
context.addService({
name: "search",
basePath: "/search",
service: {},
api: {
v1: [
{
id: "search.health",
method: "GET",
path: "/health",
handler: () => ({ status: 200, body: { ok: true } }),
},
],
},
});
},
});

Important details:

  • name is the stable runtime plugin id and should match the registry/catalog name.
  • version is plugin metadata; package publishing still follows the package registry.
  • menu is plain metadata. Plugins never reach into dashboard sidebar internals.
  • menu.page.render(...) returns a full HTML document string or { html, status, headers, contentType }.
  • Plugin pages are mounted in the dashboard through the zelavis-plugin-frame iframe web component.
  • setup(context) may register services through context.addService(...), context.addServices(...), or by returning { services }.
  • Child plugins use extends and are passed to their parent plugin; they do not get their own Workspace menu area.

The runtime registry stores plugin entries separately from Marketplace catalog metadata:

{
name: "acme-search",
specifier: "https://cdn.example.com/acme-search.mjs",
status: "installed",
source: "community",
order: 10,
}

specifier is an ESM module entry point. On Node, the adapter can resolve package names, local files, data: URLs, and remote ESM cached under .zelavis/plugins. On worker/serverless hosts, the active adapter decides how specifiers are resolved or dispatched.

Installing a plugin updates registry state. Activation is adapter-owned:

  • Node-style hosts can recompose the in-process runtime graph.
  • Cloudflare-style hosts can activate through a Worker dispatch boundary.
  • Hosts without activation support can still store registry metadata, but installs may remain pending.

Rule 5: Keep orchestration helpers only when they add real value

Section titled “Rule 5: Keep orchestration helpers only when they add real value”

Some helpers are worth keeping.

Example:

  • authService(...)

That helper still does real orchestration:

  • creates auth if needed
  • applies auth service plugins
  • returns the final mounted service definition

Some helpers are not worth keeping.

Example:

  • old databaseService(...)

That function only forwarded to defineDatabaseService(...), so it added noise without adding behavior.

The rule:

  • keep orchestration helpers when they actually orchestrate
  • remove them when they only rename another function
src/
auth-service.ts
index.ts
core/
services/
storage/

Use this when the package exposes a runtime-mounted Zelavis service.

src/
ecommerce-plugin.ts
index.ts
core/
services/
storage/

Use this when the package’s main extension point is a plugin contract rather than a mounted runtime service.

src/
stripe-plugin.ts
index.ts

Use this for focused provider packages and optional extension packages.

Prefer these names:

  • defineService(...)
  • defineAuthService(...)
  • defineDatabaseService(...)
  • definePlugin(...)
  • stripePlugin(...)
  • paypalPlugin(...)

Avoid names that make the entrypoint harder to spot:

  • createAuthServerService(...)
  • createDatabaseServerService(...)
  • deeply nested service.ts
  • deeply nested define-plugin.ts
  • package authors know where to start reading
  • docs can point to one stable file
  • package entrypoints stay ergonomic
  • service and plugin authoring look like the same family of patterns
  • the repo feels more intentional and less accidental