Skip to content

Tutorial 1 · Run a super-line server

Tutorials1 · Run a server2 · 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.

~6 minutesNode 18+TypeScript · zero codegen

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.

tutorial 1 · a live server in this tabnot booted yet

server logs will stream here — press boot the server

What's real here: a real createSuperLineServer with your contract, its actual LogTape diagnostics, real validation. In-tab substitution: the loopback transport carries the bytes instead of WebSocket — the server code is identical on both wires.

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):

bash
mkdir my-line && cd my-line
npm init -y
mkdir src
my-line/
├─ package.json
├─ tsconfig.json
└─ src/
   ├─ contract.ts   # the single source of truth — later imported by BOTH sides
   └─ server.ts     # implements it

2. Install

You need core (the contract), server, a transport, and zod for the schemas. tsx runs TypeScript directly — no build step while you learn.

bash
pnpm add @super-line/core @super-line/server @super-line/transport-websocket zod
pnpm add -D tsx typescript
bash
npm install @super-line/core @super-line/server @super-line/transport-websocket zod
npm install -D tsx typescript
bash
yarn add @super-line/core @super-line/server @super-line/transport-websocket zod
yarn add -D tsx typescript

super-line is ESM-only, so package.json needs "type": "module":

json
{
  "name": "my-line",
  "type": "module",
  "scripts": {
    "server": "tsx src/server.ts"
  }
}
json
{
  "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 a WebSocket shim; 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:

ts
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.

ts
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

bash
npm run server
super-line server on ws://localhost:3000

That'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:

ts
import { enableSuperLineLogging } from '@super-line/core'
enableSuperLineLogging({ level: 'debug' }) // pretty console, secrets redacted

Re-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 wroteWhat 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

Released under the MIT License.