Skip to content

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.

app.config.ts
// @skip-typecheck
import { 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);
},
},
},
},
},
});

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

Use the onRoomSubscribe hook to check permissions before a client joins a room:

app.config.ts (ws section)
export default defineApp({
ws: {
endpoints: {
'/ws': {
upgrade: wsUpgrade,
onRoomSubscribe: wsRoomSubscribe,
on: {
/* ... */
},
},
},
},
});
app.config.ts
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);
}

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

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: {
/* ... */
},
},
},
},
});

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.

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.

Each channel declaration (EntityChannelDeclaration) supports these fields:

FieldTypeDescription
auth'userAuth' | 'none'Auth level required to subscribe to this channel
permission{ requires: string; scope?: ... }Permission check run during onRoomSubscribe
presencebooleanEmit 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.
middlewarestring[]Named middleware that runs before subscribe/send
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" }

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.

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

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-typecheck
import { 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.