Events and the Event Bus
The event bus is the shared transport behind package-to-package side effects, SSE, webhook
delivery, search sync, and lifecycle events. Every Slingshot app gets one — createApp()
creates an in-process bus by default.
You define an event once. Anyone can publish it. Anyone can subscribe. No imports between packages, no tight coupling.
The model
Section titled “The model”| Concept | Surface |
|---|---|
| Type the event payload | declare module ... interface SlingshotEventMap { ... } |
| Register exposure and scope | defineEvent(...) in setupMiddleware({ events }) |
| Publish from a route directly | event: 'key' or { key, payload } on the route |
| Publish from anywhere else | ctx.events.publish('key', payload) |
| Subscribe | bus.on('key', handler) in setupPost |
| Swap the transport | defineApp({ eventBus: ... }) |
Define the event type
Section titled “Define the event type”Augment the framework event map once to get typed publish and on calls everywhere:
declare module '@lastshotlabs/slingshot-core' { interface SlingshotEventMap { 'blog:post.published': { postId: string; authorId: string; title: string; slug: string; }; 'blog:post.updated': { id: string; title: string }; }}
export {};Once augmented, every publish and on call for these keys is type-checked end to end.
Register exposure and scope
Section titled “Register exposure and scope”The type augmentation only describes the payload shape. To control whether the event flows
to browsers (SSE), webhook subscribers, or external connectors, register a runtime
definition with defineEvent(...):
import type { SlingshotPlugin } from '@lastshotlabs/slingshot-core';import { defineEvent } from '@lastshotlabs/slingshot-core';import './event-types';
export const blogEventsPlugin: SlingshotPlugin = { name: 'blog-events', setupMiddleware({ events }) { events.register( defineEvent('blog:post.published', { ownerPlugin: 'blog', exposure: ['client-safe', 'user-webhook'], resolveScope(payload) { return { userId: payload.authorId, actorId: payload.authorId, resourceType: 'post', resourceId: payload.postId, }; }, }), ); },};| Exposure | Reaches |
|---|---|
internal | In-process subscribers only |
client-safe | Browser SSE clients |
user-webhook | Webhooks scoped to the event’s user |
tenant-webhook | Webhooks scoped to the event’s tenant |
app-webhook | App-wide webhook subscribers |
connector | External connector integrations |
resolveScope produces the routing key used by SSE filtering, webhook fan-out, and any
other consumer that needs to know “who is this event about.” A single event model fans
out into every transport without each transport inventing its own allowlist.
Three ways to publish
Section titled “Three ways to publish”From a generated entity route
Section titled “From a generated entity route”Set event on a CRUD or named operation in the entity’s routes config — the framework
publishes after the operation succeeds, with the record as payload:
defineEntity('Post', { fields: { /* ... */ }, routes: { create: { event: 'blog:post.published' }, // shorthand: emit on create update: { event: { key: 'blog:post.updated', payload: ['id', 'title'] } }, // explicit fields },});The shorthand passes the full record as payload. The object form lets you whitelist which fields go on the wire.
From a custom domain route
Section titled “From a custom domain route”Same event: field on route.*():
route.post({ path: '/posts/:id/publish', auth: 'userAuth', event: { key: 'blog:post.published', payload: ['id', 'title', 'slug'] }, handler: async ({ params, ctx, respond }) => { const post = await ctx.posts.publish(params.id); return respond.json(post); },});Events declared this way fire only when the handler returns a 2xx response — error paths do not leak partial state to subscribers.
From anywhere else
Section titled “From anywhere else”Use ctx.events.publish(...) from any handler, plugin lifecycle hook, or background job.
This is the canonical path for non-route-driven events:
// @skip-typecheckimport { getContext } from '@lastshotlabs/slingshot';
const ctx = getContext(app);ctx.events.publish('blog:post.published', { postId: 'post_123', authorId: 'usr_123', title: 'Hello Slingshot', slug: 'hello-slingshot',});ctx.events.publish produces the framework event envelope, applies the registered event
definition, and keeps SSE and webhook filtering coherent. Use raw bus.emit(...) only for
transport-specific behavior where you don’t want the registered envelope.
Subscribe
Section titled “Subscribe”Subscribe in setupPost so all publishers are wired before any subscriber starts listening:
import type { SlingshotPlugin } from '@lastshotlabs/slingshot-core';
declare function sendNewPostNotification(authorId: string, title: string): Promise<void>;declare function sendWelcomeEmail(email: string): Promise<void>;
export const notificationsPlugin: SlingshotPlugin = { name: 'notifications', setupPost({ bus }) { bus.on('blog:post.published', async payload => { await sendNewPostNotification(payload.authorId, payload.title); });
bus.on('auth:user.created', async ({ email }) => { if (email) { await sendWelcomeEmail(email); } }); },};No dependency between the publisher and the subscriber. The blog package fires
blog:post.published; notifications, search, analytics, and webhooks all subscribe
independently.
Fanout: one event, many effects
Section titled “Fanout: one event, many effects”A single event can drive many side effects. Each subscriber does its own thing:
blog:post.published ├── notifications plugin → sends email + push to author's followers ├── search plugin → indexes the post in OpenSearch ├── SSE endpoint → notifies subscribed dashboards in realtime ├── webhook service → POSTs to user-configured webhook URLs └── orchestration → enqueues a "boost new post" workflowThis is the entire reason the bus exists. The auth package never imports the mail package
even when login emails go out — auth fires auth:login, mail subscribes.
Built-in events
Section titled “Built-in events”These fire automatically with no setup:
| Event | When it fires |
|---|---|
auth:user.created | A new user registers |
auth:user.deleted | A user account is deleted |
auth:login | A user logs in |
auth:logout | A user logs out |
app:ready | All plugins have booted |
app:shutdown | Server is shutting down |
security.rate_limit.exceeded | A rate limit was hit |
Subscribe to any of them with bus.on(...) in setupPost.
Security-sensitive events
Section titled “Security-sensitive events”Some namespaces are intentionally not browser-deliverable. SECURITY_EVENT_TYPES and the
related framework rules ensure SSE and other client-facing transports filter sensitive
events even if a plugin accidentally marks one client-safe. Auth tokens, rate-limit
internals, and security signals never reach the browser regardless of configuration.
Scaling the bus
Section titled “Scaling the bus”The default in-process adapter is correct for a single process. For multi-instance deployments, swap to a shared transport — your event definitions and subscribers don’t change, only the wire:
// @skip-typecheckimport { defineApp } from '@lastshotlabs/slingshot';
declare const sharedBus: import('@lastshotlabs/slingshot-core').SlingshotEventBus;
export default defineApp({ eventBus: sharedBus, // or 'bullmq', when using slingshot-bullmq});Cross-instance event delivery, webhook fan-out, and any package behavior that depends on events crossing process boundaries flow through this single decision.
The default, the customization, and the escape hatch
Section titled “The default, the customization, and the escape hatch”- Default — let
createApp()create the in-process bus and publish through routes (event:shorthand) orctx.events.publish(...). - Customize — register explicit event definitions with
defineEvent(...)to control exposure and scope; subscribe insetupPost. - Escape hatch — provide your own
eventBusimplementation when the app needs shared multi-instance delivery or a transport the framework doesn’t ship (Kafka, NATS, custom).
Next in your journey
Section titled “Next in your journey”Your packages are now decoupled by typed events. The canonical journey continues:
- Plugin Interface — when you need framework-level infrastructure (auth, persistence, queues) with lifecycle hooks.
- Authoring Routes — per-route concerns once your package surfaces are settled.
Reference for later:
- Server-Sent Events — stream events to browsers.
- Custom Event Bus — BullMQ + Redis transport setup.
- Event Bus Reference — plugin-level bus API.
- Internals: Event Bus — transport contract and adapter details.