Getting started
Stand up a typed realtime round-trip from an empty folder. You write one contract; the server implements it and the client calls it with full end-to-end inference — no codegen. By the end you'll have a running server and a client that does all three wire patterns at once.
Request send()Event on('message')Topic subscribe('presence')
The wire is pluggable — WebSocket by default, with HTTP (SSE / long-poll) and libp2p also available (see Transports). This guide uses WebSocket; everything above the transport line is identical on every wire.
1. Scaffold the project
Create a folder and three source files. The contract is the one module both sides import — that's what keeps them in sync.
mkdir my-line && cd my-line
npm init -y
mkdir srcYou're building toward this layout:
my-line/
├─ package.json
├─ tsconfig.json
└─ src/
├─ contract.ts # the single source of truth — imported by both sides
├─ server.ts # implements the contract
└─ client.ts # calls it, fully typed2. Install
You need core (the contract), server, client, a transport, and zod for the schemas.
pnpm add @super-line/core @super-line/server @super-line/client @super-line/transport-websocket zod
pnpm add -D tsx typescriptnpm install @super-line/core @super-line/server @super-line/client @super-line/transport-websocket zod
npm install -D tsx typescriptyarn add @super-line/core @super-line/server @super-line/client @super-line/transport-websocket zod
yarn add -D tsx typescriptWe use tsx to run TypeScript directly — no build step while you're learning.
Now wire up the two config files. super-line is ESM-only, so package.json needs "type": "module":
{
"name": "my-line",
"type": "module",
"scripts": {
"server": "tsx src/server.ts",
"client": "tsx src/client.ts"
}
}{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}3. Define the contract
The contract is split by direction (clientToServer / serverToClient) and scoped by role (a shared base plus one block per role). This one file holds every interaction in the app — a request, a pushed event, and a subscribable topic.
import { z } from 'zod'
import { defineContract } from '@super-line/core'
export const chat = defineContract({
shared: {
clientToServer: {
// request: input is validated, output is typed back to the caller
join: { input: z.object({ room: z.string() }), output: z.object({ ok: z.boolean() }) },
},
serverToClient: {
// event: the server pushes this; clients listen with `.on()`
message: { payload: z.object({ room: z.string(), text: z.string(), from: z.string() }) },
// topic: same shape, but `subscribe: true` lets clients `.subscribe()` to it
presence: { payload: z.object({ room: z.string(), count: z.number() }), subscribe: true },
},
},
roles: {
user: {
clientToServer: {
send: { input: z.object({ room: z.string(), text: z.string() }), output: z.object({ id: z.string() }) },
},
},
},
})See The contract for the full model — directions, roles, and every interaction flavor.
4. Implement the server
The server owns rooms and authorization. authenticate runs once per connection and fixes the role; every handler then receives the validated input plus the ctx you returned.
import http from 'node:http'
import { randomUUID } from 'node:crypto'
import { createSuperLineServer } from '@super-line/server'
import { webSocketServerTransport } from '@super-line/transport-websocket'
import { chat } from './contract'
const server = http.createServer() // or hand in your Express / Fastify http.Server
const srv = createSuperLineServer(chat, {
transports: [webSocketServerTransport({ server })],
authenticate: (h) => {
const name = h.query.name // the Handshake: { transport, headers, query, peer?, raw }
if (!name) throw new Error('unauthorized') // throw → rejected at the WS upgrade, no socket
return { role: 'user' as const, ctx: { name } } // ctx is handed to every handler
},
})
srv.implement({
shared: {
join: async ({ room }, _ctx, conn) => {
srv.room(room).add(conn) // membership is server-controlled
srv.forRole('user').publish('presence', { room, count: srv.room(room).size }) // push the topic
return { ok: true }
},
},
user: {
send: async ({ room, text }, ctx) => {
srv.room(room).broadcast('message', { room, text, from: ctx.name }) // → every client.on('message')
return { id: randomUUID() }
},
},
})
server.listen(3000, () => console.log('super-line server on ws://localhost:3000'))5. Write the client
The client imports the same contract, so join, send, on, and subscribe are all inferred — wrong event names and bad payloads are compile errors, not runtime surprises.
import { createSuperLineClient } from '@super-line/client'
import { webSocketClientTransport } from '@super-line/transport-websocket'
import { chat } from './contract'
const client = createSuperLineClient(chat, {
transport: webSocketClientTransport({ url: 'ws://localhost:3000' }),
role: 'user', // narrows the surface to shared ∪ user; verified by authenticate
params: { name: 'ada' }, // carried in the handshake → readable as h.query.name
})
client.on('message', (m) => console.log(`💬 ${m.from}: ${m.text}`)) // event
client.subscribe('presence', (p) => console.log(`👥 ${p.count} online in ${p.room}`)) // topic
await client.join({ room: 'lobby' })
await client.send({ room: 'lobby', text: 'hello, super-line' }) // request → typed { id }
await new Promise((r) => setTimeout(r, 300)) // let the pushes land, then exit
client.close()Node 18 / 20: provide a WebSocket
The client uses the global WebSocket, which exists in browsers and Node 22+. On older Node, install ws and pass it through: webSocketClientTransport({ url, WebSocket }).
6. Run it
Start the server, then the client in a second terminal:
npm run servernpm run clientThe client prints:
👥 1 online in lobby
💬 ada: hello, super-lineThat's a full typed round-trip.
One contract, three wire patterns, end to end. The presence line is a topic the server pushed on join; the ada: … line is an event broadcast from your send request — all over a single connection, with zero codegen.
What just happened
Each call you wrote maps to one of super-line's wire patterns, all sharing one connection and one contract:
| Your client call | Pattern | What it does |
|---|---|---|
await client.send(…) | Request | Validated input in, typed { id } back — like an RPC. |
client.on('message', …) | Event | The server pushes; you listen. Fire-and-forget. |
client.subscribe('presence', …) | Topic | You opt in; the server fans out to every subscriber. |
Rename a field in contract.ts and the other side stops compiling — that's the whole point. And types aren't trust: every inbound payload is re-validated against the schema on the server, so even an untyped peer can't slip a bad message through.
Next steps
- The contract — roles, direction, and the interaction flavors in depth.
- Events & rooms and Topics — the push patterns you just used.
- Roles & auth — give
agent(or admin) connections a different surface. - React — the same contract through typed hooks.
- Scaling & adapters — go multi-node with one extra line.
- API reference — every export, option, and type.