WebSockets
If you are learning WebSockets as a core app feature, start with Core Features: Realtime.
This guide goes deeper on lower-level and entity-channel-specific behavior.
Slingshot supports WebSocket endpoints alongside your HTTP routes. WebSockets are bidirectional — clients send messages, not just receive them. Use them for chat, collaborative editing, live cursors, and anything that needs low-latency two-way communication. For one-way event streams, use SSE instead.
Define a WebSocket endpoint
Section titled “Define a WebSocket endpoint”// @skip-typecheckimport { defineApp } from '@lastshotlabs/slingshot';
declare function verifyToken(token: string): string | null;
export default defineApp({ port: 3000, ws: { endpoints: { '/ws/chat': { upgrade: async req => { const auth = req.headers.get('authorization'); const token = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length) : null; if (!token) return new Response('Unauthorized', { status: 401 }); const userId = verifyToken(token); if (!userId) return new Response('Unauthorized', { status: 401 }); return { actorId: userId }; // attached to ws.data }, on: { open: ws => { console.log('Connected:', ws.data.actor.id); }, message: (ws, message) => { ws.send(message); }, close: ws => { console.log('Disconnected:', ws.data.actor.id); }, }, }, }, },});Rooms and pub/sub
Section titled “Rooms and pub/sub”Bun’s WebSocket server has built-in room support. Clients subscribe to named rooms and publish to them:
export async function wsOpen(ws: ServerWebSocket<{ actorId: string; roomId: string }>) { // Subscribe this connection to a room ws.subscribe(ws.data.roomId);
// Announce to the room ws.publish( ws.data.roomId, JSON.stringify({ type: 'joined', userId: ws.data.actor.id, }), );}
export async function wsMessage( ws: ServerWebSocket<{ actorId: string; roomId: string }>, msg: string | Buffer,) { const data = JSON.parse(msg.toString());
// Broadcast to everyone in the room ws.publish( ws.data.roomId, JSON.stringify({ type: 'message', userId: ws.data.actor.id, text: data.text, }), );}
export async function wsClose(ws: ServerWebSocket<{ actorId: string; roomId: string }>) { ws.unsubscribe(ws.data.roomId); ws.publish( ws.data.roomId, JSON.stringify({ type: 'left', userId: ws.data.actor.id, }), );}Pass the room ID through the upgrade data:
declare function verifyToken(token: string): string | null;
export async function wsUpgrade(req: Request) { const url = new URL(req.url); const auth = req.headers.get('authorization'); const token = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length) : null; const roomId = url.searchParams.get('room');
if (!token || !roomId) return new Response('Bad Request', { status: 400 }); const userId = verifyToken(token); if (!userId) return new Response('Unauthorized', { status: 401 });
return { actorId: userId, roomId };}Gating room access
Section titled “Gating room access”Use the onRoomSubscribe hook to check permissions before a client joins a room:
export default defineApp({ ws: { endpoints: { '/ws': { upgrade: wsUpgrade, onRoomSubscribe: wsRoomSubscribe, on: { /* ... */ }, }, }, },});declare function checkPermission( userId: string, action: string, resourceType: string, resourceId: string | undefined,): Promise<boolean>;
export async function wsRoomSubscribe( ws: ServerWebSocket<{ actorId: string }>, room: string,): Promise<boolean> { // Return true to allow, false to deny const [resourceType, resourceId] = room.split(':'); return checkPermission(ws.data.actor.id, 'read', resourceType, resourceId);}Connecting from the browser
Section titled “Connecting from the browser”Browser WebSocket clients cannot set custom request headers. Prefer same-origin session cookies for browser connections; keep long-lived bearer tokens out of the URL.
const ws = new WebSocket('wss://api.example.com/ws/chat?room=general');
ws.onopen = () => { ws.send(JSON.stringify({ text: 'Hello everyone' }));};
ws.onmessage = event => { const data = JSON.parse(event.data); console.log(data);};
ws.onclose = () => { // Reconnect with exponential backoff setTimeout(connect, 1000);};Multiple endpoints
Section titled “Multiple endpoints”Define as many endpoints as you need:
export default defineApp({ ws: { endpoints: { '/ws/chat': { upgrade: chatUpgrade, on: { /* ... */ }, }, '/ws/notifications': { upgrade: notifUpgrade, on: { /* ... */ }, }, '/ws/collaboration': { upgrade: colabUpgrade, on: { /* ... */ }, }, }, },});Scaling WebSockets
Section titled “Scaling WebSockets”Bun’s pub/sub is in-process. Multiple instances require a shared WebSocket transport. Configure Redis on ws.transport:
import { createRedisTransport, defineApp } from '@lastshotlabs/slingshot';
export default defineApp({ db: { redis: process.env.REDIS_URL! }, ws: { transport: createRedisTransport({ connection: process.env.REDIS_URL!, }), endpoints: {}, },});With a Redis transport, ws.publish() broadcasts across all instances - clients on any instance receive messages published on any other.
See Horizontal Scaling for the full multi-instance setup.
Entity channel config
Section titled “Entity channel config”Packages authored with definePackage(...) (such as slingshot-community) can declare WebSocket
channel behaviour directly on entity definitions. This wires presence, pub/sub forwarding, and
incoming event handling without hand-writing route or socket logic. createEntityPlugin(...)
remains the lower-level escape hatch that the package compiler uses internally.
Channel fields
Section titled “Channel fields”Each channel declaration (EntityChannelDeclaration) supports these fields:
| Field | Type | Description |
|---|---|---|
auth | 'userAuth' | 'none' | Auth level required to subscribe to this channel |
permission | { requires: string; scope?: ... } | Permission check run during onRoomSubscribe |
presence | boolean | Emit presence_join / presence_leave events when clients connect or disconnect. Requires presence: true on the WS endpoint config. |
forward | { events: string[]; idField: string } | Bus events to forward to room subscribers. idField identifies the entity ID in the event payload that determines which room receives the event. |
receive | { events: string[]; toRoom?: boolean; excludeSender?: boolean } | Client-sent event types the server will relay. Defaults: toRoom: true, excludeSender: true. |
middleware | string[] | Named middleware that runs before subscribe/send |
Example: entity with all fields
Section titled “Example: entity with all fields”import { defineEntity, field } from '@lastshotlabs/slingshot-core';import type { EntityChannelConfig } from '@lastshotlabs/slingshot-core';
const channels: EntityChannelConfig = { channels: { live: { auth: 'userAuth', permission: { requires: 'app:document.read' }, presence: true, forward: { events: ['app:document.updated', 'app:comment.created'], idField: 'documentId', }, receive: { events: ['cursor.move', 'document.typing'], toRoom: true, excludeSender: true, }, }, },};Room names follow the pattern {storageName}:{entityId}:{channelName}. For the example above, a
client subscribes with:
{ "action": "subscribe", "room": "documents:abc123:live" }Forwarding bus events
Section titled “Forwarding bus events”forward.idField tells the entity plugin which field in the bus event payload carries the entity
ID. When app:comment.created fires with { documentId: 'abc123', ... }, the plugin publishes it
to documents:abc123:live.
Receiving client events
Section titled “Receiving client events”receive.events is a whitelist. The server silently drops any client-sent event type not in the
list. Handlers validate that the sender is actually subscribed to the room they name in the payload
before relaying. The expected payload shape for a receive event:
{ "action": "event", "type": "document.typing", "payload": { "room": "documents:abc123:live" } }Self-wiring vs manual wiring
Section titled “Self-wiring vs manual wiring”Package-tier modules (like slingshot-community) self-wire their WS handlers during
setupPost. They read SlingshotContext.wsPublish and SlingshotContext.ws lazily, so no
caller-supplied function references are needed. The endpoint only needs presence: true:
// @skip-typecheckimport { defineApp } from '@lastshotlabs/slingshot';import { createCommunityPackage } from '@lastshotlabs/slingshot-community';
export default defineApp({ packages: [createCommunityPackage({ containerCreation: 'user', wsEndpoint: 'community' })], ws: { endpoints: { community: { presence: true } } },});Custom entity plugins that drop down to createEntityPlugin() directly (the lower-level
escape hatch) expose buildSubscribeGuard and buildReceiveIncoming for manual wiring:
const plugin = createEntityPlugin({ name: 'app', entities: [...], wsEndpoint: 'live', publishFn: publish, getWsState: () => getContext(app).ws,});
export default defineApp({ plugins: [plugin], ws: { endpoints: { live: { presence: true, onRoomSubscribe: plugin.buildSubscribeGuard({ getIdentity: (ws) => (ws as AppWs).user ?? null, checkPermission: (userId, perm, scope) => evaluator.can({ subjectId: userId, subjectType: 'user' }, perm, scope), middleware: {}, }), incoming: plugin.buildReceiveIncoming(), }, }, },});Most apps should prefer the package-tier path; reach for createEntityPlugin() only when you
need the lower-level surface directly.
Call both after all plugins have initialized — they read state set during setupMiddleware.
To self-wire in your own plugin, declare the endpoint in the root ws.endpoints config, then
write to getContext(app).wsEndpoints during setupPost:
async setupPost({ app }) { const ctx = getContext(app); if (!ctx.wsEndpoints) return; const ep = ctx.wsEndpoints['my-endpoint'] ??= {}; ep.onRoomSubscribe = async (ws, room) => checkAccess(ws, room); ep.incoming = myPlugin.buildReceiveIncoming();}Self-wiring plugins should still declare their endpoint in the root ws.endpoints config.
When the framework receives that config from your app.config.ts, Slingshot seeds
ctx.wsEndpoints before setupPost runs. Plugins can then attach incoming,
onRoomSubscribe, or other endpoint hooks during bootstrap, and the runtime later boots
from that same endpoint map.
See also
Section titled “See also”- Realtime with SSE — one-way event streams, simpler client setup
- Horizontal Scaling — multi-instance WebSocket scaling
- Custom Event Bus - event-bus options for SSE and plugin events