Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ revalidateTag("products", { revalidate: 3600 });
- Make sure to use the correct ARIA roles and attributes.
- Remember to use the "sr-only" Tailwind class for screen reader only text.
- Add alt text for all images, unless they are decorative or it would be repetitive for screen readers.
- Add emit an event for important actions like create, update, delete, etc. to allow other components to react to the changes and add to docs (apps/docs/content/docs/dev/events/built-in-events.mdx)
- Use events to communicate between components instead of prop drilling or using context.

# Design

Expand All @@ -67,6 +69,15 @@ revalidateTag("products", { revalidate: 3600 });
# Documentation

- Don't use big comments. Remember that code is self-documenting.
- If should be simple. if docs requires image to better understand, add comment:

```markdown
// Image prompt: {here_prompt_to_generate_image}
```

- Always write documentation for all new features.
- Keep docs simple and easy to understand.
- Use funny and friendly tone in docs, but don't overdo it.

# Testing

Expand Down
201 changes: 201 additions & 0 deletions apps/docs/content/docs/dev/events/built-in-events.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
---
title: Built-in Events
description: Reference of the events emitted by VitNode core and first-party plugins, with payloads and real use cases.
---

Reference of every event VitNode and its first-party plugins emit today. Listen
to any of them from your own plugin with
[`buildEventListener`](/docs/dev/events#listening-to-an-event) - no imports from
the emitting plugin are needed, the event map is global.

| Event | Payload | Emitted when |
| ----------------------- | ---------------------------------------- | ------------------------------------------------------- |
| `user.created` | `{ userId, email, name, emailVerified }` | A user is created - sign-up, AdminCP, or SSO first sign-in |
| `user.updated` | `{ userId, email, name }` | A user is edited in the AdminCP (profile or roles) |
| `user.deleted` | `{ userId, email }` | _Declared only_ - core has no user deletion flow yet |
| `role.created` | `{ roleId }` | A role is created in the AdminCP |
| `role.updated` | `{ roleId }` | A role is edited in the AdminCP |
| `role.deleted` | `{ roleId }` | _Declared only_ - core has no role deletion flow yet |
| `blog.post.created` | `{ postId, categoryId }` | A blog post is created |
| `blog.post.updated` | `{ postId, categoryId }` | A blog post is edited |
| `blog.post.deleted` | `{ postId, categoryId }` | A blog post is deleted |
| `blog.category.created` | `{ categoryId }` | A blog category is created |
| `blog.category.updated` | `{ categoryId }` | A blog category is edited |
| `blog.category.deleted` | `{ categoryId, postIds }` | A blog category (and its posts, via cascade) is deleted |

## Core

### user.created

Emitted after a user row is committed to `core_users`, from every creation
path: the public sign-up form, user creation in the AdminCP, and the first
sign-in through an [SSO provider](/docs/dev/sso).

import { TypeTable } from "fumadocs-ui/components/type-table";

<TypeTable
type={{
userId: {
description: "Id of the new user.",
type: "number",
},
email: {
description: "Email the account was registered with.",
type: "string",
},
name: {
description: "Display name.",
type: "string",
},
emailVerified: {
description:
"Whether the account starts verified (e.g. no email adapter configured, or created by an admin).",
type: "boolean",
},
}}
/>

**Use cases:** send a welcome or verification email (dispatch a
[queue task](/docs/dev/advanced/queue) so it retries), subscribe the user to a
newsletter audience, provision plugin-owned data (a profile row, default
settings), or notify moderators about new registrations via
`c.get("realtime")`.

```ts title="Example: welcome email listener"
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 },
});
},
});
```

### user.updated

Emitted after a user is edited in the AdminCP - profile fields (email, name,
name code) and/or role assignments. The payload carries the user's **current**
values after the update.

<TypeTable
type={{
userId: {
description: "Id of the updated user.",
type: "number",
},
email: {
description: "Email after the update.",
type: "string",
},
name: {
description: "Display name after the update.",
type: "string",
},
}}
/>

**Use cases:** sync the user's identity into an external system (CRM, mailing
list), invalidate plugin-owned caches keyed by user, or audit-log staff edits
using the envelope's `actor`.

### role.created / role.updated

Emitted after a role is created or edited in the AdminCP (including its
translated names, which live in `core_languages_words`).

<TypeTable
type={{
roleId: {
description: "Id of the affected role.",
type: "number",
},
}}
/>

**Use cases:** provision plugin-side permission defaults for a new role, or
refresh externally-cached permission matrices when a role changes.

### user.deleted / role.deleted (declared only)

These events exist in the `VitNodeEvents` map so listeners and payloads are
already typed, but **core never emits them today** - there is no user or role
deletion flow yet. They are the agreed-upon names for plugins that implement
deletion themselves, and core will emit them once deletion lands.

## Blog (`@vitnode/blog`)

### blog.category.created / blog.category.updated

Emitted after a category (and its translated titles) is created or edited in
the AdminCP.

<TypeTable
type={{
categoryId: {
description: "Id of the affected category.",
type: "number",
},
}}
/>

**Use cases:** keep navigation menus or externally-cached category trees in
sync, or notify an external CMS/feed of taxonomy changes.

### blog.post.created / blog.post.updated / blog.post.deleted

Emitted from the AdminCP post routes after the post (and its translations and
search index entries) are written.

<TypeTable
type={{
postId: {
description: "Id of the affected post.",
type: "number",
},
categoryId: {
description: "Category the post belongs to.",
type: "number",
},
}}
/>

**Use cases:** push a realtime "new post" notification to subscribers, ping a
webhook (via a queue task) that shares the post to social media, invalidate an
external cache/CDN, or keep plugin-owned derived data (reading lists, related
posts) in sync.

### blog.category.deleted

Emitted after a category is deleted. Deleting a category cascade-deletes its
posts at the database level, so the payload carries the ids of the posts that
were removed with it.

<TypeTable
type={{
categoryId: {
description: "Id of the deleted category.",
type: "number",
},
postIds: {
description: "Posts removed by the category's cascade delete.",
type: "number[]",
},
}}
/>

**Use cases:** the blog plugin itself ships a listener on this event
(`cleanup-category-search`) that removes the cascade-deleted posts from the
search index - a good template for cleaning up any data your plugin keys by
post id.

## Deliberately not emitted (yet)

High-frequency or consumer-less events are added only when a listener needs
them, to keep the catalog meaningful: there is currently no `user.signedIn`,
`user.passwordResetRequested`, or `file.uploaded`. If you need one of these,
open an issue or PR - adding an event is a one-line `emit` plus an entry in the
`VitNodeEvents` map. (`user.deleted` and `role.deleted` are a special case:
they are declared in the map already, but wait on core growing deletion flows.)
124 changes: 124 additions & 0 deletions apps/docs/content/docs/dev/events/custom-adapter.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
---
title: Custom Adapter
description: Replace the in-process event transport with your own adapter, e.g. a message broker for cross-instance delivery.
---

The transport behind `c.get("events").emit()` is pluggable, following the same
pattern as [search](/docs/dev/search), storage, and email adapters. The default
**Local** adapter delivers in-process only; a custom adapter can publish events
to a broker (Redis Streams, RabbitMQ, NATS, ...) so every instance can react.

An adapter implements a single method:

```ts
import type { Context } from "hono";
import type {
EventEnvelope,
EventEmitResult,
} from "@vitnode/core/api/models/events";

export interface EventsApiPlugin {
name: string;
publish: (c: Context, envelope: EventEnvelope) => Promise<EventEmitResult>;
}
```

## Usage

<Steps>
<Step>

### Create your custom adapter

As an example, an adapter that publishes every event to a message broker
instead of running listeners in-process:

```ts
import type {
EventsApiPlugin,
EventEnvelope,
} from "@vitnode/core/api/models/events";

export const MyBrokerEventsAdapter = (): EventsApiPlugin => ({
name: "my-broker",
publish: async (c, envelope) => {
await broker.publish("vitnode:events", JSON.stringify(envelope)); // [!code ++]

// Delivery happens out-of-band - report "queued", not "delivered".
return {
eventId: envelope.eventId,
status: "queued",
delivered: 0,
failures: [],
};
},
});
```

<Callout type="info" title="Report the right status">
A broker adapter returns `status: "queued"`: listeners run out-of-band, so
`delivered` and `failures` say nothing about them. The consumer side of your
broker is responsible for running the registered listeners
(`c.get("core").events.listeners`) on each instance.
</Callout>

</Step>

<Step>

### Integrate the adapter into your application

```ts title="src/vitnode.api.config.ts"
import { MyBrokerEventsAdapter } from "./path/to/your/custom/events.adapter";

export const vitNodeApiConfig = buildApiConfig({
// [!code ++]
events: { adapter: MyBrokerEventsAdapter() },
});
```

</Step>

<Step>

### Restart server

After making these changes, stop your server (if it's running) and restart it
to apply the new configuration.

import { Tab, Tabs } from "fumadocs-ui/components/tabs";

<Tabs groupId='package-manager' persist items={['bun', 'pnpm', 'npm']}>

```bash tab="bun"
bun dev
```

```bash tab="pnpm"
pnpm dev
```

```bash tab="npm"
npm run dev
```

</Tabs>

Every `emit()` call in core and plugins now goes through your adapter - no emit
site changes needed.

</Step>

</Steps>

## Contract your adapter must keep

- **Never let `publish` throw for expected failures** if you can help it - but
if it does, `emit()` still resolves: the model catches the error, logs it to
`core_logs`, and reports it in `result.failures`.
- **Envelopes are JSON-serializable** by convention (payloads are documented as
plain JSON), so `JSON.stringify(envelope)` is safe; `emittedAt` serializes to
an ISO string.
- **Preserve ordering per event name** if your listeners rely on it - the Local
adapter runs listeners sequentially in registration order, and listener code
written against it may assume that.
Loading