Events
Emit typed domain events and react to them from any plugin, with per-listener error isolation and a pluggable transport.
Events let plugins react to things happening elsewhere in your app - "a user signed up", "a blog category was deleted" - without the emitting code knowing who is listening. An event is a typed name + payload; listeners are plain handlers registered by modules, exactly like queue tasks and cron jobs.
await c.get("events").emit("user.created", {
userId: user.id,
email: user.email,
name: user.name,
emailVerified: user.emailVerified,
});See Built-in Events for the events VitNode and its plugins already emit.
How it works
c.get("events").emit(name, payload)
1. build an envelope (eventId, emittedAt, actor, emitting pluginId)
2. hand it to the configured adapter (Local by default)
3. Local adapter: run matching listeners sequentially, in registration order
ยท a listener that throws is logged to core_logs - the rest still run
4. resolve with an EventEmitResult { eventId, status, delivered, failures }Single-process delivery
The default Local adapter runs listeners in-process, inside the request
that emitted the event, on that instance only. It is not distributed event
delivery: with multiple instances, only the emitting instance runs listeners.
The events.adapter config slot exists so a broker transport (Redis Streams,
RabbitMQ, NATS, ...) can be plugged in later without changing any emit call -
see Custom Adapter.
Listening to an event
A listener is a named handler bound to one event, registered in a module - the
same shape as queueTasks and cronJobs.
Create the listener
The handler receives the request context, the typed payload, and the full envelope. This is the real listener shipped with the blog plugin - it removes search index rows of posts that were cascade-deleted with their category:
import { buildEventListener } from "@vitnode/core/api/lib/events";
export const cleanupCategorySearchListener = buildEventListener({
event: "blog.category.deleted",
name: "cleanup-category-search",
description:
"Remove search index rows of posts cascade-deleted with a category",
handler: async (c, payload) => {
for (const postId of payload.postIds) {
await c.get("search").delete("blog_post", postId);
}
},
});Register it in a module
import { buildModule } from "@vitnode/core/api/lib/module";
import { CONFIG_PLUGIN } from "@/const";
import { cleanupCategorySearchListener } from "../../lib/events";
export const adminModule = buildModule({
pluginId: CONFIG_PLUGIN.pluginId,
name: "admin",
routes: [],
events: [cleanupCategorySearchListener],
});Top-level modules only
Like cronJobs and queueTasks, events are only collected from the modules
passed directly to buildApiPlugin - listeners declared on nested submodules
are not registered.
Ownership is tracked automatically: every listener carries the pluginId and
module it was registered by, which is used for failure attribution and logging.
Removing a plugin from vitnode.api.config.ts removes its listeners - there is
nothing else to clean up.
Emitting an event
Emit from any route or model via c.get("events") - after the writes the
event describes have succeeded, so listeners never see an event for data that
doesn't exist:
const result = await c
.get("db")
.delete(blog_categories)
.where(eq(blog_categories.id, id))
.returning();
if (result.length === 0) {
throw new HTTPException(404);
}
await c.get("events").emit("blog.category.deleted", {
categoryId: id,
postIds: posts.map(post => post.id),
});emit() never throws. A broken listener in one plugin cannot break the
request or other plugins' listeners - failures are caught per listener, logged,
and reported in the result. If you care whether listeners succeeded, inspect it:
const result = await c.get("events").emit("blog.category.deleted", payload);
if (result.failures.length > 0) {
// each failure: { pluginId, module, listener, error }
}EventEmitResult
Prop
Type
The envelope
Listeners receive the payload directly plus the full envelope as a third argument:
Prop
Type
Execution and failure semantics
- Listeners run sequentially, in deterministic registration order: core
first, then your
pluginsarray order, then module order, then theeventsarray order within the module. - Each listener is awaited. A slow listener delays the request that emitted the event (see the queue tip below).
- A listener that throws is isolated: the error is written to
core_logs(visible in AdminCP โ Logs) withpluginId:module:listenerattribution, and the remaining listeners still run. emit()resolves with the result - it never rejects, even if every listener (or the adapter itself) fails.
Need retries or heavy work? Compose with the queue
Listeners run inline and are not retried. For work that should survive a crash or retry on failure, keep the listener thin and dispatch a queue task from it - the queue adds persistence, backoff, and retries.
handler: async (c, payload) => {
await c.get("queue").dispatch({
name: "send-welcome-email",
payload: { userId: payload.userId },
});
};Events and transactions
Emit after your awaited writes succeed. Because a route handler that throws
before emit() never emits, listeners can trust the event describes committed
data.
If you wrap writes in db.transaction, emit after the transaction callback
returns - never inside it. Inside the callback the data is not committed yet,
and the transaction may still roll back:
const post = await c.get("db").transaction(async tx => {
// ...writes on tx
return created;
});
// โ
transaction committed - safe to emit
await c.get("events").emit("blog.post.created", { postId: post.id, ... });There is no outbox in the first version: if the process crashes between the commit and the emit, the event is lost. Design listeners so a missed event is recoverable (e.g. the search rebuild exists for exactly this) rather than load-bearing for correctness.
Adding your own events
Events are typed through a single global map, VitNodeEvents. Plugins extend
it with module augmentation, then emit and listen with full type safety - other
plugins see your events too.
declare module "@vitnode/core/api/models/events" {
interface VitNodeEvents {
"shop.order.placed": {
orderId: number;
total: number;
userId: number;
};
}
}Payloads are plain JSON
Keep payloads JSON-serializable (no class instances, Maps, or functions). A broker adapter serializes the envelope to move it between processes. Prefer ids over whole records - listeners can load what they need.
Cross-plugin subscriptions just work: any plugin can listen to core's
user.created (or another plugin's events) by importing nothing more than
buildEventListener:
export const welcomeListener = buildEventListener({
event: "user.created",
name: "send-welcome-email",
handler: async (c, payload) => {
await c.get("queue").dispatch({
name: "send-welcome-email",
payload: { userId: payload.userId, email: payload.email },
});
},
});