Recipes
Patterns that come up in almost every realtime app. They use nothing beyond stream / mutate, the bus and the upgrade hook; the point is how the pieces fit, not new API.
Authenticating the connection
Authenticate once, at the upgrade, and every operation on that socket inherits the
answer. Returning a Response refuses the handshake, so an unauthenticated client never
gets a connection to abuse:
// socket.config.ts
import { defineSocketServer } from 'svelte-ws/config';
export default defineSocketServer({
upgrade: async ({ request }) => {
const session = await sessionFromCookie(request.headers.get('cookie'));
if (!session) return new Response('Unauthorized', { status: 401 });
return { data: { userId: session.userId, roles: session.roles } };
}
}); Handlers read it from ctx.data, typed from this very config:
export const messages = stream(roomSchema, async function* ({ room }, ctx) {
if (!(await canRead(ctx.data.userId, room))) throw socketError('Not allowed');
yield* subscribe(`room:${room}`);
}); Tokens instead of cookies. Browsers can’t set headers on a WebSocket handshake, and the client always connects to the same-origin endpoint with no query string, so a bearer token can’t ride along on the socket itself. Trade it for a cookie scoped to the socket endpoint: an HTTP endpoint checks the token and sets the cookie, and the upgrade reads it like any session:
// src/routes/api/socket-session/+server.ts
export async function POST({ request, cookies }) {
const user = await verifyBearer(request.headers.get('authorization'));
if (!user) return new Response('Unauthorized', { status: 401 });
cookies.set('socket_session', await issueSocketSession(user.id), {
path: '/_events', // sent on the handshake and nowhere else
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 // as long as the token itself stays valid
});
return new Response(null, { status: 204 });
} // socket.config.ts
upgrade: async ({ request }) => {
const id = parseCookies(request.headers.get('cookie')).socket_session;
const user = id && (await lookupSocketSession(id));
if (!user) return new Response('Unauthorized', { status: 401 });
return { data: { userId: user.id } };
}; Call the endpoint once before the first stream or mutation. The cookie has to outlive more than the first handshake: the client reconnects on its own after a network drop, and each reconnect is a fresh upgrade that needs it again.
One caveat: sessions expire, sockets don’t. A connection authenticated an hour ago is
still open. If that matters, re-check inside the handlers that need it, or close the
socket from handleError.
Presence: who is in the room
Presence is just a stream whose cleanup runs when the client goes away. ctx.signal fires on unsubscribe and on disconnect alike, which is the whole trick:
const present = new Map<string, Set<string>>(); // room -> user ids
export const presence = stream(roomSchema, async function* ({ room }, ctx) {
const members = present.get(room) ?? new Set();
present.set(room, members);
members.add(ctx.data.userId);
publish(`presence:${room}`, [...members]);
ctx.signal.addEventListener('abort', () => {
members.delete(ctx.data.userId);
publish(`presence:${room}`, [...members]);
});
yield [...members]; // the joiner sees the room immediately
yield* subscribe<string[]>(`presence:${room}`);
}); A Map works for one process. Across several, keep presence in Redis: a sorted set per
room scored by “last seen”, with members re-added on a heartbeat and stale ones trimmed on
read. That survives the case a Map can’t, a process dying without ever running its
cleanup.
Typing indicators
Ephemeral signals are a mutate that publishes and stores nothing:
export const startTyping = mutate(roomSchema, ({ room }, ctx) => {
publish(`typing:${room}`, { userId: ctx.data.userId, at: Date.now() });
});
export const typing = stream(roomSchema, async function* ({ room }) {
yield* subscribe<{ userId: string; at: number }>(`typing:${room}`);
}); Let them expire on the client instead of sending a “stopped typing” event. A client that disappears mid-keystroke never sends one, and you’d be waiting forever:
<script>
const events = typing({ room });
let typists = $state(new Map());
$effect(() => {
const event = events.current;
if (!event) return;
typists.set(event.userId, event.at);
const timer = setTimeout(() => typists.delete(event.userId), 3000);
return () => clearTimeout(timer);
});
</script> Throttle the mutation to one call every second or so. A keystroke handler firing on every character is a lot of frames for something nobody reads precisely.
Optimistic updates
mutate returns the stored record, and the stream delivers it to everyone, including the
sender. Render your own message immediately, then let the authoritative copy replace it:
<script>
const feed = messages({ room });
let pending = $state([]);
async function submit(text) {
const optimistic = { id: crypto.randomUUID(), text, pending: true };
pending = [...pending, optimistic];
try {
const saved = await send({ room, text, clientId: optimistic.id });
pending = pending.filter((m) => m.id !== optimistic.id);
return saved;
} catch (error) {
// mark it failed rather than dropping it — the user typed that
pending = pending.map((m) => (m.id === optimistic.id ? { ...m, failed: true } : m));
}
}
</script> Echo the client’s own id back from the handler (clientId above) so the incoming message
can be matched against the optimistic one. Without it, deduplication has to guess from text
and timestamps, and gets it wrong exactly when someone sends the same thing twice.
Remember that a mutate in flight when the connection drops rejects with “Connection
lost before the server answered”, yet the server may still have applied it. For anything
that must not happen twice, give the operation an idempotency key (the same clientId does the job) and have the handler ignore a repeat.
Catching up after a reconnect
A reconnect re-subscribes, but the gap in between is yours to fill. Give the stream a cursor and the client sends the last one it saw back as ctx.since:
export const messages = stream(
roomSchema,
async function* ({ room }, ctx) {
// `since` comes from the client: check it before it reaches a query
if (typeof ctx.since === 'string') yield* await messagesAfter(room, ctx.since);
yield* subscribe(`room:${room}`);
},
{ cursor: (message) => message.id }
); Make the replay overlap rather than risk a hole. An id the client already has is cheap to drop; a message it never receives is gone for good.
Rate limiting a connection
Per-connection state is a closure in the upgrade hook: it exists for as long as the socket
does, and disappears with it.
upgrade: () => {
let tokens = 20;
let refilled = Date.now();
return {
data: {
spend() {
const now = Date.now();
tokens = Math.min(20, tokens + ((now - refilled) / 1000) * 5);
refilled = now;
if (tokens < 1) throw socketError('Slow down');
tokens--;
}
}
};
}; Refill by elapsed time rather than a setInterval: there is no per-connection “closed” hook
to clear a timer in, and a timer per socket that nothing stops is a leak with a schedule.
For limits that must hold across processes (and against someone opening fifty sockets),
count in Redis against the user id, not the connection. The per-connection guard rails (maxOperations, maxPayloadBytes,
backpressure) are the blunt instrument underneath this, and they apply whether or not you
add anything.