The pub/sub bus

svelte-ws/pubsub is a standalone channel bus, deliberately not coupled to the WebSocket layer. Import it inside any server code and publish or subscribe at will:

import { stream, mutate } from 'svelte-ws';
import { publish, subscribe } from 'svelte-ws/pubsub';

// a stream (or a native query.live) relays a channel to its client…
export const messages = stream(roomSchema, async function* ({ room }) {
	yield* subscribe(`room:${room}`);
});

// …and a mutate, a remote command, a +server.ts or a webhook publishes to it
export const send = mutate(messageSchema, ({ room, text }) => publish(`room:${room}`, { text }));

One bus, every transport

The active engine is a single instance (pinned to globalThis), so every server context shares it. That makes transports composable:

// chat.remote.ts — a plain HTTP remote command (remote functions are still behind
// `experimental: { remoteFunctions: true }` in the SvelteKit config)
import { command } from '$app/server';
import { publish } from 'svelte-ws/pubsub';

export const sendViaHttp = command(messageSchema, ({ room, text }) => {
	publish(`room:${room}`, { text }); // WebSocket subscribers see it live
});

A message published over HTTP shows up in every WebSocket stream subscribed to the channel. It works the other way around too: a socket mutate can feed an SSE query.live.

API

FunctionPurpose
publish(channel, payload)deliver payload to every subscriber of channel
subscribe<T>(channel)AsyncIterableIterator<T> of payloads until you return
getPubSub() / setPubSub(engine)read / replace the active engine
createInMemoryPubSub()the default engine, explicitly

Subscriptions are per-consumer FIFO queues, so no events are dropped between yields. A channel is reclaimed when its last subscriber leaves.

Ending a subscription

Inside a stream handler, subscribe() ends with the stream: the moment the client unsubscribes or disconnects, the subscription closes, even on a channel nobody publishes to. There is nothing to pass.

Everywhere else (a query.live body, a background job) there is no stream to follow, so pass a signal of your own:

const ac = new AbortController();
for await (const event of subscribe('jobs', { signal: ac.signal })) {
	if (await handle(event)) ac.abort();
}

Without one, the subscription only ends when the channel next publishes. An async generator’s own .return() gets queued behind a yield* delegate’s pending next() rather than interrupting it, so on an idle channel the subscription, and the memory it holds, outlives whoever asked for it. A signal settles the pending read directly.

Swapping the engine

An engine is two methods:

export type PubSubEngine = {
	publish(channel: string, payload: unknown): void | Promise<void>;
	subscribe<T = unknown>(channel: string, options?: SubscribeOptions): AsyncIterableIterator<T>;
};

A custom engine should pass options.signal through (or honour it itself): that is what ends a subscription on an idle channel the moment its client leaves. Inside a stream subscribe() fills it in for you.

Replace it once at server start. The natural place is the init hook of socket.config.ts:

import { defineSocketServer } from 'svelte-ws/config';
import { setPubSub } from 'svelte-ws/pubsub';
import { createRedisPubSub } from '#lib/server/redis-pubsub.js';

export default defineSocketServer({
	init() {
		setPubSub(createRedisPubSub());
	}
});

See Redis pub/sub for a complete engine backed by Bun’s built-in Redis client, with an ioredis variant for Node.

Last updated · Edit this page on GitHub