Tutorial 1 · Run a super-line server
Tutorials → 1 · Run a server → 2 · Connect a typed client
Everything in super-line starts with two files: a contract that declares every interaction in your app, and a server that implements it. In this lesson you write both and boot the server on a WebSocket wire. Nothing connects to it yet — that's the next lesson — but you'll see it running, twice: once in your terminal, and once live in this page.
Declare defineContractImplement srv.implementBoot server.listen
First, see it run
This isn't a video. Press the button and a real createSuperLineServer — the same npm package you're about to install — boots inside this browser tab and streams its own internal diagnostics into the pane. The probe buttons preview what a client will do in Tutorial 2.
server logs will stream here — press boot the server
One substitution makes this possible: in the tab, the server listens on the loopback transport instead of WebSocket. That's not a cheat — it's the first lesson. The wire is a pluggable transport (WebSocket by default; HTTP-SSE, libp2p, and in-memory loopback also ship), and everything you write above the transport line is identical on every wire. Your terminal build below uses WebSocket; the page uses loopback; the server code is the same.
1. Scaffold the project
Create a folder and two source files (the client joins in the next lesson):
mkdir my-line && cd my-line
npm init -y
mkdir srcmy-line/
├─ package.json
├─ tsconfig.json
└─ src/
├─ contract.ts # the single source of truth — later imported by BOTH sides
└─ server.ts # implements it2. Install
You need core (the contract), server, a transport, and zod for the schemas. tsx runs TypeScript directly — no build step while you learn.
pnpm add @super-line/core @super-line/server @super-line/transport-websocket zod
pnpm add -D tsx typescriptnpm install @super-line/core @super-line/server @super-line/transport-websocket zod
npm install -D tsx typescriptyarn add @super-line/core @super-line/server @super-line/transport-websocket zod
yarn add -D tsx typescriptsuper-line is ESM-only, so package.json needs "type": "module":
{
"name": "my-line",
"type": "module",
"scripts": {
"server": "tsx src/server.ts"
}
}{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}Node version. Every package declares
engines.node >= 18. (On Node < 22 the client — next lesson — needs aWebSocketshim; the server does not.)
3. Define the contract
The contract is one plain TypeScript module holding every interaction in the app, split by direction (clientToServer / serverToClient) and scoped by role (a shared base plus one block per role). This one declares all three wire patterns — a request, a pushed event, and a subscribable topic — for a tiny room-chat app:
import * as 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() }) },
},
},
},
})No client imports this yet — and that's the point: the contract exists before either side, and both will be typed by it. See The contract model for roles, directions, and every interaction flavor.
4. Implement it and boot
The server is authoritative: authenticate runs once per connection and fixes its role; every handler receives schema-validated input plus the ctx you returned; rooms are server-controlled membership.
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.publish('presence', { room, count: srv.room(room).size }) // push the shared 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. Run it
npm run serversuper-line server on ws://localhost:3000That's a live server holding a typed surface — requests it validates, an event and a topic it can push — waiting for its first connection.
See its internals, like the demo does
The log pane in the demo above isn't invented — it's super-line's own LogTape diagnostics. Two lines turn them on in your terminal too:
import { enableSuperLineLogging } from '@super-line/core'
enableSuperLineLogging({ level: 'debug' }) // pretty console, secrets redactedRe-run the server with these at the top of server.ts and you'll see the same conn / dispatch categories the in-page pane shows.
You have a running, typed, validating server.
The demo at the top of this page is this exact code on the loopback wire. Boot it, connect the probe, call join('lobby') — the log lines you see (connection accepted, request join) are what your terminal server will emit the moment something connects. Which is the next lesson.
What just happened
| What you wrote | What it does |
|---|---|
defineContract({ shared, roles }) | Declares every interaction once — requests, events, topics — typed and schema-backed. |
transports: [webSocketServerTransport(…)] | Picks the wire. Loopback (the demo), HTTP-SSE, and libp2p plug into the same slot. |
authenticate(h) | Runs at connect, fixes the connection's role, returns the ctx every handler sees. Throw to reject. |
srv.implement({ shared, user }) | Type-checked handlers for each role block. Input arrives already validated. |
srv.room(…) / srv.publish(…) | Server-owned rooms and topic pushes — clients can't broadcast, they can only ask. |
Next: connect to it
A server with nobody to talk to is only half the story. The other half imports the same contract — and gets the entire surface typed for free.
Continue the series
Tutorial 2 · Connect a typed client → — call the request, listen for the event, subscribe to the topic, and watch the server reject an invalid payload.
Or branch off from here
- The contract model — roles, directions, and interaction flavors in depth.
- Choose a transport — WebSocket vs. HTTP-SSE vs. libp2p vs. loopback.
- Authenticate & assign roles — multi-role surfaces and connect-time auth.
- Debug with logs — the logging you just switched on, in depth.