Skip to Content
Documentation

Events

Event payloads

How a VitNode event's Zod schema shapes what emit accepts and listeners receive: dates on the wire, validation versus delivery failures, adapters and the API.

Every VitNode event is defined with a payload schema. c.get("events").emit(event, payload) type-checks the payload against the schema's input, copies it to JSON and validates it before the adapter or any listener sees it. Listeners, and the adapter, receive the schema's output. This page explains what that means for dates, errors and adapters. To define and emit your first event, start with Events.

Input and output

Most schemas have the same input and output. They differ when the schema transforms a value, and the one transform core uses is for dates. The JSON wire has no date type, so a date field takes an ISO string and gives listeners a Date:

plugins/site-notes/src/api/events.ts
import { eventDate } from "@vitnode/core/api/events/wire";
import { z } from "zod";

import { defineEvent } from "@/api/builders";

export const notePinnedEvent = defineEvent({
  name: "site-notes.note.pinned",
  payload: z.object({
    noteId: z.number().int().positive(),
    pinnedUntil: eventDate().nullable(),
  }),
});
// emit: an ISO string
await c.get("events").emit(notePinnedEvent, {
  noteId: note.id,
  pinnedUntil: until.toISOString(),
});

// listener: a Date
handler: async (c, { pinnedUntil }) => {
  if (pinnedUntil && pinnedUntil < new Date()) return;
},

Passing a Date to emit is a compile error, and z.date() in an event schema is too. EventInput<typeof event> and EventPayload<typeof event> name the two types. Core's dated fields work the same way: moderation.sanction.applied takes endsAt as an ISO string, and Content Engine events take publishedAt, scheduledFor and hiddenAt as ISO strings. Listeners of all of them get Date values.

Validation failures and delivery failures

What went wrongWhat emit() does
The payload is not JSON, or the schema rejects itThrows EventPayloadValidationError. No listener ran and nothing reached the adapter
A different definition is registered under the nameThrows EventDefinitionError
A listener throwsLogs it and returns it in failures; the other listeners still run
The adapter throwsLogs it and returns one failure with module: "adapter"

A validation failure is a bug in the emitting code, so it surfaces as an error in that request. A delivery failure is someone else's listener, so it never fails the write that already happened. Error messages name paths and issue codes, never payload values. The schema runs once per emit, and every listener gets the same parsed value.

Local and external adapters

The default local adapter runs listeners in the emitting request and hands them the parsed output directly. A custom adapter receives the same output in envelope.payload, including Date objects, and serializes it itself. envelope.name is the event name as a string.

When an event crosses a process boundary, parse the payload on the receiving side with the same definition: notePinnedEvent.payload.parse(JSON.parse(raw).payload). JSON.stringify writes a Date as an ISO string, which eventDate() reads back, so dates round-trip. Avoid other transforms in schemas of events that leave the process; they may not survive a round trip.

Content Engine events

contentEvent(contentType, action) from @vitnode/core/content returns the definition of content.<id>.<action>. You never define or register these: buildApiPlugin registers one per action for every content type it registers, owned by that plugin, and every call returns the same object.

  • Only the actions the content type enables are accepted. contentEvent(categoryContentType, "published") does not compile for a content type without publication.
  • contentId is a number for the serial id strategy and a string for uuid and bigint.
  • changedFields entries must start with one of the content type's field names.
  • The schemas accept extra keys, because the engine stamps documented extras such as version onto some payloads.

Reference

ExportBehavior
defineEvent({ name, payload }) from @/api/buildersDeclares an event owned by your plugin. Names use letters, digits and . _ - : / @, up to 200 characters. The unbound version in @vitnode/core/api/lib/event-definition also takes pluginId
EventDefinition, EventInput<typeof event>, EventPayload<typeof event>The definition type, what emit accepts and what listeners receive. From @vitnode/core/api/lib/event-definition
EventPayloadValidationErrorThrown by emit for an invalid payload. reason names paths and codes
EventDefinitionErrorTwo different definitions for one name, a plugin registering another plugin's definition, or an invalid name. Thrown at startup, or by emit for a conflicting definition
eventDate() from @vitnode/core/api/events/wireA date field: ISO string in, Date out
buildEventListener({ event, name, handler, description? }) from @vitnode/core/api/lib/eventsA listener for one definition. Options type: EventListenerOptions
EventEnvelope<Payload>, EventsApiPlugin from @vitnode/core/api/models/eventsWhat listeners and adapters receive, and the adapter interface
contentEvent(contentType, action) from @vitnode/core/contentA Content Engine event definition

buildApiPlugin({ eventDefinitions }) takes the plugin's own definitions. A definition used by a listener is known without it, so another plugin's event never has to be registered to be listened to.

  • Events: define, emit, listen, and how listeners run.
  • Built-in events: every event and the export to import for it.