Realtime with SSE
Slingshot’s SSE support bridges the event bus to browser clients. Events from plugins and entities flow directly to connected clients — no WebSocket server, no polling.
How it works
Section titled “How it works”- Your entities and plugins emit events to the internal event bus
- SSE endpoints subscribe to the bus and forward client-safe events to connected clients
- Clients connect with a session cookie or bearer token and receive a filtered stream
Only events whose registry definitions include exposure: ['client-safe'] reach clients. Endpoint
filters can narrow that set further, but they cannot widen it beyond the registry-approved event
definitions.
Community notifications
Section titled “Community notifications”slingshot-notifications ships a built-in SSE inbox route at /notifications/sse.
Register createNotificationsPackage() alongside createCommunityPackage() and clients can connect to GET /notifications/sse to receive notification events scoped to the authenticated user.
Custom SSE endpoint
Section titled “Custom SSE endpoint”For a custom event stream, define your own SSE endpoint:
import { defineApp } from '@lastshotlabs/slingshot';
export default defineApp({ sse: { endpoints: { '/__sse/activity': { events: ['blog:post.created', 'blog:post.published'],
upgrade: async req => { const token = req.headers.get('authorization')?.replace('Bearer ', ''); if (!token) return new Response('Unauthorized', { status: 401 }); const userId = verifyToken(token); if (!userId) return new Response('Unauthorized', { status: 401 }); return { actor: { id: userId, kind: 'user' as const, tenantId: null, sessionId: null, roles: null, claims: {}, }, requestTenantId: null, }; },
filter: async (message, clientData) => { // Per-user filtering — only send events relevant to this client return true; }, }, }, },});Entity-level client-safe events
Section titled “Entity-level client-safe events”Events only reach SSE clients if the route event opts into client-safe exposure:
export const Post = defineEntity('Post', { routes: { create: { event: { key: 'blog:post.created', payload: ['id', 'authorId', 'title'], scope: { resourceType: 'blog:post', resourceId: 'record:id' }, exposure: ['client-safe'], }, }, },});Events without exposure: ['client-safe'] — including blog:post.deleted, internal validation events, or anything with sensitive data — never reach clients.
Consuming SSE from the browser
Section titled “Consuming SSE from the browser”The native EventSource API doesn’t support custom headers. For browser clients, prefer
cookie-backed sessions so the token stays out of URLs, logs, and browser history:
const es = new EventSource('/__sse/activity', { withCredentials: true });
es.addEventListener('blog:post.created', event => { const post = JSON.parse(event.data); console.log('New post:', post.title);});
es.addEventListener('blog:post.published', event => { const post = JSON.parse(event.data); console.log('Published:', post.title);});
es.onerror = () => { // Reconnect automatically after a delay setTimeout(() => reconnect(), 3000);};Cookie-based sessions work without query parameters because browsers send cookies automatically
with EventSource requests. If your endpoint uses bearer tokens instead of cookies, use a
fetch-based SSE client that can set the Authorization header, or connect from a non-browser
client:
const response = await fetch('/__sse/activity', { headers: { authorization: `Bearer ${token}` },});Per-user filtering
Section titled “Per-user filtering”Use the filter function to implement per-client filtering — only send events the specific user should see:
filter: async (message, clientData: { actor: { id: string | null } }) => { const { key, payload } = message;
// Only send thread events to users in that container if (key === 'community:thread.created') { const isMember = await checkContainerMembership(clientData.actor.id, payload.containerId); return isMember; }
return true;};WebSockets
Section titled “WebSockets”For bidirectional communication, Slingshot also supports WebSocket endpoints:
export default defineApp({ ws: { endpoints: { '/api/chat': { upgrade: wsUpgrade, on: { open: wsOpen, message: wsMessage, close: wsClose, }, }, }, },});export async function wsUpgrade(req: Request, server: Server) { const token = req.headers.get('authorization')?.replace('Bearer ', ''); if (!token) return new Response('Unauthorized', { status: 401 }); // Return undefined to allow upgrade, or a Response to reject}
export async function wsOpen(ws: ServerWebSocket) { ws.subscribe('chat-room');}
export async function wsMessage(ws: ServerWebSocket, message: string | Buffer) { ws.publish('chat-room', message);}
export async function wsClose(ws: ServerWebSocket) { ws.unsubscribe('chat-room');}Multi-instance SSE
Section titled “Multi-instance SSE”For multi-instance deployments, replace the in-process event bus with a shared bus so SSE events cross instance boundaries. BullMQ is the built-in option:
import { defineApp } from '@lastshotlabs/slingshot';import { createBullMQAdapter } from '@lastshotlabs/slingshot-bullmq';
export default defineApp({ eventBus: createBullMQAdapter({ connection: { host: process.env.REDIS_HOST!, port: 6379 }, }),});If you want Redis pub/sub, NATS, Kafka, or another broker instead, register it as a custom event bus and select it with the object form shown in Custom Event Bus.
Where to go next
Section titled “Where to go next”- Custom Event Bus - BullMQ plus custom buses for multi-instance deployments
- Forum App — community SSE notifications in context
- Internals: Event Bus — how client-safe event filtering works under the hood