Skip to content

The strictly typed, opinionated data bus for TypeScript.

One contract for every pattern on the wire — requests, events, and subscriptions — with end-to-end types and zero codegen. Run the same code over WebSocket, HTTP, or libp2p/WebRTC — the transport is one line — on a single node or a cluster.

pnpm add @super-line/core @super-line/server @super-line/client @super-line/transport-websocket zod

A real two-node cluster runs on this page — watch a message cross the bus
contract.ts + client.ts
// one contract — imported by both sides
const chat = defineContract({
  shared: {
    clientToServer: {
      send: {
        input:  z.object({ text: z.string() }),
        output: z.object({ id: z.string() }),
      },
    },
    serverToClient: {
      message: {
        payload: z.object({ text: z.string() }),
      },
      online: {
        payload: z.object({ count: z.number() }),
        subscribe: true,
      },
    },
  },
  roles: { user: {} },
})

// client — typed end to end, zero codegen
await client.send({ text: 'hi' })   // req/res
client.on('message', render)        // event
client.subscribe('online', setOnline) // topic

Today, realtime is a glue job.

A connection from ws. An EventEmitter for local events. Redis pub/sub, hand-wired, when it has to cross processes. Correlation IDs and ack callbacks for request/response. A job runner and its own datastore the moment work has to outlive the connection that asked for it. Five moving parts, none of them typed across the wire — re-assembled on every project.

  • wsraw transport
  • EventEmitterlocal events
  • redispub/sub fan-out
  • ack gluereq/res by hand
  • bullmqbackground jobs & cron
super-lineone contract · one connection · strictly typed

Rename a field. The other side stops compiling.

The contract is one object both ends import, so an event name or payload can't drift between client and server — change it in one place and TypeScript flags every call that no longer fits.

And types aren't trust. Every inbound message is validated against the same schema at runtime, so even an untyped peer can't slip a bad payload past the server.

server.ts
srv.implement({
  shared: {
    // `text` is validated before your handler runs
    send: async ({ text }, ctx) => {
      srv.room('lobby').broadcast('message', { text })
      return { id: crypto.randomUUID() } // typed reply
    },
  },
})

New Collections

State that persists — and converges.

The same contract that types your messages also declares collections — typed rows you filter and subscribe to in subsets, and CRDT documents whose concurrent edits merge. Every write is schema-validated at the server before it commits.

super-line is the server-authoritative sync source: it owns the data, enforces row-level security, and streams each client exactly the slice it's allowed to see. One node or a cluster, it converges.

@super-line/collections-memory · -sqlite · -pglite — plus CRDT tiers. Collections →

contract.ts + client.ts
// the SAME contract declares state: rows + a CRDT doc
const api = defineContract({
  collections: {
    messages: { schema: Message, key: 'id' },
    canvas:   { schema: Canvas, crdt: { mode: 'document' } },
  },
  roles: { user: {} },
})

// a live, filtered row-set — server-authoritative
const sub = client.collection('messages')
  .subscribe({ filter: eq('room', 'lobby') })

// …and a document whose edits merge everywhere
const doc = client.collection('canvas').open('board')
doc.update({ title: 'hi' }) // converges across tabs + nodes

New Queue plugin

Work that outlives the connection.

Some work can't finish inside a request. Declare a queue and its worker and it becomes a durable, at-least-once job: typed input and result, retries, leases, declarative concurrency — and cron, when it should just happen every morning.

Jobs are rows in the same collections as the rest of your state, so a job and the row that caused it commit together — no second datastore, no separate worker deployment. Point it at Postgres and the whole cluster coordinates.

@super-line/plugin-queueQueues & workers →

queue.ts
// the work and its worker, declared once
const jobs = queue({
  queues: {
    sendEmail: {
      input:  z.object({ to: z.email() }),
      result: z.object({ messageId: z.string() }),
      concurrency: 3, // enforced by durable slot rows
      worker: async ({ to }, { signal }) => ({
        messageId: await sendEmail(to, { signal }),
      }),
    },
  },
})

// durable: survives a restart, retries, at least once
await jobs.enqueue('sendEmail', { to: 'ada@example.com' })

// …or every morning at 09:00, cluster-wide
await jobs.schedules.create({
  queue: 'sendEmail',
  cron: '0 9 * * *',
  input: { to: 'ada@example.com' },
})

New Chat plugin

Watch a human and an AI agent share one contract.

Whole domains drop onto your contract as plugins. chatContract() adds channels, membership, and messages — every mutation server-authoritative and hookable — and chatAgentTools() hands that same typed surface to an LLM over its own connection, so the server authorization-checks every move. The agent is just another user on the bus.

contract.ts + server.ts
// one contract — merge whole domains as plugins
const app = defineContract({
  roles: { user: {} },
  plugins: [authContract(), chatContract()],
})

// server — each plugin owns its policies + handlers
plugins: [authKit.plugin, chatKit.plugin],

// an AI agent is just a user — on the same wire,
// with the same typed surface, server-authorized
const agent = new ToolLoopAgent({
  model: 'anthropic/claude-sonnet-5',
  tools: chatAgentTools(client), // its own connection
})
# ask-ai3 online · you are ada

connecting a live super-line instance…

That panel is live: the real plugin running in this tab over the loopback transport — every message you send is a typed request, and the agent replies through the same server-authoritative API. The full app (with a real LLM agent) is examples/collections-chat. Chat plugin →

New Channel resources

Three faces. One server.

In examples/chat-supervisor, a human and a Mastra supervisor agent co-edit one CRDT canvas through a chat channel — in the browser, in a full terminal cockpit, and as a headless JSONL line protocol a script can drive. Same contract, same hooks — three renderers.

chat-supervisor · terminal cockpit
Terminal recording of the chat-supervisor cockpit: in the chat pane the supervisor agent streams a delegation card with reasoning and tool calls, while the sticky notes it creates land on the canvas pane beside it and the human drags them from the keyboard

The cockpit mounts the same React hooks the web app uses — useMessages, useChannelResources, the live doc handle — under a terminal renderer, and pnpm tui --json strips the UI entirely. The example → · The cockpit →

Pluggable transports

Same app. Any wire.

super-line splits what travels — your typed contract — from how it travels. The same server, the same client, the same handlers run over a WebSocket, an HTTP/SSE stream, or a libp2p/WebRTC peer connection. The transport is one line; everything above it is identical.

@super-line/transport-websocket
// one client — identical on every wire
const client = createSuperLineClient(chat, {
  transport: webSocketClientTransport({ url }), // ← the only line that changes
  role: 'user',
})
await client.send({ text: 'hi' }) // same call, every wire
client → websocketsend({ text: 'hi' })… on the wire

Real: examples/transports — one server mounts WebSocket, HTTP and libp2p at once; three clients call the same echo, each over a different wire.

Transport — the client ↔ server wire this section

WebSocketHTTPlibp2ploopback

Adapter — the server ↔ server fan-out next ↓

Redislibp2pRabbitMQZeroMQ

Two nodes, one bus

A real super-line cluster, running in this tab: two server nodes joined by one adapter, two subscriber clients each. React on any client — it fans out to that node's other client and crosses the bus to the far node. Sever the bus and cross-node delivery stops dead, while each node keeps serving its own.

node a
client a1
client a2
node b
client b1
client b2
live·2 nodes · 4 subscribers·0 reactions·0 crossed the bus

See the whole network.

Mount the inspector() plugin and point Control Center at any node. It draws your live topology, every connection with its ctx, the running contract, and a streaming event feed — cluster-wide, with no instrumentation to add.

npx @super-line/control-center

super-line · Control Center

Opinionated, on purpose: the server is in charge.

super-line takes three positions and holds them — so you don't re-litigate them per feature.

  1. The contract is the source of truth

    One object, split by direction and scoped by role. Types flow to both ends; a cross-role call gets NOT_FOUND.

  2. Nothing on the wire is trusted

    Every inbound message is validated against its schema before a handler ever sees it. Always on, not opt-in.

  3. The server owns rooms & topics

    Clients don't self-join or self-subscribe. Membership and authorization live on the server, where they belong.

One library where you'd otherwise reach for several.

It's a typed distributed event emitter — and req/res, rooms, presence, and a server that's in charge.

Capability comparison of super-line against Socket.IO, tRPC, raw ws, and distributed event-emitter libraries
Capabilitysuper-lineSocket.IOtRPCraw wsdist. emitter
One typed contract (SSOT)yestypes onlypartial — types onlyyesnono
Runtime validationyesnoyesnono
Req/res — both directionsyesack cbspartial — ack cbsc→s onlypartial — c→s onlynono
Events & roomsyesyesnonoeventspartial — events
Topics (pub/sub)yesvia roomspartial — via roomssubspartial — subsnoyes
Pluggable transport (WS · HTTP · libp2p)yesWS + pollpartial — WS + polllink-basedpartial — link-basednono
Cross-node fan-outyesyesnonoyes
Per-role contractsyesnononono
Presence / introspectionclusteryes — clusterroomspartial — roomsnonono
Typed persisted state (rows + CRDT)yesnononono
Durable background jobs & cronyesnononono
Server-authoritativeyespartialnonono

One bus. Every pattern. Any wire.

Requests, events, and subscriptions over one typed connection, zero codegen — on WebSocket, HTTP, or libp2p, with reconnection, presence, and a cluster event bus built in. Swap the wire in one line; add an adapter only when you outgrow a single node.

pnpmpnpm add @super-line/core @super-line/server @super-line/client @super-line/transport-websocket zod

Pre-1.0 — role-scoped contracts, req/res, events, rooms, topics, the cluster event bus, presence, reconnect, collections (typed rows + CRDT documents), durable queues with cluster-wide cron, and plugins (auth, queue, chat, inspector) are implemented and tested — over pluggable transports (WebSocket, HTTP, libp2p, loopback) and pluggable adapters (in-memory, Redis, libp2p, RabbitMQ, ZeroMQ).

Released under the MIT License.