DevPluginsREST API
Migrate to typed APIs
Move a VitNode plugin from the removed string-based APIs to typed builders, permission references, schema-defined tasks and events and typed cache entries.
VitNode's plugin APIs now take definitions instead of strings. A queue task, an event, a cache entry and a staff permission are each declared once with a schema or a manifest, and every call site imports that declaration. The string forms are removed, so a plugin written against them stops compiling. This guide maps each removed API to its replacement, in the order that keeps the plugin building, and says what happens to data that already exists.
Every code block marked Removed API shows code that no longer compiles. Everything else is the current API.
Before you begin
- Update
@vitnode/corein the app and in every plugin to the same version. - Commit or stash your work. The steps below touch most API files of a plugin.
Export all five builders
src/api/builders.ts binds the builders to your plugin. Plugins generated before this change export only buildModule and buildRoute. Add the other three:
import { bindApiPlugin } from "@vitnode/core/api/lib/bind-api-plugin";
import { manifest } from "@/manifest.gen";
const apiBuilders = bindApiPlugin(manifest);
export const buildModule = apiBuilders.buildModule;
export const buildRoute = apiBuilders.buildRoute;
export const buildQueueTask = apiBuilders.buildQueueTask;
export const defineCacheEntry = apiBuilders.defineCacheEntry;
export const defineEvent = apiBuilders.defineEvent; create-vitnode-app --plugin and vitnode generate write this file with all five.
Move staff permissions to references
| Removed | Use |
|---|---|
permissionStaff: { admin: {...}, moderator: {...} } in buildApiPlugin | defineStaffPermissions(manifest, { admin, moderator }) in src/staff-permissions.ts, then permissionStaff: staffPermissions |
defineStaffPermissions({ admin, moderator }) | defineStaffPermissions(manifest, { admin, moderator }) |
permissionStaff: staffPermissions.config | permissionStaff: staffPermissions |
adminStaffPermission: { module: "notes", permission: "can_create" } | adminStaffPermission: staffPermissions.admin.notes.canCreate |
moderatorStaffPermission: { module: "notes", permission: "can_delete" } | moderatorStaffPermission: staffPermissions.moderator.notes.canDelete |
AdminCP nav item or dashboard widget permission: { module, permission }, type AdminNavPermission | permission: staffPermissions.admin.notes.canCreate, type AdminStaffPermissionRef |
A content type's permission as { module: contentType.permissionModule, permission: "can_edit" } | contentAdminStaffPermission(manifest.id, contentType, "can_edit") from @vitnode/core/content |
A container collection's permission as { module: "newsroom_desks", permission: "can_edit" } | contentContainerAdminStaffPermission(manifest.id, deskContainers, "can_edit") from @vitnode/core/content |
defineStaffPermissions comes from @vitnode/core/lib/staff-permissions. Create the file, moving the declarations unchanged:
import { defineStaffPermissions } from "@vitnode/core/lib/staff-permissions";
import { manifest } from "@/manifest.gen";
export const staffPermissions = defineStaffPermissions(manifest, {
admin: { notes: ["can_create"] },
moderator: { notes: ["can_delete"] },
});Then replace each guard:
buildRoute({
adminStaffPermission: { module: "notes", permission: "can_create" },
// ...
});import { staffPermissions } from "@/staff-permissions";
buildRoute({
adminStaffPermission: staffPermissions.admin.notes.canCreate,
// ...
});What changes for you:
- Admin and moderator are different types. An admin reference in
moderatorStaffPermission, or a moderator one inadminStaffPermissionor an AdminCP nav item, is a compile error, even when both declare the same name. Before, both took any{ module, permission }, and the wrong kind silently checked the other staff type's grants. - A reference names its plugin. A guard checks the plugin that declares the permission, not the route's plugin. To guard with core's or another plugin's permission, import that plugin's references, for example
coreStaffPermissionsfrom@vitnode/core/lib/core-staff-permissions. - Generated permissions are a runtime boundary. Content Engine permissions do not exist until the content type is registered, so the compiler cannot check them.
contentAdminStaffPermissionandcontentContainerAdminStaffPermissioncheck them when the module loads instead, and throw for a permission the content type does not generate.
Nothing changes in the database. Stored grants, the AdminCP staff editor and the permission keys (site-notes:notes:can_create) are the same. checkStaffPermission, assertStaffPermission, useAdminPermission and editable pages' permission still take plain { plugin, module, permission } objects; staffPermissionArgs(ref) converts a reference. A guard still decides only who may call a route, not which record they may change. See Permission references.
Give every queue task a schema
| Removed | Use |
|---|---|
buildQueueTask({ name, handler }) from @vitnode/core/api/lib/queue, without payload | buildQueueTask({ name, payload, handler }) from @/api/builders |
c.get("queue").dispatch({ name, payload, pluginId?, ...options }) | c.get("queue").dispatch(task, payload, options?) |
BuildQueueTaskReturn | QueueTaskDefinition |
QueueDispatchArgs | QueueDispatchOptions, the third argument |
UntypedQueueTaskOptions | QueueTaskOptions |
import { buildQueueTask } from "@vitnode/core/api/lib/queue";
const zodPayload = z.object({ noteId: z.number().int().positive() });
export const announceNoteTask = buildQueueTask({
name: "announce-note",
handler: async (c, payload) => {
const { noteId } = zodPayload.parse(payload);
// ...
},
});
await c.get("queue").dispatch({
name: "announce-note",
payload: { noteId: id },
pluginId: "site-notes",
});import { z } from "zod";
import { buildQueueTask } from "@/api/builders";
export const announceNoteTask = buildQueueTask({
name: "announce-note",
payload: z.object({ noteId: z.number().int().positive() }),
handler: async (c, { noteId }) => {
// ...
},
});
// where you dispatch it:
await c.get("queue").dispatch(announceNoteTask, { noteId: id });A task without data takes payload: z.object({}). The task now carries its plugin id, so a dispatch from a listener, a cron job or another plugin's request no longer needs pluginId. buildApiPlugin throws QueueTaskRegistrationError for an entry in queueTasks that is not a task definition, and dispatch throws it for a task that is not registered.
Define events instead of declaring names
| Removed | Use |
|---|---|
declare module "@vitnode/core/api/models/events" { interface VitNodeEvents { ... } } | defineEvent({ name, payload }) from @/api/builders, in src/api/events.ts, plus buildApiPlugin({ eventDefinitions }) |
c.get("events").emit("site-notes.note.created", payload) | c.get("events").emit(noteCreatedEvent, payload) |
buildEventListener({ event: "user.created", ... }) | buildEventListener({ event: userCreatedEvent, ... }), with userCreatedEvent from @vitnode/core/api/events/users |
ContentEventsFor<typeof contentType> grafted onto VitNodeEvents, and contentEventName(id, action) | contentEvent(contentType, action) from @vitnode/core/content, registered for you |
VitNodeEventName, PaymentEventPayload, ContentContainerEventPayload, ContentApprovalEventPayload | EventPayload<typeof event> from @vitnode/core/api/lib/event-definition |
TypedEventEnvelope<Payload>, EventEnvelope<"name"> | EventEnvelope<Payload> from @vitnode/core/api/models/events |
TypedEventListenerOptions | EventListenerOptions from @vitnode/core/api/lib/events |
isEventDefinition | Not needed: every event is a definition |
declare module "@vitnode/core/api/models/events" {
interface VitNodeEvents {
"site-notes.note.created": { noteId: number; title: string };
}
}
await c.get("events").emit("site-notes.note.created", { noteId, title });
buildEventListener({ event: "site-notes.note.created", name: "log-new-note", handler });import { z } from "zod";
import { defineEvent } from "@/api/builders";
export const noteCreatedEvent = defineEvent({
name: "site-notes.note.created",
payload: z.object({
noteId: z.number().int().positive(),
title: z.string().min(1),
}),
});
// config.api.ts: buildApiPlugin({ ..., eventDefinitions: [noteCreatedEvent] })
// emit: await c.get("events").emit(noteCreatedEvent, { noteId, title });
// listen: buildEventListener({ event: noteCreatedEvent, name: "log-new-note", handler });Keep the event names: listeners in other plugins and external consumers match on them. Delete the declare module file and its side-effect import from config.api.ts. Built-in events lists the definition to import for every core, Content Engine, blog and newsroom event.
What changes at runtime:
- Every emit is validated. A payload the schema rejects now throws
EventPayloadValidationErrorin the emitting request, and nothing is delivered. Before, an emit by name was not validated. Listener and adapter failures are still reported infailures, never thrown. - Dates travel as ISO strings. An event payload must be JSON, so a date field uses
eventDate()from@vitnode/core/api/events/wire: emit an ISO string, and listeners get aDate. Core's only dated payload,moderation.sanction.applied, now takesendsAtas an ISO string; its listeners still receive aDate. Content Engine events emitted withemitContentEventtakeDatevalues and convert them for you. - Delivery is unchanged. The local adapter runs listeners in the emitting request, once per emit. Events are still not durable and not exactly-once.
Replace string cache keys with entries
| Removed | Use |
|---|---|
c.get("cache").remember(key, ttlSeconds, loader) | c.get("cache").entry(definition, ...keyArgs).remember(loader) |
c.get("cache").get(key), .set(key, value, ttl), .has(key) | entry(...).get(), entry(...).set(value); a miss is undefined |
c.get("cache").delete(key) or .delete([keys]) | entry(...).delete() for each entry |
getSystem, setSystem, deleteSystem, getSystemMany, acquireLock, releaseLock, isConfigured, status | Not on c.get("cache") any more, and not a replacement for the removed methods: they are VitNode core's own infrastructure. Use an entry (setIfMissing for a first-writer-wins token), or a database lock such as FOR UPDATE SKIP LOCKED |
MemoryCacheStore.has | Removed |
const stats = await c
.get("cache")
.remember(`notes:stats:${year}`, 300, async () => await countNotes(c, year));
await c.get("cache").delete(`notes:stats:${year}`);import { z } from "zod";
import { defineCacheEntry } from "@/api/builders";
export const yearStatsCache = defineCacheEntry({
name: "year-stats",
version: 1,
key: (year: number) => [year],
schema: z.object({ notes: z.number().int().nonnegative() }),
ttlSeconds: 300,
});
// read: await c.get("cache").entry(yearStatsCache, year).remember(loader);
// expire: await c.get("cache").entry(yearStatsCache, year).delete();Only entry() and flush() stay on c.get("cache"). A stored null is now a hit, where remember used to reload it. Cache API data walks through the full example.
Update generated services
Services that vitnode g service and vitnode g resource wrote before this change follow the old stubs. They still compile, but the new shape keeps HTTP out of the service:
| Removed stub | Current stub |
|---|---|
createNotesService = (deps: NotesServiceDeps) => ({ deps }) | createNotesService = (_deps: NotesServiceDeps) => ({ /* operations */ }) |
Resource operations rejecting with new HTTPException(501, ...) | Operations rejecting with NotesNotImplementedError; each route maps it to 501 with { error: "not_implemented", message } |
In services you have written, replace a thrown HTTPException with a result union or a domain error, and map it to a status in the route. See Add a service.
Check the result
- Build the plugin with
pnpm vitnode build. Type errors point at every remaining string API. - Run
pnpm vitnode doctor --strictin the plugin. It fails on an undeclared or wrong-type permission, a task, listener or cron job on a nested module, and warns on an admin route without a guard. See Doctor and inspect. - Run
pnpm vitnode inspect jobsandpnpm vitnode inspect events. Every task and listener showspayload schemaas its contract, with your plugin as the owner of its own tasks and events.
Queued tasks are durable
Rows in core_queue survive the upgrade, and the worker parses them with the task's new schema before the handler runs:
- Keep the task's
nameand the plugin id. Rows are matched to the handler by both. A renamed task leaves its old rows retrying withNo handler registered for queue task "ā¦"until they run out of attempts. - Write a schema that accepts what you dispatched before. If your handler parsed the payload by hand, that schema is the answer. New fields need
.optional()or.default(). - A row the schema rejects fails for good. It becomes failed at once with
Invalid payload: <path>: <code>inlastError, without retries, and stays in the table so you can dispatch a corrected task.
Core's own tasks kept their names and accept the rows earlier versions queued. Change a task payload covers later schema changes.
Cache entries are disposable
Nothing in the cache needs migrating. After the upgrade:
- Old string keys under
vitnode:cache:{plugin id}:are never read again. Keys written with a TTL expire on their own;c.get("cache").flush()in one of your routes removes the rest. - Without Redis, every restart starts cold, so the first requests after a deploy miss and reload.
- A stored value the entry's schema rejects is a miss, deleted on the way, and bumping
versionmakes every old value a miss at once.
See Change a cache entry.
Custom event adapters and third-party plugins
A custom EventsApiPlugin keeps its shape: publish(c, envelope) returning an EventEmitResult. Two things differ:
envelope.nameis astring, not a union of known names, andEventEnvelope<Payload>is generic over the payload instead of the name.envelope.payloadis the schema's output, validated beforepublishruns. Dated fields holdDatevalues;JSON.stringifywrites them as ISO strings, which the receiver can parse back with the same definition'spayloadschema.
Listeners in c.get("core").events.listeners still have an event name and a handler(c, payload, envelope). A third-party plugin that listened to another plugin's event by name now imports that plugin's definition; when the other plugin does not export one, ask its author to, rather than defining the same name again, which the API refuses at startup.
Related
- Write a complete operation: every typed API in one feature.
- Permission references: references, staff types and Content Engine permissions.
- Event payloads: validation, dates and adapters.