Skip to content

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.

  1. Your entities and plugins emit events to the internal event bus
  2. SSE endpoints subscribe to the bus and forward client-safe events to connected clients
  3. 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.

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.

For a custom event stream, define your own SSE endpoint:

app.config.ts (excerpt)
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;
},
},
},
},
});

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.

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}` },
});

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;
};

For bidirectional communication, Slingshot also supports WebSocket endpoints:

app.config.ts (excerpt)
export default defineApp({
ws: {
endpoints: {
'/api/chat': {
upgrade: wsUpgrade,
on: {
open: wsOpen,
message: wsMessage,
close: wsClose,
},
},
},
},
});
app.config.ts
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');
}

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.