Skip to content

WebSockets

WebSockets are a first-class core transport in Slingshot.

They are not interchangeable with SSE. Use WebSockets when clients need to send messages back, subscribe to rooms, or participate in presence and recovery flows.

These examples extend the starter app with a blog collaboration endpoint.

app.config.ts
// @skip-typecheck
import { defineApp } from '@lastshotlabs/slingshot';
import { blogPackage } from './blog/package';
export default defineApp({
packages: [blogPackage],
ws: {
endpoints: {
'/ws/blog': {
presence: true,
heartbeat: true,
rateLimit: { windowMs: 10_000, maxMessages: 50, onExceeded: 'close' },
onRoomSubscribe: async (ws, room) =>
room === `post:${ws.data.actor.id}:drafts` || room.startsWith('post:public:'),
incoming: {
'post:typing': {
auth: 'userAuth',
handler: (_ws, payload, ctx) => {
const room = `post:${ctx.actor.id}:drafts`;
ctx.publish(room, { type: 'post:typing', payload });
return { delivered: true };
},
},
'post:subscribePublic': {
auth: 'none',
handler: (_ws, payload, ctx) => {
const postId = (payload as { postId: string }).postId;
ctx.subscribe(`post:public:${postId}`);
return { subscribed: postId };
},
},
},
},
},
},
});

Each endpoint can configure:

  • on.open, on.message, on.close, on.drain
  • onRoomSubscribe
  • incoming named event handlers
  • middleware for incoming events
  • presence
  • heartbeat
  • persistence
  • recovery
  • rateLimit

That is a real runtime surface, not just a thin socket wrapper.

Incoming event handlers get a WsEventContext with:

  • publish(room, data)
  • subscribe(room)
  • unsubscribe(room)
  • socketId
  • actorId
  • endpoint

That is the canonical room-level API for framework-managed WebSocket behavior.

heartbeat: true (or { intervalMs, timeoutMs }) makes the server send WebSocket protocol ping frames on a timer. Browsers answer a ping with a pong automatically, at the protocol layer — clients write no code for this, and a client-side heartbeat is not required.

The contract:

  • Every intervalMs (default 30000), a socket with no outstanding ping is pinged.
  • A socket is closed with 1001 Heartbeat timeout once a ping it was sent has gone unanswered for timeoutMs (default 10000). A socket that has not been pinged yet is never overdue, so timeoutMs may be shorter than intervalMs — the effective detection time is the timeout rounded up to the next tick.
  • One ping is outstanding at a time. A client that has not answered is not re-pinged.

Two things worth knowing when you enable it. The ping traffic is what keeps an idle connection alive through NAT devices that would otherwise reap it. And it protects the server’s view of liveness: when the network drops a connection silently, the close frame may never reach the client, whose socket stays OPEN — so a client that must notice a dead connection needs its own check as well, not just this one.

Presence and recovery are separate concerns:

  • presence tracks connected actors and room membership
  • recovery lets clients reconnect and resume within a configured window

Recovery requires persistence. Slingshot will reject invalid recovery configuration at startup instead of failing later at runtime.

Single-instance room publish works without extra setup. Multi-instance delivery requires a shared WebSocket transport:

app.config.ts (excerpt)
import { defineApp } from '@lastshotlabs/slingshot';
declare const myWsTransport: import('@lastshotlabs/slingshot').WsConfig['transport'];
export default defineApp({
ws: {
transport: myWsTransport,
endpoints: {
'/ws': { presence: true },
},
},
});

This is separate from the event bus. The event bus handles app events. The WebSocket transport handles cross-instance room fan-out.

  • default: one endpoint with incoming, onRoomSubscribe, and room publish
  • customize: add middleware, persistence, and recovery
  • escape hatch: supply a shared transport and let packages self-wire endpoint behavior through the bootstrap endpoint map

Packages can bootstrap endpoint hooks through the root ws config. createApp() exposes the bootstrap endpoint map on context so packages can attach incoming handlers or subscribe guards before the runtime starts the live transport.

That is how package-owned realtime behavior can remain package-owned instead of leaking into the app root.

  • chat and collaborative editing
  • room subscriptions
  • presence
  • low-latency bidirectional workflows

Use Server-Sent Events when the stream is server-to-client only.