Skip to content

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.

ConceptSurface
Type the event payloaddeclare module ... interface SlingshotEventMap { ... }
Register exposure and scopedefineEvent(...) in setupMiddleware({ events })
Publish from a route directlyevent: 'key' or { key, payload } on the route
Publish from anywhere elsectx.events.publish('key', payload)
Subscribebus.on('key', handler) in setupPost
Swap the transportdefineApp({ eventBus: ... })

Augment the framework event map once to get typed publish and on calls everywhere:

src/blog/event-types.ts
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.

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(...):

src/blog/events.ts
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,
};
},
}),
);
},
};
ExposureReaches
internalIn-process subscribers only
client-safeBrowser SSE clients
user-webhookWebhooks scoped to the event’s user
tenant-webhookWebhooks scoped to the event’s tenant
app-webhookApp-wide webhook subscribers
connectorExternal 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.

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:

src/blog/post.ts
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.

Same event: field on route.*():

src/packages/blog.ts
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.

Use ctx.events.publish(...) from any handler, plugin lifecycle hook, or background job. This is the canonical path for non-route-driven events:

// @skip-typecheck
import { 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 in setupPost so all publishers are wired before any subscriber starts listening:

src/packages/notifications.ts
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.

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" workflow

This 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.

These fire automatically with no setup:

EventWhen it fires
auth:user.createdA new user registers
auth:user.deletedA user account is deleted
auth:loginA user logs in
auth:logoutA user logs out
app:readyAll plugins have booted
app:shutdownServer is shutting down
security.rate_limit.exceededA rate limit was hit

Subscribe to any of them with bus.on(...) in setupPost.

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.

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:

app.config.ts
// @skip-typecheck
import { 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) or ctx.events.publish(...).
  • Customize — register explicit event definitions with defineEvent(...) to control exposure and scope; subscribe in setupPost.
  • Escape hatch — provide your own eventBus implementation when the app needs shared multi-instance delivery or a transport the framework doesn’t ship (Kafka, NATS, custom).

Your packages are now decoupled by typed events. The canonical journey continues:

  1. Plugin Interface — when you need framework-level infrastructure (auth, persistence, queues) with lifecycle hooks.
  2. Authoring Routes — per-route concerns once your package surfaces are settled.

Reference for later: