Skip to content

Tutorial 7 · Go multi-node

Tutorials6 · Collaborate on one document7 · Go multi-node

One node has a ceiling. super-line's answer is the adapter: a tiny server↔server fan-out seam that carries rooms, topics, and the cluster event bus across every node — while none of your contract, handlers, policies, or clients change. In this lesson you meet the seam by reading a complete adapter (it fits on this page), run a real two-node cluster in this tab, and learn the one line that swaps it for Redis.

~7 minutesBuilds on Tutorial 2TypeScript · zero codegen

Fan out adapterBus srv.publish / srv.subscribeSever it, live

First, sever a cluster

Two real server nodes, each a full createSuperLineServer with two subscribed clients, joined by one adapter bus. React on any client: it reaches that node's other client and crosses the bus to the far node. Now sever the bus — cross-node delivery stops dead, while each node keeps serving its own clients. Reconnect and the cluster heals.

tutorial 7 · two nodes, one adapter busbooting…
node aadapter → shared bus
client a1
client a2
node badapter → shared bus
client b1
client b2
0 reactions0 crossed the bus
What's real here: two real createSuperLineServer nodes, four real subscribed clients, and a real Adapter implementation (demo-bus.ts, shown on this page) carrying the cross-node fan-out. In-tab substitution: the bus is an in-page object where production uses Redis/RabbitMQ/libp2p — same interface, same behavior.

This is the vocabulary triangle from Tutorial 4, completed: transports carry client↔server bytes, backends store collection rows, and adapters carry node↔node fan-out.

1. Read a whole adapter

The demo's bus isn't a special demo mode — it's an ordinary implementation of core's Adapter interface, the same seam @super-line/adapter-redis and -libp2p implement. Here is its entire source, exactly as the demo above runs it:

ts
import type { Adapter } from '@super-line/core'

// A complete, working adapter — the same seam `@super-line/adapter-redis` or
// `-libp2p` implement — except the "broker" is an object in this page. Each node
// gets ONE DemoAdapter; the bus routes every publish to every subscribed adapter.
// Severing the bus stops cross-node delivery while each node keeps delivering to
// itself (the loopback every real adapter also performs).

export class SeverableBus {
  private channels = new Map<string, Set<DemoAdapter>>()
  linked = true

  subscribe(channel: string, adapter: DemoAdapter): void {
    let set = this.channels.get(channel)
    if (!set) this.channels.set(channel, (set = new Set()))
    set.add(adapter)
  }

  unsubscribe(channel: string, adapter: DemoAdapter): void {
    const set = this.channels.get(channel)
    if (!set) return
    set.delete(adapter)
    if (!set.size) this.channels.delete(channel)
  }

  publish(channel: string, payload: string | Uint8Array, source: DemoAdapter): void {
    for (const adapter of this.channels.get(channel) ?? []) {
      if (!this.linked && adapter !== source) continue // severed: own node only
      adapter.deliver(channel, payload)
    }
  }
}

export class DemoAdapter implements Adapter {
  private handler?: (channel: string, payload: string | Uint8Array) => void
  constructor(private bus: SeverableBus) {}

  subscribe(channel: string): void {
    this.bus.subscribe(channel, this)
  }
  unsubscribe(channel: string): void {
    this.bus.unsubscribe(channel, this)
  }
  publish(channel: string, payload: string | Uint8Array): void {
    this.bus.publish(channel, payload, this)
  }
  onMessage(handler: (channel: string, payload: string | Uint8Array) => void): void {
    this.handler = handler
  }
  deliver(channel: string, payload: string | Uint8Array): void {
    this.handler?.(channel, payload)
  }
}

Four methods: subscribe/unsubscribe a channel, publish a payload, onMessage to receive. Rooms, topics, and the event bus all compile down to channel pub/sub on this seam. A node subscribes to a channel only while it has a local member, and every publish goes through the adapter — which is why severing SeverableBus kills cross-node delivery and nothing else.

2. Give each node the adapter

Each node in the demo is your Tutorial 1 server plus one option:

ts
const srv = createSuperLineServer(contract, {
  transports: [webSocketServerTransport({ server })],
  adapter: new DemoAdapter(bus), // ← the whole clustering story is this line
  authenticate: /* … */,
})

Every node runs the same code — same contract, same implement, same policies. A client can connect to any node (put a load balancer in front); a srv.room(...).broadcast or srv.publish on one node reaches subscribers on all of them.

3. In production: Redis

Swap the in-page bus for a broker and you have the classic deployment shape:

bash
pnpm add @super-line/adapter-redis
bash
npm install @super-line/adapter-redis
ts
import { createRedisAdapter } from '@super-line/adapter-redis'

  adapter: new DemoAdapter(bus), 
  adapter: createRedisAdapter('redis://localhost:6379'), 

Run two copies of your server on different ports behind that one Redis and you've reproduced the demo — for real processes. Prefer broker-less? libp2p gossips peer-to-peer; RabbitMQ and ZeroMQ also ship. Choose an adapter compares them.

4. Servers talking to servers

The adapter also powers the cluster event bus — server-side pub/sub over the same shared topics, with local echo. That's how nodes coordinate work (cache invalidation, job signals, presence aggregation) without a side-channel:

ts
// any node — publish reaches every node's subscribers, clients and servers alike
srv.publish('presence', { room: 'lobby', count: srv.room('lobby').size })

// server-side subscription: fires for a publish from ANY node, including this one
const off = srv.subscribe('presence', (data, meta) => {
  if (meta.from === srv.nodeId) return // self-exclude if you only want remote publishes
  console.log(`node ${meta.from} says ${data.room} has ${data.count}`)
})

And your data layer? Collection writes relay across nodes through the same adapter — while the self-clustering Postgres tier (collections-pglite) gives every node a synced replica of a central database and doesn't need the adapter for row sync at all. See Backends & clustering.

That's the whole path, zero to cluster.

A contract that types everything on the wire · a server that validates and authorizes all of it · clients and React hooks inferred from the same file · collections with row-level security on swappable storage · auth and chat as mergeable plugins · documents that merge under concurrent edits · and now N nodes behind one adapter seam — with the app code unchanged.

What just happened

PieceWhat it does
Adapter (4 methods)The entire server↔server seam: channel pub/sub. You read a full implementation above.
adapter: … server optionJoins the node to the cluster. Everything else is unchanged.
srv.publish / srv.subscribeThe cluster event bus — shared topics, cluster-wide, with local echo and meta.from.
Redis / libp2p / RabbitMQ / ZeroMQProduction adapters behind the same interface as the demo's 50-line bus.

Where to next

You've finished the path. Three deeper builds continue it, each on top of what you just made:

Or go wide: How-to guides for task recipes, Concepts for the model behind the API, Examples for complete apps, and the Control Center to watch a cluster like the demo's — for real deployments.

Released under the MIT License.