Building Relay

Part 1 · Chapter 1.1

The monorepo and the toolchain

You will produce: A running pnpm workspace — TypeScript, lint, and a passing test suite · about 90 minutes including the exercise

Source: SAD — Software Architecture Document · ADR deep dives

Part 0 ended with a sentence we now have to honor: the building begins. This chapter is the first commit — and it is deliberately humble. We will not write a line of chat logic today. We will build the ground that every one of the next forty-odd chapters stands on: one repository, one language, one toolchain, with rules that enforce themselves. By the end you will have a workspace where three commands pass — and you will know exactly why it is one of everything.

The decisions, if you skipped the reasoning

Part 0 produced the paperwork this series builds against. If you read it, this list is a ten-second refresher; if you skipped it, this is your entry ticket — each item is a binding decision with its argument recorded in the chapter named.

  • The product (0.1): Relay is chat infrastructure — an API and SDK product teams embed, not a chat app. The non-goals list is a contract; media files are in (a reversed non-goal, reasons answered), voice calls and E2E encryption are out.
  • The people (0.2): Mai adopts, David approves, Priya operates, Tuan experiences. Resolution order when they collide: Tuan's reliability beats everything.
  • The moments (0.3): three ★s decide the product — Mai's first message, Priya's reconstruction, Tuan's tunnel.
  • The promises (0.4): 224 requirements with IDs, priorities, and verification methods. FR-TEN-05 (tenant isolation) is the most important line in the document.
  • The decisions (0.5): eight drivers distilled from those requirements; fourteen ADRs, immutable once accepted, each naming its rejected alternatives and its undoing.

Today we implement the first of those fourteen: ADR-01.

ADR-01 — why one language is the decision, not the default

The conventional microservices instinct says "right tool per service": Go for the gateway, maybe Rust for hot paths, Node for the dashboard. Relay's SAD records six services — the instinct would hand us three toolchains before we have shipped a message. ADR-01 rejects it, and the deep dive's argument is worth holding in your hands before you type anything.

The decisive observation: the SDK must be TypeScript regardless — FR-SDK-01 targets browsers, Node, and React Native. The WebSocket protocol has two ends, and the frame types, cursor semantics, and idempotency-key logic exist on both. If the server is also TypeScript, that contract lives in one shared package, consumed by the gateway, the API service, and the SDK. In the deep dive's words: a frame type change is one commit, and drift between server and client serialization "becomes a compile error instead of a production incident." If the server were Go, that contract would be maintained twice, forever — by hand, or by codegen machinery that is itself a maintenance surface.

flowchart TB
    proto["@relay/protocol<br/>frame types · cursor semantics ·<br/>idempotency-key logic (one package)"]
    gw["Gateway service"]
    apisvc["API service"]
    sdk["The JS SDK<br/>(browsers · Node · React Native)"]
    proto --> gw
    proto --> apisvc
    proto --> sdk
    note["A frame type change is ONE commit —<br/>drift becomes a compile error,<br/>not a production incident"]
    proto ~~~ note
ADR-01's payoff, drawn: one protocol package feeding the gateway, the API service, and the SDK — the contract has one home, so it cannot drift.

Go's real advantages for a gateway — cheaper sockets, no GC pauses of consequence — are real but not binding at v1 scale; mostly-idle sockets are exactly the workload Node's event loop was designed for. And the polyglot showcase gets the deep dive's sharpest rejection: reviewers with production experience read "five languages, one author" as five half-maintained toolchains and no depth anywhere. Driver D8 — one engineer must run and reason about all of it — makes one stack the only sustainable choice. The reversal condition is on record too: revisit when gateway profiling shows more than 20% of event-loop time in crypto or serialization at target load.

The workspace, from an empty directory

Check your tools, then make the directory. Everything below assumes Node 22+ and pnpm 10+.

mkdir relay-platform && cd relay-platform
git init -b main

The root package.json declares the workspace's identity: the pinned package manager (so every machine resolves dependencies identically), the Node floor, and — most importantly — the three scripts that will gate every chapter of this series:

package.json
{
  "name": "relay-platform",
  "private": true,
  "version": "0.0.0",
  "packageManager": "pnpm@10.33.0",
  "engines": {
    "node": ">=22"
  },
  "scripts": {
    "lint": "eslint .",
    "typecheck": "pnpm -r --if-present typecheck",
    "test": "vitest run"
  },
  "devDependencies": {
    "@eslint/js": "^10.0.1",
    "@types/node": "^26.1.2",
    "eslint": "^10.8.0",
    "prettier": "^3.9.6",
    "typescript": "^5.9.3",
    "typescript-eslint": "^8.65.0",
    "vitest": "^4.1.10"
  }
}

Two details deserve a sentence each. "private": true because this workspace root is never published — packages inside it will be. And the TypeScript version is pinned to the 5.9 line on purpose: at the time of writing, the newest TypeScript major is ahead of what the lint toolchain supports, and a workspace that chases latest-everything spends its mornings on upgrade archaeology. Versions in this file will drift as the series ages — the chapter tag, not the prose, is always the truth.

The workspace map is one file. Two globs, and they are a promise about the future:

pnpm-workspace.yaml
packages:
  - "packages/*"
  - "services/*"

packages/ holds code that other code imports — the protocol package arrives in chapter 1.3. services/ holds the six deployable services from the SAD's service view — the first skeletons arrive in 1.4. Today services/ contains only a .gitkeep, and that is not embarrassing; it is the map matching the architecture document before the buildings exist.

flowchart TB
    root["relay-platform/<br/>package.json · pnpm-workspace.yaml<br/>tsconfig.base.json · eslint.config.mjs · vitest.config.ts"]
    pkgs["packages/"]
    svcs["services/<br/>(empty until 1.4)"]
    config["@relay/config<br/>shared constants + the smoke test<br/>(today)"]
    protocol["@relay/protocol<br/>frame types, error codes<br/>(chapter 1.3)"]
    api["api · gateway · workers…<br/>(chapters 1.4 →)"]
    root --> pkgs
    root --> svcs
    pkgs --> config
    pkgs -.-> protocol
    svcs -.-> api
The workspace at the end of this chapter — one config of each kind at the root, one real package, and the services directory holding a promise.

One strict compiler, one lint config, one runner

The TypeScript baseline lives once, at the root, and every package extends it. Strictness is cheapest on day one — there is no code yet to complain:

tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "noEmit": true
  }
}

strict is the headline, but the two lines after it are the ones future chapters will thank us for: noUncheckedIndexedAccess makes every array and record lookup admit it might miss, and exactOptionalPropertyTypes stops "absent" and "undefined" from blurring — both matter enormously in a protocol package where a missing field and a null field mean different things on the wire.

Lint follows the same one-home rule — a single flat config at the root:

eslint.config.mjs
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
 
// One lint config for the whole workspace (ADR-01's consequence made literal).
export default tseslint.config(
  { ignores: ["**/node_modules/**", "**/dist/**", "**/coverage/**"] },
  eslint.configs.recommended,
  ...tseslint.configs.recommended,
);

And the test runner — Vitest, one config, workspace-wide include patterns:

vitest.config.ts
import { defineConfig } from "vitest/config";
 
// One test runner for the whole workspace (ADR-01's consequence made literal).
export default defineConfig({
  test: {
    include: ["packages/**/src/**/*.test.ts", "services/**/src/**/*.test.ts"],
  },
});

Install the toolchain (this writes the devDependencies you saw above):

pnpm add -Dw typescript@~5.9.0 eslint @eslint/js typescript-eslint prettier vitest @types/node

The first package — and a test that means something

An empty toolchain proves nothing; the checks need something to check. Our first package is @relay/config — the workspace's shared constants, and the designated home for lint and test fragments as packages multiply. To be clear about ownership, because the trap above is watching: the compiler baseline stays at the root as the single source; this package exports the workspace's runtime constants — and its test is the interesting part.

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

Note the extends path: relative, to the root, to the one baseline. The package's source is three constants:

packages/config/src/index.ts
// @relay/config — the workspace's shared constants, and the designated home
// for lint/test fragments as packages multiply. The compiler baseline itself
// lives once at the repository root (tsconfig.base.json); packages extend it
// by relative path — one home per rule, never copies (chapter 1.1's TRAP).
 
export const NODE_VERSION_RANGE = ">=22";
 
export const WORKSPACE_GLOBS = ["packages/*", "services/*"] as const;
 
export const TOOLCHAIN_CHECKS = ["lint", "typecheck", "test"] as const;

And here is the smoke test — not a expect(true).toBe(true) placeholder, but a real assertion: the constants this package exports must agree with the actual manifests in the repository. If the root package.json and @relay/config ever tell different stories, the suite fails:

packages/config/src/index.test.ts
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
 
import { describe, expect, it } from "vitest";
 
import { NODE_VERSION_RANGE, TOOLCHAIN_CHECKS, WORKSPACE_GLOBS } from "./index.js";
 
// The smoke test that makes "tested state" mean something on day one: the
// shared constants must agree with the workspace's real manifests — if the
// root package.json and @relay/config ever tell different stories, this fails.
 
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
 
describe("@relay/config agrees with the workspace manifests", () => {
  const rootPkg = JSON.parse(
    readFileSync(join(repoRoot, "package.json"), "utf8"),
  ) as { engines: { node: string }; scripts: Record<string, string> };
 
  it("pins the same Node version range as the root manifest", () => {
    expect(rootPkg.engines.node).toBe(NODE_VERSION_RANGE);
  });
 
  it("names exactly the toolchain checks the root manifest provides", () => {
    for (const check of TOOLCHAIN_CHECKS) {
      expect(rootPkg.scripts).toHaveProperty(check);
    }
  });
 
  it("lists the same workspace globs as pnpm-workspace.yaml", () => {
    const workspaceYaml = readFileSync(
      join(repoRoot, "pnpm-workspace.yaml"),
      "utf8",
    );
    for (const glob of WORKSPACE_GLOBS) {
      expect(workspaceYaml).toContain(`"${glob}"`);
    }
  });
});

Round out the housekeeping — a .gitignore so build artifacts never reach the history:

.gitignore
node_modules/
dist/
coverage/
*.log
.env*
.DS_Store

— plus a .nvmrc saying 22, a .prettierrc with house style, a services/.gitkeep, and a README that tells a visitor what this repository is and how the chapter tags work. All four are in the tag if you want the exact text.

The gate

Three commands. From here to the end of the series, a chapter is not finished until they pass:

pnpm install
pnpm lint
pnpm typecheck
pnpm test

On this workspace, that last command runs one file, three assertions, and comes back green in well under a second. Small — and load-bearing: the gate now exists, and every future chapter walks through it.

flowchart LR
    code["the chapter's code"]
    lint["pnpm lint<br/>one ESLint config"]
    types["pnpm typecheck<br/>one strict tsconfig"]
    test["pnpm test<br/>one runner (Vitest)"]
    tag["the chapter tag<br/>part1-ch1 · part1-ch2 · …"]
    code --> lint --> types --> test --> tag
The gate every chapter passes from now on: lint, typecheck, test — then and only then, the chapter tag.

Your turn

Part 0's exercises built a parallel project. From this chapter on, the convention changes: the exercise is the build — you construct Relay itself, alongside the chapter, and your artifact is the working state at the end.

So: build the workspace above, from the empty directory, typing rather than pasting where you can stand it. Then verify yourself against the gate:

  1. Do pnpm lint, pnpm typecheck, and pnpm test all pass from a fresh pnpm install?
  2. Delete "lint" from the root scripts and run the tests. Did the smoke test fail? (Put it back.) If yes, your test is real — it noticed reality change.
  3. Change strict to false in a copy of the base tsconfig inside packages/config/ and point the package at it. Feel how easy that was — then delete the copy and re-read the TRAP.

If you are stuck, the tag holds the answer key.

Takeaways

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

  • One language is a decision with an argument, not a default: the SDK must be TypeScript regardless, so a TypeScript server makes the protocol a shared package — drift becomes a compile error, not a production incident (ADR-01).
  • Every rule gets exactly one home: one base tsconfig, one lint config, one test runner. Packages extend; they never copy — copies drift, and drift kills the shared-types payoff.
  • The gate is the format: pnpm lint && pnpm typecheck && pnpm test, green at every chapter tag, from part1-ch1 to the end of the series.
  • A day-one test should assert something true about reality — ours fails if the workspace's manifests and its constants ever disagree.
  • Empty directories can be architecture: services/ holds nothing but a promise that matches the SAD's service view — the map before the buildings.