The contract
The contract is the single source of truth, imported by both server and client. It has two axes — role (outer) and direction (inner) — and each entry's shape picks an interaction flavor. Understanding those two axes and the flavors they encode is most of what you need to read a super-line codebase.
import * as z from 'zod'
import { defineContract } from '@super-line/core'
export const api = defineContract({
shared: { // every role inherits these
clientToServer: { /* requests */ },
serverToClient: { /* events + topics */ },
},
roles: { // each role sees shared ∪ its own block
user: { clientToServer: {…}, serverToClient: {…} },
agent: { clientToServer: {…}, serverToClient: {…} },
},
})Direction
Within shared and each role block there are two directions:
clientToServer— requests the client may call.serverToClient— events and topics the client may receive.
Direction is encoded as named keys, never positional generics — you can't accidentally swap them, and there's nothing to keep in sync between the two sides. This is the axis that gives Socket.IO users trouble: there, the split lives in interface generics you thread by position; here it lives in the shape of the object.
The five flavors
Each entry's shape decides how it behaves on the wire. There is no separate registry of message kinds to learn — the flavor falls out of what fields the entry has.
| Flavor | Contract entry | Who initiates |
|---|---|---|
| request | clientToServer: { input, output } | client calls, awaits one reply |
| event | serverToClient: { payload } | server pushes to recipients it picks |
| topic | serverToClient: { payload, subscribe: true } | client subscribes; server publishes |
| room | server API (srv.room(...)) | server controls membership; broadcasts a shared event |
A serverToClient entry is an event by default; adding subscribe: true turns it into a topic the client opts into. (Topics fold into serverToClient so there's just one axis to learn.)
A shared topic is also the cluster event bus: the same declaration types server.publish (any node fans out), server.subscribe (in-process, cluster-wide server-side consumers with local echo), and client.subscribe over the wire — one decl, three subscriber kinds. See The cluster event bus.
The recipes for each flavor live in How-to: requests, events & rooms, and topics. This page is only the model.
Roles
Each role is an audience with its own surface. The effective surface for a role is shared ∪ roles[R] — for both requests and events/topics. A user and an agent can have entirely different verbs:
roles: {
user: { clientToServer: { say: { input: z.object({ text: z.string() }), output: z.object({ id: z.string() }) } } },
agent: { clientToServer: { announce: { input: z.object({ text: z.string() }), output: z.object({ id: z.string() }) } } },
}The role does double duty — once at the type level, once at runtime:
- Type-level: a client created with
role: 'agent'only sees the agent surface;agent.say(...)is a compile error. - Runtime: the server resolves the role in
authenticateand rejects any call outsideshared ∪ roles[role]withNOT_FOUND. The role is a real security boundary, not just a typing convenience.
Server-authoritative enforcement
The contract is a claim about what may happen; the server is what makes it true. Types constrain the caller at compile time, but a hostile or buggy client can send anything. So the same contract that types a payload is also the server's runtime gate:
- Every inbound message is validated against its schema before a handler runs — malformed input is rejected automatically, with no hand-written checks.
- Every call is checked against the connection's effective surface — a cross-role call is
NOT_FOUND, because the role was frozen at connect.
This is why the contract lives on both sides but is enforced only on one. See Server-authoritative for why the server owns the boundary, and Roles & auth for how a role is resolved and verified.
Schemas
Any Standard Schema validator works — Zod, Valibot, ArkType. The examples use Zod. The same schema both types the payload and validates it at runtime, which is what keeps the two ends from drifting: there is no second, hand-copied truth to fall out of sync.