Skip to content

Event Bus

If you are building an app, start with Core Features: Events and the Event Bus.

This page is the lower-level plugin and authoring reference view of the same system.

The event bus is how plugins talk to each other without importing each other. Auth emits auth:user.created. Mail subscribes and sends the welcome email. No auth-to-mail dependency, no circular imports, no tight coupling.

That is the point. When auth deletes a user, it should not need to know that mail exists, or notes exist, or anything else. It fires the event. Every subscriber gets it and does its own thing.

import type { SlingshotPlugin } from '@lastshotlabs/slingshot-core';
import { getContext } from '@lastshotlabs/slingshot-core';
const plugin: SlingshotPlugin = {
name: 'notes',
setupPost({ app, bus }) {
const ctx = getContext(app);
void ctx;
bus.on('auth:user.created', () => {});
},
};
void plugin;
import { InProcessAdapter } from '@lastshotlabs/slingshot-core';
const bus = new InProcessAdapter();
bus.emit('auth:user.created', {
userId: 'user-123',
email: 'user@example.com',
tenantId: null,
});
bus.emit('app:ready', { plugins: ['slingshot-auth', 'notes'] });

Always subscribe in setupPost - that is after all plugins have finished setupRoutes, which means all publishers are wired before any subscriber tries to listen.

import type { SlingshotPlugin } from '@lastshotlabs/slingshot-core';
async function sendWelcomeEmail(email: string): Promise<void> {
void email;
}
async function deleteAllUserData(userId: string): Promise<void> {
void userId;
}
const plugin: SlingshotPlugin = {
name: 'notes',
setupPost({ bus }) {
bus.on('auth:user.created', async ({ email }) => {
if (email) {
await sendWelcomeEmail(email);
}
});
bus.on('auth:user.deleted', async ({ userId }) => {
await deleteAllUserData(userId);
});
},
};
void plugin;

Add your package’s events to the shared type map via module augmentation:

src/events.ts
interface Note {
id: string;
title: string;
}
declare module '@lastshotlabs/slingshot-core' {
interface SlingshotEventMap {
'note.created': { userId: string; noteId: string; title: string };
'note.updated': { userId: string; noteId: string; changes: Partial<Note> };
'note.deleted': { userId: string; noteId: string };
}
}

After this, bus.emit('note.created', ...) is fully typed and bus.on('note.created', payload => ...) gets the correct payload type.

The SSE endpoint only forwards events you have explicitly flagged as client-safe. This is not opt-out - it is opt-in. Internal events such as auth tokens, security signals, and delivery internals never reach browser clients regardless of what you register.

Register externally deliverable events before publishing them:

import { type SlingshotPlugin, defineEvent } from '@lastshotlabs/slingshot-core';
declare module '@lastshotlabs/slingshot-core' {
interface SlingshotEventMap {
'note.created': { userId: string; noteId: string };
'note.updated': { userId: string; noteId: string };
'note.deleted': { userId: string; noteId: string };
}
}
const plugin: SlingshotPlugin = {
name: 'notes',
setupMiddleware({ events }) {
events.register(
defineEvent('note.created', {
ownerPlugin: 'notes',
exposure: ['client-safe'],
resolveScope(payload) {
return { userId: payload.userId, actorId: payload.userId };
},
}),
);
events.register(
defineEvent('note.updated', {
ownerPlugin: 'notes',
exposure: ['client-safe'],
resolveScope(payload) {
return { userId: payload.userId, actorId: payload.userId };
},
}),
);
events.register(
defineEvent('note.deleted', {
ownerPlugin: 'notes',
exposure: ['client-safe'],
resolveScope(payload) {
return { userId: payload.userId, actorId: payload.userId };
},
}),
);
},
};
void plugin;

Forbidden namespaces are never forwarded regardless of event-definition exposure:

NamespaceReason
security.*Internal security events
auth:*Auth lifecycle with PII or tokens
community:delivery.*Internal delivery signals
push:*Push delivery internals
app:*Framework lifecycle

Event handlers run asynchronously - emit fires and returns immediately. In tests, that means your assertions can run before the handlers finish. bus.drain() waits for all pending handlers to settle before you assert.

import { expect, test } from 'bun:test';
import { InProcessAdapter } from '@lastshotlabs/slingshot-core';
test('drains async listeners before asserting', async () => {
const bus = new InProcessAdapter();
const sentEmails: string[] = [];
bus.on('auth:user.created', async ({ email }) => {
if (email) {
sentEmails.push(email);
}
});
bus.emit('auth:user.created', {
userId: 'user-1',
email: 'test@example.com',
tenantId: null,
});
await bus.drain();
expect(sentEmails).toEqual(['test@example.com']);
});

These come from the core event map - no augmentation needed:

EventPayload
auth:user.created{ userId, email?, tenantId? }
auth:user.deleted{ userId, tenantId? }
auth:login{ userId, sessionId, tenantId? }
auth:logout{ userId, sessionId }
auth:mfa.enabled{ userId, method }
auth:mfa.disabled{ userId, method? }
security.rate_limit.exceeded{ key?, ip?, meta? }
app:ready{ plugins }
app:shutdown{ signal }

The default InProcessAdapter delivers events only within the current process. For multi-instance deployments, switch to a shared event bus so events cross instances:

app.config.ts (excerpt)
import { createBullMQAdapter } from '@lastshotlabs/slingshot-bullmq';
export default defineApp({
eventBus: createBullMQAdapter({
connection: { host: process.env.REDIS_HOST!, port: 6379 },
}),
});

BullMQ is the built-in option. Other brokers still work through the registry-backed custom event-bus path. See Custom Event Bus for the full configuration.