Building Relay

Part 1 · Chapter 1.3

The protocol package

You will produce: The shared wire contract — frame types, error codes, and schemas that reject bad input · about 75 minutes including the exercise

Source: SRS — Software Requirements Specification · SAD — Software Architecture Document

Chapter 1.1 made an argument and drew a picture: one shared package feeding the gateway, the API service, and the SDK, so that a frame type change is one commit and drift between the two ends of the wire "becomes a compile error instead of a production incident." Two chapters of groundwork later, the box in that picture is still empty. Today we fill it. No services exist yet, and that is exactly the point — the contract comes first, and everything built afterwards has to speak it.

The contract comes first

Every distributed system has a wire contract. The only question is whether it is written down once, or reconstructed from reading two codebases and hoping they agree. Relay's documents chose the first path long before this chapter: the SRS declares that "all frames shall be JSON objects carrying a type discriminator and a payload" (EIR-WS-02), names the handshake acknowledgment and its one-second deadline (EIR-WS-03), and requires the whole protocol — reconnection, ordering, backfill — to be documented (EIR-WS-07). The SAD's sequence diagrams already speak in frames: message.send {idem_key, channel, text} going up, message.ack {seq} coming back.

The vocabulary, derived

Almost none of what goes into this package is ours to invent. The documents name the frames; our job is to transcribe them — and to say so explicitly on the rare line where the documents leave a gap.

From the handshake: connection.ack, carrying "the resolved user identity and a resume cursor" (EIR-WS-03). The cursor is ADR-03's per-channel map — { channel_id: highest seq seen } — the same sequence numbers that make resume and deduplication trivial. And because the SAD's resume sequence (§5.2) fetches backfill before sending the ack, the ack is also where truncation lives: any channel whose backfill would exceed 500 messages is flagged so the client refetches history instead of streaming the backlog (FR-RTM-04). That carrier choice — truncation on the ack — is the documents' ordering plus our decision, and we record it as such.

From the send path: message.send {idem_key, channel, text} and message.ack {seq}, exactly as SAD §5.1 draws them. The idempotency key is client-supplied (FR-SDK-06) and deduplicated server-side within 24 hours (FR-MSG-04). The documents fix no key format, so we decide one and record it: a non-empty string of at most 255 characters.

From the event stream: FR-RTM-05 names six real-time event kinds — message creation, edit, deletion, membership change, presence change, typing. The kinds are the SRS's; the type-string spellings are ours, recorded here: message.created, message.updated, message.deleted, membership.changed, presence.changed, typing — following the noun.verb pattern the documents' own connection.ack and message.send already use. The message events carry the message itself, and its shape is derived from the SAD's own messages table (§6.1): id, channel, seq, user, text, created_at — the wire spellings following §5.1's frame line, and the remaining columns (metadata, attachments, edit and tombstone markers) explicitly deferred to the parts that implement them. Presence states are online and offline (FR-RTM-06); typing expires after five seconds and is never persisted (FR-RTM-08).

From the failure paths: an error frame reusing the REST error shape — machine-readable code, human-readable message, a docs_url, an optional field (EIR-API-04). That reuse is our decision; so is what's missing: the constitution's error envelope also carries a request_id, and we defer it deliberately — no gateway exists yet to mint one. It joins the frame in Part 2. One more decision, and the smallest: the SRS requires a server ping every 30 seconds (EIR-WS-04), and we satisfy it with native WebSocket ping/pong control frames rather than protocol frames — browsers answer those automatically, so the vocabulary doesn't need to.

flowchart LR
    client["Client<br/>(SDK, later)"]
    server["Server<br/>(gateway, 1.4 →)"]
    client -- "message.send" --> server
    server -- "connection.ack · message.ack" --> client
    server -- "message.created · message.updated · message.deleted" --> client
    server -- "membership.changed · presence.changed · typing" --> client
    server -- "error {code, message, docs_url}" --> client
    codes["close codes<br/>4001 auth · 4002 protocol ·<br/>4008 quota · 4009 shutdown"]
    server -.-> codes
The wire vocabulary by direction — one frame up, everything else down, and the close codes at the edge of the conversation.

The first runtime dependency

Everything the workspace has installed so far — TypeScript, ESLint, Vitest — is a devDependency: present while we build, absent when code runs. This chapter takes the workspace's first runtime dependency, and it goes in the package that needs it, not at the root:

packages/protocol/package.json
{
  "name": "@relay/protocol",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "exports": {
    ".": "./src/index.ts"
  },
  "scripts": {
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "zod": "^4.4.3"
  }
}
packages/protocol/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "include": ["src"]
}

Two familiar moves and one new one. The tsconfig extends the one base — 1.1's rule, still holding. The dependency is pinned within a major — the same discipline as 1.2's image tags, and the same caveat: versions in this prose will age; the chapter tag holds the exact state. And the dependency is package-local: the root package.json doesn't change, because zod is @relay/protocol's business. Dependencies live where they're used — consumers of this package will get validation through it, which is the whole idea.

The schemas

Create the package and its source. One file for the frames:

mkdir -p packages/protocol/src
packages/protocol/src/frames.ts
import { z } from "zod";
 
// The wire contract, one home (ADR-01). Every frame is a JSON object with a
// `type` discriminator and a `payload` (EIR-WS-02). Schemas are the single
// source of truth: every exported static type is inferred from its schema,
// so the types and the validation cannot drift — there is no second
// definition. Payloads are strict: unknown fields are rejected.
 
/** Per-channel resume cursor: { channel_id: highest seq seen } (ADR-03). */
export const cursorSchema = z.record(z.string(), z.number().int().positive());
 
/** The message on the wire — derived from the SAD §6.1 `messages` columns.
 * Wire spellings follow SAD §5.1's own frame line (`channel`, `seq`).
 * metadata/attachments/edit/tombstone fields arrive with Part 2/4. */
export const messageSchema = z.strictObject({
  id: z.string().min(1),
  channel: z.string().min(1),
  seq: z.number().int().positive(),
  user: z.string().min(1),
  text: z.string(),
  created_at: z.iso.datetime(), // UTC, RFC 3339 (constitution: timestamps)
});
 
/** Server → client on successful handshake (EIR-WS-03, SAD §5.2). Sent after
 * backfill is fetched, so it can also carry the per-channel truncation list
 * (FR-RTM-04) — channels where backfill exceeded 500 and the client must
 * refetch history instead. */
export const connectionAckSchema = z.strictObject({
  type: z.literal("connection.ack"),
  payload: z.strictObject({
    user: z.string().min(1),
    cursor: cursorSchema,
    resume_ok: z.boolean(),
    truncated: z.array(z.string().min(1)),
  }),
});
 
/** Client → server send (SAD §5.1: `message.send {idem_key, channel, text}`).
 * The idempotency key is client-supplied (FR-SDK-06), deduplicated
 * server-side within 24 h (FR-MSG-04). */
export const messageSendSchema = z.strictObject({
  type: z.literal("message.send"),
  payload: z.strictObject({
    idem_key: z.string().min(1).max(255),
    channel: z.string().min(1),
    text: z.string(),
  }),
});
 
/** Server → sender after commit — never before (SAD §5.1, FR-MSG-05). */
export const messageAckSchema = z.strictObject({
  type: z.literal("message.ack"),
  payload: z.strictObject({
    seq: z.number().int().positive(),
  }),
});
 
// The six real-time event kinds (FR-RTM-05). The kinds are the SRS's; the
// `noun.verb` spellings are this chapter's recorded decision, following the
// documents' own connection.ack / message.send naming.
 
export const messageCreatedSchema = z.strictObject({
  type: z.literal("message.created"),
  payload: messageSchema,
});
 
export const messageUpdatedSchema = z.strictObject({
  type: z.literal("message.updated"),
  payload: messageSchema,
});
 
export const messageDeletedSchema = z.strictObject({
  type: z.literal("message.deleted"),
  payload: messageSchema,
});
 
export const membershipChangedSchema = z.strictObject({
  type: z.literal("membership.changed"),
  payload: z.strictObject({
    channel: z.string().min(1),
    user: z.string().min(1),
    change: z.enum(["added", "removed"]),
  }),
});
 
/** Presence states per FR-RTM-06; delivery scope is FR-RTM-07's concern. */
export const presenceChangedSchema = z.strictObject({
  type: z.literal("presence.changed"),
  payload: z.strictObject({
    user: z.string().min(1),
    state: z.enum(["online", "offline"]),
  }),
});
 
/** Expires after 5 s without renewal and is never persisted (FR-RTM-08). */
export const typingSchema = z.strictObject({
  type: z.literal("typing"),
  payload: z.strictObject({
    channel: z.string().min(1),
    user: z.string().min(1),
  }),
});
 
/** Protocol-level error — EIR-API-04's error shape, reused on the socket
 * (this chapter's recorded decision). `request_id` joins in Part 2, when a
 * gateway exists to mint one. */
export const errorFrameSchema = z.strictObject({
  type: z.literal("error"),
  payload: z.strictObject({
    code: z.string().min(1),
    message: z.string().min(1),
    docs_url: z.string().min(1),
    field: z.string().min(1).optional(),
  }),
});
 
/** Every frame either end may legally utter. */
export const frameSchema = z.discriminatedUnion("type", [
  connectionAckSchema,
  messageSendSchema,
  messageAckSchema,
  messageCreatedSchema,
  messageUpdatedSchema,
  messageDeletedSchema,
  membershipChangedSchema,
  presenceChangedSchema,
  typingSchema,
  errorFrameSchema,
]);
 
// The static types ARE the schemas — z.infer, never a hand-written twin.
export type Cursor = z.infer<typeof cursorSchema>;
export type Message = z.infer<typeof messageSchema>;
export type ConnectionAck = z.infer<typeof connectionAckSchema>;
export type MessageSend = z.infer<typeof messageSendSchema>;
export type MessageAck = z.infer<typeof messageAckSchema>;
export type Frame = z.infer<typeof frameSchema>;
 
/** Parse anything the wire delivers. Hostile input is an expected value, not
 * an exception: this returns zod's safeParse result and never throws. */
export function parseFrame(raw: unknown) {
  return frameSchema.safeParse(raw);
}

Read it from the bottom up and the design shows itself. parseFrame returns zod's safeParse result — success with typed data, or failure with a structured error, and never a thrown exception, because on a network boundary malformed input is not exceptional, it is Tuesday. The type exports are all z.infer: the schema is the one definition, and the static type is derived from it, so the two literally cannot disagree. And every payload is a strict object — a frame smuggling an extra field is rejected, which is the same discipline the constitution demands of write endpoints ("unknown fields are rejected").

flowchart TB
    schema["ONE zod schema<br/>messageSendSchema"]
    runtime["runtime validation<br/>parseFrame(raw) →<br/>accepts or rejects, never throws"]
    types["static type<br/>type MessageSend = z.infer&lt;…&gt;<br/>(no hand-written twin)"]
    schema --> runtime
    schema --> types
    note["Types erase at runtime.<br/>Schemas are types that survive —<br/>and they cannot drift apart:<br/>there is no second definition."]
    schema ~~~ note
One schema, two artifacts: the runtime gatekeeper and the static type, derived from the same definition — nothing to drift.

The failure vocabulary

Contracts are not just happy paths. The SRS requires close codes that distinguish "authentication failure, quota exhaustion, server shutdown, and protocol violation" (EIR-WS-06) — two of them already numbered by the documents, two numbered here, and said so:

packages/protocol/src/codes.ts
// Close codes and protocol error codes — the contract's failure vocabulary.
// EIR-WS-06 requires close codes to distinguish authentication failure,
// quota exhaustion, server shutdown, and protocol violation. Two numbers are
// document-fixed (4001: EIR-WS-05; 4009: SAD §7); the other two classes are
// numbered here — chapter 1.3's recorded decision.
 
export const CLOSE_CODES = {
  4001: "invalid or expired token",
  4002: "protocol violation",
  4008: "quota exhausted",
  4009: "server shutdown (drain)",
} as const;
 
export type CloseCode = keyof typeof CLOSE_CODES;
 
// Protocol-level error codes carried by the `error` frame (EIR-API-04's
// shape). A starter registry — endpoints and services add their own codes in
// their chapters; uniqueness is test-enforced from day one.
export const ERROR_CODES = {
  invalid_frame: "the frame failed schema validation",
  unknown_frame_type: "the type discriminator names no known frame",
  unauthorized: "the connection is not authorized for this action",
  rate_limited: "too many frames; slow down and retry",
} as const;
 
export type ErrorCode = keyof typeof ERROR_CODES;

And the public face of the package — one import for everything:

packages/protocol/src/index.ts
// @relay/protocol — the shared wire contract (ADR-01's payoff, chapter 1.3).
// One home for frame schemas, their inferred types, and the failure
// vocabulary. Consumed by the gateway and API service from 1.4, and by the
// SDK in a later part.
 
export * from "./frames.js";
export * from "./codes.js";

Tests that bite

A schema that accepts garbage is worse than no schema — it certifies garbage. So the test suite's job is mostly to say no: for every frame, one valid specimen that parses and round-trips, and a table of near-misses that must every one be rejected.

packages/protocol/src/frames.test.ts
import { describe, expect, it } from "vitest";
 
import { parseFrame } from "./frames.js";
 
// The contract must bite: for every frame, one specimen that parses and a
// table of malformed near-misses that MUST reject. A schema that accepts
// garbage is worse than no schema — it certifies garbage.
 
const message = {
  id: "m1",
  channel: "c1",
  seq: 42,
  user: "u1",
  text: "hello",
  created_at: "2026-08-01T09:00:00.000Z",
};
 
const valid: Record<string, unknown> = {
  "connection.ack": {
    type: "connection.ack",
    payload: { user: "u1", cursor: { c1: 42 }, resume_ok: true, truncated: [] },
  },
  "message.send": {
    type: "message.send",
    payload: { idem_key: "k-1", channel: "c1", text: "hi" },
  },
  "message.ack": { type: "message.ack", payload: { seq: 43 } },
  "message.created": { type: "message.created", payload: message },
  "message.updated": { type: "message.updated", payload: message },
  "message.deleted": { type: "message.deleted", payload: message },
  "membership.changed": {
    type: "membership.changed",
    payload: { channel: "c1", user: "u2", change: "added" },
  },
  "presence.changed": {
    type: "presence.changed",
    payload: { user: "u1", state: "online" },
  },
  typing: { type: "typing", payload: { channel: "c1", user: "u1" } },
  error: {
    type: "error",
    payload: {
      code: "invalid_frame",
      message: "no",
      docs_url: "https://docs.example/errors/invalid_frame",
    },
  },
};
 
describe("every frame parses its valid specimen and round-trips", () => {
  for (const [name, frame] of Object.entries(valid)) {
    it(name, () => {
      const result = parseFrame(frame);
      expect(result.success).toBe(true);
      if (result.success) expect(result.data).toEqual(frame);
    });
  }
});
 
describe("malformed frames reject", () => {
  const rejects: Array<[string, unknown]> = [
    ["not an object", "message.send"],
    ["unknown type discriminator", { type: "message.destroy", payload: {} }],
    ["missing payload", { type: "message.ack" }],
    [
      "missing payload field",
      { type: "message.send", payload: { channel: "c1", text: "hi" } },
    ],
    [
      "wrong primitive (seq as string)",
      { type: "message.ack", payload: { seq: "43" } },
    ],
    ["zero seq", { type: "message.ack", payload: { seq: 0 } }],
    ["negative seq", { type: "message.ack", payload: { seq: -1 } }],
    [
      "empty idem_key",
      { type: "message.send", payload: { idem_key: "", channel: "c1", text: "hi" } },
    ],
    [
      "oversized idem_key",
      {
        type: "message.send",
        payload: { idem_key: "k".repeat(256), channel: "c1", text: "hi" },
      },
    ],
    [
      "unknown extra payload field",
      {
        type: "message.send",
        payload: { idem_key: "k-1", channel: "c1", text: "hi", admin: true },
      },
    ],
    [
      "invalid presence state",
      { type: "presence.changed", payload: { user: "u1", state: "away" } },
    ],
    [
      "non-RFC3339 timestamp",
      { type: "message.created", payload: { ...message, created_at: "yesterday" } },
    ],
  ];
 
  for (const [name, frame] of rejects) {
    it(name, () => {
      expect(parseFrame(frame).success).toBe(false);
    });
  }
});
packages/protocol/src/codes.test.ts
import { describe, expect, it } from "vitest";
 
import { CLOSE_CODES, ERROR_CODES } from "./codes.js";
 
// The failure vocabulary stays coherent: EIR-WS-06's four classes are all
// present, exactly once, with distinct meanings — and error codes never
// collide or go blank as chapters add to the registry.
 
describe("close codes cover EIR-WS-06's four classes", () => {
  it("contains exactly 4001, 4002, 4008, 4009", () => {
    expect(Object.keys(CLOSE_CODES).map(Number).sort()).toEqual([
      4001, 4002, 4008, 4009,
    ]);
  });
 
  it("gives every code a distinct, non-empty meaning", () => {
    const meanings = Object.values(CLOSE_CODES);
    expect(new Set(meanings).size).toBe(meanings.length);
    for (const meaning of meanings) expect(meaning.length).toBeGreaterThan(0);
  });
});
 
describe("error codes stay unique and described", () => {
  it("has no duplicate or empty descriptions", () => {
    const descriptions = Object.values(ERROR_CODES);
    expect(new Set(descriptions).size).toBe(descriptions.length);
    for (const d of descriptions) expect(d.length).toBeGreaterThan(0);
  });
 
  it("uses snake_case machine-readable keys (EIR-API-04)", () => {
    for (const code of Object.keys(ERROR_CODES)) {
      expect(code).toMatch(/^[a-z][a-z_]*$/);
    }
  });
});

Install the dependency and walk the gate:

pnpm install
pnpm lint
pnpm typecheck
pnpm test

Thirty-two tests now — the six from chapters past, and twenty-six that interrogate this contract from both sides. Note what the reject table is really testing: not zod, but our claims. "Unknown fields are rejected" is a sentence until a test proves a smuggled admin: true bounces.

flowchart TB
    proto["@relay/protocol ✓ BUILT<br/>frame schemas · inferred types ·<br/>error + close codes (this chapter)"]
    gw["Gateway service<br/>(1.4 →)"]
    apisvc["API service<br/>(1.4 →)"]
    sdk["The JS SDK<br/>(a later part)"]
    proto -.-> gw
    proto -.-> apisvc
    proto -.-> sdk
    note["1.1 promised it, 1.3 built it:<br/>a frame change is ONE commit —<br/>drift is a compile error,<br/>not a production incident"]
    proto ~~~ note
1.1's picture, two chapters later: the package is solid, and the arrows are about to become imports.

Your turn

The exercise is the build: create packages/protocol from this chapter, typing the schemas yourself. Then interrogate what you built:

  1. Add your own malformed specimen to the reject table — a message.send whose payload is an array, say — and watch the suite catch it. If you can invent a near-miss the schemas accept, you have found a real bug: tighten the schema and keep the specimen as a test.
  2. Hand-write the MessageSend type as a TypeScript interface, then compare it with what z.infer produced (hover it in your editor). Now delete your interface and consider: every field you typed was a chance to disagree with the schema. That deletion is the design.
  3. Change seq to accept any number and run the tests. Two suites object — the reject table here, and nothing else yet. By Part 2, ordering invariants will make that one-character change fail a dozen tests across services. That growing blast radius is the contract doing its job.

If you are stuck, the tag holds the answer key: part1-ch3.

Takeaways

If you read nothing else in this chapter, keep these:

  • The contract comes first — services conform to the protocol package, never the reverse; that is what makes drift a compile error instead of a production incident (ADR-01, paid in full).
  • The vocabulary is derived, not invented: frames, cursors, truncation, and close codes come from the SRS and SAD; the handful of gaps the documents leave are decided out loud and recorded.
  • Schemas are types that survive runtime — every static type is z.infer from the schema that validates it; one definition, nothing to drift.
  • Validation lives with the types: one library, one home, zero per-service copies — the drift disease doesn't return through the back door.
  • Tests must bite: a suite that only parses valid frames certifies nothing; the reject table is where the contract earns its keep.