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.tsthat 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:
- plugins/ecommerce/src/ecommerce-plugin.ts
- plugins/ecommerce/plugins/stripe/src/stripe-plugin.ts
- packages/auth/plugins/email-password/src/email-password-plugin.ts
Avoid hiding the real definition under paths like:
src/server/service.tssrc/core/define-plugin.ts
Those files work mechanically, but they make the package harder to read.
Rule 2: Keep index.ts small and boring
Section titled “Rule 2: Keep index.ts small and boring”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.
Runtime plugin shape
Section titled “Runtime plugin shape”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:
nameis the stable runtime plugin id and should match the registry/catalog name.versionis plugin metadata; package publishing still follows the package registry.menuis 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-frameiframe web component. setup(context)may register services throughcontext.addService(...),context.addServices(...), or by returning{ services }.- Child plugins use
extendsand are passed to their parent plugin; they do not get their own Workspace menu area.
Registry and activation
Section titled “Registry and activation”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
Preferred package shapes
Section titled “Preferred package shapes”Core package with a mounted service
Section titled “Core package with a mounted service”src/ auth-service.ts index.ts core/ services/ storage/Use this when the package exposes a runtime-mounted Zelavis service.
Core package with a plugin builder
Section titled “Core package with a plugin builder”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.
Nested plugin package
Section titled “Nested plugin package”src/ stripe-plugin.ts index.tsUse this for focused provider packages and optional extension packages.
Naming guidance
Section titled “Naming guidance”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
What this buys us
Section titled “What this buys us”- 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