Service Authoring
Use this guide when creating a new Zelavis core package, runtime-mounted service, or service 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:
packages/zelavis/src/app/identity/identity-service.tspackages/zelavis/src/db/database-service.ts
For service packages, the service definition should also live in a named file near the top of the package source.
Examples:
zelavis-services/ecommerce/src/ecommerce-service.tszelavis-services/ecommerce/plugins/stripe/src/stripe-service.tspackages/zelavis/src/app/identity/providers/password.ts
Avoid hiding the real definition under paths like:
src/server/service.tssrc/core/define-service.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-service.js";export * from "./core/types.js";Example service package index:
export * from "./stripe-service.js";That gives package authors one obvious place to open first, while keeping imports ergonomic.
Rule 3: Use package.json manifests and ZelavisRuntimeService
Section titled “Rule 3: Use package.json manifests and ZelavisRuntimeService”Zelavis uses package.json as the manifest and configuration surface for plugins and services ("zelavis": { "kind": "plugin" }, "type": "module", "exports").
For internal or runtime-mounted services, definition files return frozen ZelavisRuntimeService objects:
import type { ZelavisRuntimeService } from "zelavis";import type { AuthApi } from "./core/types.js";
export function defineAuthService(auth: AuthApi): ZelavisRuntimeService { return Object.freeze({ name: "@zelavis/auth", kind: "plugin", basePath: "/auth", api: { v1: [ { id: "auth.accounts.list", method: "GET", path: "/accounts", handler: async () => ({ status: 200, body: await auth.accounts.list(), }), }, ], }, });}That factory 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
- contributes explicit capabilities when needed
Rule 4: Use the official Zelavis SDK (zelavis/sdk) for plugins
Section titled “Rule 4: Use the official Zelavis SDK (zelavis/sdk) for plugins”For plugins that register menus, API routes, commands, or event listeners, use the official Zelavis SDK (import { zelavis } from "zelavis/sdk"):
import { zelavis } from "zelavis/sdk";
zelavis.plugins.ui.menus.create({ title: "Reports", path: "/reports", page: { id: "dashboard", file: "dashboard.html", },});
zelavis.routes.create({ id: "reports.data", method: "GET", path: "/data", handler: async () => ({ status: 200, body: { generatedAt: Date.now() } }),});Provider plugins declare a capability in package.json ("zelavis": { "kind": "plugin" }) and export their runtime service:
import type { EcommerceApi } from "@zelavis/ecommerce";import type { ZelavisRuntimeService } from "zelavis";
export function stripeService(): ZelavisRuntimeService { return Object.freeze({ name: "@zelavis/ecommerce-stripe", kind: "plugin", capabilities: ["@zelavis/ecommerce:payments"], service: { name: "stripe", register(api: EcommerceApi) { // register provider behavior through the public Ecommerce API }, }, });}Provider plugins declare a capability owned by the service they extend — zelavis/identity:credentials, @zelavis/ecommerce:payments — and expose that service’s public registration contract as their service value. The owner discovers them by capability.
The owner is named because a bare domain such as provider:payments says what a plugin implements and never whose contract it satisfies, so two commerce plugins scanning for it would each collect the other’s gateways. There is still no parent/child graph: discovery is a flat scan, naming an owner asks to be considered by it and grants nothing, and the owner validates every provider against its own contract.
Rule 5: Make capabilities endpoint-backed
Section titled “Rule 5: Make capabilities endpoint-backed”Anything a service lets users do from the dashboard should also be exposed as a service capability and API endpoint.
Service dashboard pages can present forms, charts, setup flows, and actions, but the authoritative behavior belongs in the service/runtime layer. This lets CLI commands, AI agents, scripts, plugins, and external admin clients use the same operation without depending on the dashboard.
Good shape:
setup(context)registers the capability and endpointmenu.page.fileselects a bundled HTML entry file that calls the endpoint- the operation can be tested without rendering the dashboard
Avoid:
- dashboard-only mutations
- framework-specific server actions as the only execution path
- hiding service behavior inside page rendering code
Runtime service shape
Section titled “Runtime service shape”A top-level service is a normal ESM module. Export the service definition as default, service, or directly from the module so Zelavis can resolve it through dynamic import().
import { zelavis } from "zelavis/sdk";
zelavis.plugins.ui.menus.create({ title: "Search", path: "/search", page: { id: "dashboard", title: "Search", file: "dashboard.html", },});
zelavis.routes.create({ id: "search.health", method: "GET", path: "/health", handler: () => ({ status: 200, body: { ok: true } }),});The installed service bundle should include dashboard.html. That file can be
plain HTML or boot a client app:
<main id="app"></main><script type="module" src="./dashboard.js"></script>dashboard.js is served from the same service bundle as the HTML file. If it
contains a client router, tabs, or a local menu, that navigation belongs to the
embedded page itself.
Important details:
nameis the stable runtime service id and should match the registry/catalog name.versionis service metadata; package publishing still follows the package registry.menuis plain metadata. Services never reach into dashboard sidebar internals.- Service pages are mounted in the dashboard through the
zelavis-service-frameiframe web component. menu.pathis the dashboard URL owned by React Router.menu.page.fileis the browser-extension-style HTML entry file for iframe-backed service UI, such asdashboard.html,settings.html, oroptions.html.- Zelavis serves
page.fileand sibling assets from the service bundle. A page can reference bundled files with normal relative URLs such as<script type="module" src="./settings.js"></script>. - If the HTML file boots a SPA, that SPA owns its internal router, tabs, and menu. Zelavis sidebar items select concrete HTML entry files; they do not deep-link into plugin-private SPA routes.
- Core services whose content already ships with
@zelavis/uishould usemenu.pathwithoutmenu.page, so the local route renders directly instead of mounting an iframe. - Menu items can use
fixed: truefor slide-local pinned actions such as “Add Function”. UsefixedOrderwhen a slide has multiple fixed actions. - Nested slides can set
fixedActionScopeto control whether parent fixed actions carry forward:localuses only the opened slide’s fixed actions,inheritcombines parent and local actions,replaceuses local actions as an explicit boundary, andclearhides fixed actions until a deeper slide reintroduces them. - Menu items can use
dynamicItemswhen a slide section is backed by runtime state. The dynamic endpoint returns{ "items": [...] }, and each item uses the same metadata shape as static service menu items. - Menu items can include
searchwhen navigation depends on URL state, for example{ databaseTable: "products" }. Prefer returning that from the service-owned menu endpoint over hardcoding service-specific lists in the dashboard. - Statically trusted system services can declare a dashboard
surface.platformrenders in the global/zelavismanagement shell;root,core,extensions, andsettingsrender in project dashboards. Runtime-installed services are forced under Extensions. setup(context)may mount endpoint groups owned by the package throughcontext.addEndpointGroup(...),context.addEndpointGroups(...), or by returning{ endpointGroups }. A package cannot create other services.- Provider plugins are ordinary installed services. They declare a namespaced provider capability and expose that domain’s explicit registration contract; the owning auth, payments, or future domain discovers and registers them.
Example fixed action plus dynamic section:
menu: { title: "Workloads", items: [ { title: "Functions", fixedActionScope: "inherit", items: [ { title: "Add Function", path: "/workloads/new", fixed: true, fixedOrder: 1, }, ], dynamicItems: { path: "/workloads/menu/functions", emptyTitle: "No functions yet", }, }, ],}Example clearing a parent action and reintroducing a different action deeper down:
menu: { title: "Tools", items: [ { title: "Create Tool", path: "/tools/new", fixed: true, fixedOrder: 1, }, { title: "Advanced", fixedActionScope: "inherit", items: [ { title: "Danger Zone", fixedActionScope: "clear", items: [ { title: "Recovery", fixedActionScope: "replace", items: [ { title: "Create Recovery Point", path: "/tools/recovery/new", fixed: true, fixedOrder: 1, }, ], }, ], }, ], }, ],}In that example, Advanced keeps Create Tool, Danger Zone shows no fixed
actions, and Recovery reintroduces Create Recovery Point.
The repo includes examples/plugin-basic as a minimal uploadable service. Build its upload package with pnpm --filter @zelavis/example-plugin-basic package, then select examples/plugin-basic/dist/example-basic.zip in a project Marketplace flow at /zelavis/projects/:projectId/marketplace. The service module defines its own name, version, menu, pages, and services, so the dashboard does not ask for a separate service name. The ZIP includes a standard package.json, whose zelavis field (kind, namespace) and exports field point at the ESM module the host adapter should import; the older zelavis.service.json sidecar with an entry field is retired and no longer read.
Registry and activation
Section titled “Registry and activation”The runtime registry stores service 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/services. Bun follows the same local-runtime shape.
Installing a service updates registry state. Activation is adapter-owned:
- An installed Platform activates a plugin or frontend by handing traffic to a fresh engine of the same release; requests wait meanwhile. A Project recipe is used by the next Project created or upgraded, with no reload.
- 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 services
- 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 service builder
Section titled “Core package with a service builder”src/ ecommerce-service.ts index.ts core/ services/ storage/Use this when the package’s main adapter surface is a service contract rather than a mounted runtime service.
Nested service package
Section titled “Nested service package”src/ stripe-service.ts index.tsUse this for focused provider packages and optional extension packages.
Naming guidance
Section titled “Naming guidance”Prefer these names:
defineAuthService(...)defineDatabaseService(...)stripeService(...)paypalService(...)
Avoid names that make the entrypoint harder to spot:
createAuthServerService(...)- database server service aliases that only rename the real entrypoint
- deeply nested
service.ts - deeply nested
define-service.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 service authoring look like the same family of patterns
- the repo feels more intentional and less accidental