packages/wrap/src/realtime/index.ts — import from @donilite/wrap/realtime, a Bun-only subpath (relies on Bun.serve WebSocket topics and, optionally, Bun.RedisClient).

WebSocket endpoint with a channel subscribe/unsubscribe protocol, fan-out through Bun’s native server.publish/ws.subscribe topics, an optional Redis pub/sub relay for multi-instance deployments, and one-line auto-publication of entity events.

createRealtime(options?)

interface RealtimeOptions {
  redisUrl?: string;      // enables the multi-instance relay when set
  topicPrefix?: string;    // default "wrap:rt" — prefix of Bun topics / Redis channels
  authorize?: (c: Context, channel: string) => boolean | Promise<boolean>; // default: every channel is open
}

function createRealtime(options?: RealtimeOptions): Realtime

interface Realtime {
  upgrade: MiddlewareHandler;                                   // Hono handler that upgrades the connection
  websocket: typeof import("hono/bun").websocket;                 // for `Bun.serve({ websocket })`
  attach(server: Server): void;                                    // hands the layer the Bun server for topic fan-out
  publish(channel: string, payload: unknown): Promise<void>;
  bindEntityEvents(map?: (event: EntityEvent) => unknown): () => void; // returns an unbind function
  close(): Promise<void>;
}

Wiring

const realtime = createRealtime({ redisUrl: process.env.REDIS_URL });
app.get("/realtime", realtime.upgrade);
realtime.bindEntityEvents();

const server = app.listen(port, host, { websocket: realtime.websocket });
realtime.attach(server);

app.listen(port, hostname, { websocket }) (see Wrap) forwards websocket straight into Bun.serve and returns the Server handle attach() needs — this is the one thing Wrap deliberately doesn’t own end-to-end, since createRealtime() needs the raw server reference after listen() returns.

Client protocol (JSON over the WebSocket)

→ { "action": "subscribe",   "channel": "entity:examples" }
→ { "action": "unsubscribe", "channel": "entity:examples" }
← { "type": "subscribed", "channel": "..." }
← { "type": "message", "channel": "...", "data": ... }
← { "type": "error", "error": "forbidden" | "invalid JSON" | "unknown action", "channel"?: "..." }

authorize(c, channel) runs on every subscribe — return false to reject with a forbidden error frame instead of subscribing the socket to that topic. There is no per-message authorization beyond the subscribe gate; once subscribed, a client receives every message published on that channel.

publish(channel, payload)

Publishes locally via server.publish(topic, ...) to every subscriber on this instance, and — if redisUrl was given — relays the message through Redis pub/sub so other instances’ subscribers receive it too. Each instance tags its own outgoing relay messages with a random instanceId and ignores relay messages carrying its own id, avoiding an echo loop. Calling publish() before attach(server) logs a warning and drops the local publish (the Redis relay side still fires if configured) — attach() must run before any publish() call that needs local delivery.

bindEntityEvents(map?)

bindEntityEvents(map?: (event: EntityEvent) => unknown): () => void

Subscribes to every entity event (onEntityEvent("*", ...), see Registry — entity events) and republishes each one on entity:<table> — every repository write in the app becomes a realtime message with zero extra code per feature. map transforms the event payload before publishing (e.g. to strip sensitive fields); omitted, the raw EntityEvent is published as-is. Returns the onEntityEvent unsubscribe function.

close()

Closes the Redis publisher/subscriber connections, if any were opened. Does not close the underlying Bun.serve server — that’s the app’s own server.stop() to call, since Realtime only owns the Redis side of its own lifecycle.


This site uses Just the Docs, a documentation theme for Jekyll.