Skip to content

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.

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

FlavorContract entryWho initiates
requestclientToServer: { input, output }client calls, awaits one reply
eventserverToClient: { payload }server pushes to recipients it picks
topicserverToClient: { payload, subscribe: true }client subscribes; server publishes
roomserver 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:

ts
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 authenticate and rejects any call outside shared ∪ roles[role] with NOT_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.

Released under the MIT License.