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; seventeen ADRs, immutable once accepted, each naming its rejected alternatives and its undoing.
Today we implement the first of those seventeen: 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 ~~~ noteGo'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 mainThe 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:
{
"name": "relay-platform",
"private": true,
"version": "0.0.0",
"packageManager": "pnpm@10.33.0",
"engines": {
"node": ">=22.12"
},
"scripts": {
"dev": "turbo run dev",
"lint": "turbo run //#lint:root",
"lint:root": "eslint .",
"typecheck": "turbo run typecheck",
"test": "turbo run test",
"build": "turbo run build"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^26.1.2",
"eslint": "^10.8.0",
"prettier": "^3.9.6",
"turbo": "^2.10.8",
"typescript": "^5.9.3",
"typescript-eslint": "^8.65.0",
"vitest": "^4.1.10"
}
}Three details deserve a sentence each. "private": true because this workspace
root is never published — packages inside it will be. 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.
And the Node floor is >=22.12, not a round >=22: a runtime capability this
workspace will lean on arrives at exactly that version — chapter 1.4 names it
when the need appears. Versions in this file will drift as the series ages —
the chapter tag, not the prose, is always the truth.
Notice what the gate scripts are now: every one of them says turbo run.
The commands you will type never change — pnpm lint, pnpm typecheck,
pnpm test — but what runs underneath them is a task graph, and that is a
decision with its own record (ADR-17), argued properly a few sections down.
First, the pieces the graph will run.
The workspace map is one file. Two globs, and they are a promise about the future:
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 · turbo.json<br/>tsconfig.base.json · eslint.config.mjs"]
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 -.-> apiOne 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:
{
"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:
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,
);The test runner is Vitest everywhere — but note what is absent: there is no
root Vitest config. Each package owns a two-word "test": "vitest run" script
and Vitest's defaults find that package's src tests. "One test runner" was
never about one config file; it is about one tool — and the thing that runs
every package's script, in the right order, is the task graph.
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 turboThe task graph — ADR-17, and a cache you are allowed to trust
Why does a one-package workspace need a task runner? It doesn't — yet. It needs
one before the moment it starts hurting, because that moment lands inside a
published chapter: 1.4 gives this workspace its first real build step, and a
build step brings a build order (the protocol package must compile before
the services that import it). Plain recursive scripts have no memory and no
ordering — a one-line docs change re-checks the world. ADR-17's answer is
Turborepo: every task stays an ordinary package script, turbo run decides
what runs and remembers what already ran. Delete turbo.json tomorrow and
pnpm -r still runs the same scripts — slowly, and correctly. That
degradation path is most of why the decision is safe to make this early.
The whole graph is one file:
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"dependsOn": ["^build"],
"cache": false,
"persistent": true
},
"typecheck": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/compose.yaml"]
},
"//#lint:root": {
"inputs": [
"**/*.{ts,mts,cts,mjs,js}",
"eslint.config.mjs",
"package.json"
]
}
}
}Read it top to bottom and you have the workspace's future: build and dev
have no targets yet — they are declared shape, like services/ is a declared
directory, and chapter 1.4 fills both. dependsOn: ["^build"] is the ordering
rule ("my dependencies' builds before me"). Lint stays what it always was —
ONE ESLint invocation over the whole repository, registered as a root task so
it caches like everything else. And test declares an extra input naming a
file that does not exist yet: chapter 1.2 creates compose.yaml, and a test
will read it. Declaring the input before the file exists is not pedantry — it
is the entire discipline the next box is about.
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.
{
"name": "@relay/config",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
}
}Those two scripts are the package's whole citizenship in the task graph: turbo
discovers tasks by script name, so having a test script is what makes this
package testable, cacheable, and orderable — no registration file anywhere.
{
"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:
// @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.12";
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:
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:
node_modules/
dist/
coverage/
.turbo/
*.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 testOn this workspace, that last command runs one file, three assertions, and comes back green in well under a second. Run it again and watch the graph earn its keep:
@relay/config:test: cache hit, replaying logs 11d54803264fc27c
Tasks: 1 successful, 1 total
Cached: 1 cached, 1 total
Time: 7ms >>> FULL TURBOSeven milliseconds, because nothing changed and turbo can prove it. When you
want the proof re-earned instead of replayed — before a tag, say — force it:
pnpm turbo run test --force re-runs everything cold, and it had better be
just as green. 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 · …"]
turbo["turbo run — orders the graph,<br/>caches what provably ran (ADR-17)"]
code --> lint --> types --> test --> tag
turbo -.-> lint
turbo -.-> types
turbo -.-> testYour 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:
- Do
pnpm lint,pnpm typecheck, andpnpm testall pass from a freshpnpm install? - 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. - Change
stricttofalsein a copy of the base tsconfig insidepackages/config/and point the package at it. Feel how easy that was — then delete the copy and re-read the first TRAP. - Run
pnpm testtwice and read the second output:FULL TURBO. Now bookmark this one for the end of chapter 1.2: delete the$TURBO_ROOT$/compose.yamlline fromturbo.json, edit one character ofcompose.yaml, and runpnpm test. Watch the cache lie to you in green. Put the line back, run again, and watch it confess (cache miss, executing). That is the second TRAP, performed live.
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, frompart1-ch1to the end of the series. - A cache is a claim, and claims need honest inputs (ADR-17): turbo skips what it can prove already ran; every file a task reads gets declared, or the cache will one day be green about something it never checked.
- 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.