Skip to content

The Event Bus

The event bus lets Slingshot packages communicate without coupling to each other. slingshot-auth doesn’t know about slingshot-community — but when a user logs in, auth emits an event and community reacts.

interface SlingshotEventBus {
emit<K extends keyof SlingshotEventMap>(event: K, payload: SlingshotEventMap[K]): void;
on<K extends keyof SlingshotEventMap>(
event: K,
listener: (payload: SlingshotEventMap[K]) => void | Promise<void>,
): void;
off<K extends keyof SlingshotEventMap>(
event: K,
listener: (payload: SlingshotEventMap[K]) => void,
): void;
shutdown?(): Promise<void>;
}

InProcessAdapter is the default — fast, zero-dependency, sufficient for single-instance deployments.

import { InProcessAdapter } from '@lastshotlabs/slingshot-core';
declare function hydrateUserProfile(userId: string): Promise<void>;
const bus = new InProcessAdapter();
bus.on('auth:user.created', async payload => {
await hydrateUserProfile(payload.userId);
});
bus.emit('auth:user.created', {
userId: 'usr_123',
email: 'new-user@example.com',
tenantId: null,
});

The bus calls handlers and moves on — it doesn’t await them inline with request handling. Use drain() in tests to wait for pending handlers to settle.

Event keys use a namespace:entity.action pattern. Reserved namespaces:

  • security.* — internal auth security events, never exposed to clients
  • auth:* — auth events, never exposed to clients
  • community:delivery.* — delivery status, internal only
  • push:* — push notification internals
  • app:* — application lifecycle events

Any other key can be exposed externally, but only when its event definition opts into exposure: ['client-safe'].

SSE endpoints filter against registry-backed event definitions. Plugins or route configs declare which events flow through by setting exposure: ['client-safe']:

declare const sseClients: Array<{ send(event: string, payload: unknown): void }>;
events.register(
defineEvent('community:thread.created', {
owner: 'community',
exposure: ['client-safe'],
}),
);
sseClients.forEach(client => client.send('community:thread.created', { id: 'thr_123' }));

This keeps internal events — session invalidation, security events, delivery status — from leaking to browser clients via SSE.

Handlers are fire-and-forget from the request handler’s perspective. In tests, wait for them explicitly:

// Emit event
await app.request('/community/threads', { method: 'POST', ... });
// Wait for all bus handlers to complete
await bus.drain();
// Now assert on side effects
expect(notificationStore.notifications).toHaveLength(1);

drain() resolves when all currently-enqueued handlers have settled. You’ll only need it in tests — production code never awaits the bus.

When events need to cross process boundaries, replace InProcessAdapter with a shared implementation by passing your own bus to defineApp:

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

The bus interface stays identical. Only the implementation changes.