Building Relay

Part 1 · Chapter 1.4

Walking skeleton

You will produce: Two running skeleton services — health-checked, request-ID'd, logging structured JSON · about 90 minutes including the exercise

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

Three chapters of Part 1 built ground no user will ever see: a workspace, four stores, a contract. Today something answers back. We stand up the first two of Relay's six services — the API service and the gateway — as a walking skeleton: no business logic, no store connections, nothing a product manager could demo. What they have instead is everything operations will need on the worst day: a health check, a request ID on every response, and structured logs that let one grep tell a request's whole story. Deploy the skeleton before the muscles — because retrofitting a nervous system into a grown body is surgery.

Why these two services first

The SAD's service view names six deployable services, and marks exactly two of them Phase 1. That pairing is not arbitrary — it is the architecture's central division of labor, decided back in the ADRs we read in 0.5 and quoted here from §4.1's own responsibility sheet. The API service "owns all REST semantics" and is "the only service that writes to PostgreSQL — a deliberate single-writer discipline" (ADR-04). The gateway "terminates WebSockets" and forwards writes over internal HTTP — "the gateway never writes to the database" (ADR-05).

Observability from line one

The row in the tutorial plan gives this chapter its second half: health checks, request IDs, structured logs. Each one is a requirement, not a habit.

Request IDs are EIR-API-05, quoted in full because every clause does work: "Every response shall include a unique X-Request-Id header, referenced in all error responses and in the request log." The documents fix uniqueness but not format — we decide crypto.randomUUID() and record it.

Structured logs are NFR-OBS-01: "All services shall emit structured JSON logs including request ID, tenant ID, and correlation ID." Read that honestly and two of its three fields cannot exist yet — there are no tenants until Part 2's data paths, and no correlation until there is more than one hop to correlate (OpenTelemetry arrives with NFR-OBS-02, later). We log request_id for real today and record the other two as deferrals with named arrival points. Faking them would be worse than omitting them.

Why any of this before there is logic to observe? NFR-OBS-06: "Any customer-reported issue shall be traceable from a request ID to complete logs and traces within 5 minutes." That promise is impossibly expensive to retrofit and nearly free to start with — if it starts on day one, which is this day.

The health endpoint itself is our decision, recorded: GET /healthz, the same spelling our compose healthchecks used in 1.2, returning { status: "ok", service, uptime_s }. Ports too: 4000 for the API service, 4001 for the gateway, each overridable via PORT (3000 belongs to nothing in this repo — it is where the tutorial you are reading lives).

flowchart TB
    api["API service ✓ STANDING<br/>/healthz · X-Request-Id · JSON logs<br/>(owns REST; the only Postgres writer — ADR-04)"]
    gw["Gateway service ✓ STANDING<br/>/healthz + protocol advertisement<br/>(terminates WebSockets; never writes — ADR-05)"]
    whk["Webhook dispatcher<br/>(Part 3 →)"]
    ing["Analytics ingester<br/>(Part 5 →)"]
    mws["Media worker<br/>(Part 4 →)"]
    dash["Dashboard<br/>(Part 5 →)"]
    api ~~~ gw
    whk ~~~ ing
    mws ~~~ dash
The six services of the SAD, two now standing — empty of product, full of the properties operations needs on the worst day.

One home for the plumbing

Both services need the same three pieces: the logger, the request-id stamp, the health/404 wiring.

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

One new line in an otherwise familiar tsconfig: erasableSyntaxOnly makes the compiler reject any TypeScript syntax that can't simply be deleted to leave valid JavaScript (enums, namespaces). Hold that thought — it belongs to a story we'll finish below. The services' own tsconfigs are this identical file; we won't print it twice more.

packages/service-kit/src/index.ts
import { randomUUID } from "node:crypto";
import { createServer, type Server } from "node:http";
 
// The operational plumbing every Relay service shares — ONE home, because
// the second copy is where drift starts (chapter 1.4's TRAP; 1.1's lesson
// applied to behavior instead of configuration).
//
// Logs are structured JSON, one object per line (NFR-OBS-01): request_id is
// real from day one; tenant_id and trace/correlation ids are recorded
// deferrals — they join when Part 2's data paths and NFR-OBS-02's tracing
// make them mean something.
 
export type LogSink = (line: string) => void;
 
const stdoutSink: LogSink = (line) => {
  process.stdout.write(line + "\n");
};
 
export interface Logger {
  log(level: "info" | "error", msg: string, fields?: Record<string, unknown>): void;
}
 
/** One JSON object per line; the sink is injectable so tests can assert log
 * structure instead of scraping stdout — observability you can't test rots. */
export function createLogger(service: string, sink: LogSink = stdoutSink): Logger {
  return {
    log(level, msg, fields = {}) {
      sink(
        JSON.stringify({
          time: new Date().toISOString(),
          level,
          service,
          msg,
          ...fields,
        }),
      );
    },
  };
}
 
/** EIR-API-05 fixes uniqueness; the UUID format is chapter 1.4's decision. */
export function newRequestId(): string {
  return randomUUID();
}
 
export interface ServeOptions {
  service: string;
  /** Extra fields merged into the /healthz payload. */
  health: () => Record<string, unknown>;
  logger?: Logger;
}
 
/** Build (but do not start) a service's HTTP server: every response carries
 * X-Request-Id (EIR-API-05), every request logs exactly one structured line
 * carrying the same id (NFR-OBS-06's grep-ability starts here), GET /healthz
 * answers with the service's health payload, and unknown routes get the
 * EIR-API-04 error shape. The docs_url host is a placeholder until the docs
 * site exists — constitution V's reachable-page promise lands with it. */
export function serve(options: ServeOptions): Server {
  const { service, health } = options;
  const logger = options.logger ?? createLogger(service);
  return createServer((req, res) => {
    const requestId = newRequestId();
    const path = req.url ?? "/";
    res.setHeader("X-Request-Id", requestId);
    res.setHeader("content-type", "application/json");
 
    let status: number;
    let body: unknown;
    if (req.method === "GET" && path === "/healthz") {
      status = 200;
      body = { status: "ok", service, ...health() };
    } else {
      status = 404;
      body = {
        code: "not_found",
        message: `no route for ${req.method ?? "?"} ${path}`,
        docs_url: "https://relay.example/docs/errors/not_found",
      };
    }
    res.statusCode = status;
    res.end(JSON.stringify(body));
    logger.log("info", "request", {
      request_id: requestId,
      method: req.method,
      path,
      status,
    });
  });
}

Three details earn their lines. The sink is injectable because logging you can't test rots quietly — our tests will parse log lines, not squint at stdout. The 404 body is EIR-API-04's error shape — code, message, docs_url — with two recorded decisions attached: the not_found code lives with the services until an API chapter owns a REST registry, and the docs_url host is a placeholder until a docs site exists to make constitution V's reachable-page promise true. And serve builds but does not start the server — that separation is what lets tests boot it on an ephemeral port.

The two services

With the kit in place, a service is barely a page:

services/api/package.json
{
  "name": "@relay/api",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/main.ts",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@relay/protocol": "workspace:*",
    "@relay/service-kit": "workspace:*"
  },
  "devDependencies": {
    "tsx": "^4.23.1"
  }
}
services/api/src/main.ts
import { createLogger, serve, type Logger } from "@relay/service-kit";
 
// The API service — SAD §4.1: owns all REST semantics and is the ONLY
// service that writes to PostgreSQL (ADR-04). At walking-skeleton stage it
// is deliberately empty: health, request ids, structured logs. The muscles —
// tenancy, channels, the message write path — grow onto this in Part 2, and
// its REST error envelope is already asserted (in the test beside this file)
// to match @relay/protocol's error payload shape: one error shape, one home.
 
export function createServer(logger?: Logger) {
  return serve({
    service: "api",
    health: () => ({ uptime_s: Math.round(process.uptime()) }),
    ...(logger ? { logger } : {}),
  });
}
 
if (import.meta.main) {
  const port = Number(process.env.PORT ?? 4000);
  const logger = createLogger("api");
  createServer().listen(port, () => {
    logger.log("info", "listening", { port });
  });
}
services/gateway/package.json
{
  "name": "@relay/gateway",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/main.ts",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@relay/protocol": "workspace:*",
    "@relay/service-kit": "workspace:*"
  },
  "devDependencies": {
    "tsx": "^4.23.1"
  }
}
services/gateway/src/main.ts
import { CLOSE_CODES, frameSchema } from "@relay/protocol";
import { createLogger, serve, type Logger } from "@relay/service-kit";
 
// The gateway — SAD §4.1: terminates WebSockets and never writes to the
// database (ADR-05). At walking-skeleton stage no sockets exist yet; instead
// the gateway DECLARES the wire vocabulary it will speak, computed from
// @relay/protocol — never hardcoded, so the advertisement cannot drift from
// the contract. Sessions, JWT verification, and real frames arrive in Part 2.
 
const frames = frameSchema.options.map((option) => option.shape.type.value);
const closeCodes = Object.keys(CLOSE_CODES).map(Number);
 
export function createServer(logger?: Logger) {
  return serve({
    service: "gateway",
    health: () => ({
      uptime_s: Math.round(process.uptime()),
      protocol: { frames, close_codes: closeCodes },
    }),
    ...(logger ? { logger } : {}),
  });
}
 
if (import.meta.main) {
  const port = Number(process.env.PORT ?? 4001);
  const logger = createLogger("gateway");
  createServer().listen(port, () => {
    logger.log("info", "listening", { port });
  });
}

The gateway does one thing beyond health: its health payload advertises the protocol vocabulary it speaks — every frame name and close code, computed at startup from @relay/protocol's actual exports. Not one string is typed by hand, so the advertisement cannot drift from the contract. 1.3 promised the skeleton would speak nothing but the protocol package; an empty skeleton cannot hold conversations yet, but it can put its vocabulary in the window.

Stand it up

Two terminals — honest tooling at this scale; a process manager is machinery we don't need yet:

pnpm --filter @relay/api dev
pnpm --filter @relay/gateway dev

Now interrogate the skeleton:

curl -i localhost:4000/healthz
curl -s localhost:4001/healthz
curl -i localhost:4000/no-such-route

Three things to see with your own eyes. The X-Request-Id header on every response — request it twice, get two different UUIDs. The gateway's protocol block naming all ten frames and four close codes. And in each service's terminal, one JSON log line per request whose request_id matches the header you just received — that pairing is NFR-OBS-06 in miniature: given an id from anywhere, grep finds the request's whole story.

flowchart LR
    req["curl /healthz"]
    svc["service<br/>stamps one fresh UUID"]
    header["response header<br/>X-Request-Id: 639c…e9a"]
    logline["log line (stdout, JSON)<br/>{ …, request_id: 639c…e9a, status: 200 }"]
    grep["grep 639c…e9a *.log<br/>→ the whole story of one request<br/>(NFR-OBS-06: traceable in minutes)"]
    req --> svc
    svc --> header
    svc --> logline
    header --> grep
    logline --> grep
One request, one id, two places: the response header the caller keeps and the log line the operator greps — the same UUID threads both.

Tests that watch the watchers

The suites boot each service on port 0 — an ephemeral port the OS assigns — so tests never collide with running services or each other:

packages/service-kit/src/index.test.ts
import { describe, expect, it } from "vitest";
 
import { createLogger, newRequestId } from "./index.js";
 
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
 
describe("structured logger (NFR-OBS-01)", () => {
  it("emits one valid JSON object per line with the required fields", () => {
    const lines: string[] = [];
    const logger = createLogger("test-svc", (line) => lines.push(line));
    logger.log("info", "hello", { request_id: "r-1" });
 
    expect(lines).toHaveLength(1);
    const parsed = JSON.parse(lines[0]!) as Record<string, unknown>;
    expect(parsed).toMatchObject({
      level: "info",
      service: "test-svc",
      msg: "hello",
      request_id: "r-1",
    });
    expect(Number.isNaN(Date.parse(parsed.time as string))).toBe(false);
  });
 
  it("keeps levels and extra fields intact through the sink", () => {
    const lines: string[] = [];
    const logger = createLogger("test-svc", (line) => lines.push(line));
    logger.log("error", "boom", { status: 500 });
    const parsed = JSON.parse(lines[0]!) as Record<string, unknown>;
    expect(parsed.level).toBe("error");
    expect(parsed.status).toBe(500);
  });
});
 
describe("request ids (EIR-API-05)", () => {
  it("are UUID-shaped and unique", () => {
    const a = newRequestId();
    const b = newRequestId();
    expect(a).toMatch(UUID_RE);
    expect(b).toMatch(UUID_RE);
    expect(a).not.toBe(b);
  });
});
services/api/src/main.test.ts
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
 
import { errorFrameSchema } from "@relay/protocol";
import { describe, expect, it } from "vitest";
 
import { createLogger } from "@relay/service-kit";
 
import { createServer } from "./main.js";
 
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const silent = createLogger("api", () => {});
 
function listen(server: Server): Promise<number> {
  return new Promise((resolve) =>
    server.listen(0, () => resolve((server.address() as AddressInfo).port)),
  );
}
 
describe("api skeleton", () => {
  it("answers /healthz with its shape and a fresh request id per response", async () => {
    const server = createServer(silent);
    const port = await listen(server);
    try {
      const res = await fetch(`http://127.0.0.1:${port}/healthz`);
      expect(res.status).toBe(200);
      const body = (await res.json()) as Record<string, unknown>;
      expect(body).toMatchObject({ status: "ok", service: "api" });
      expect(typeof body.uptime_s).toBe("number");
 
      const id1 = res.headers.get("x-request-id");
      const id2 = (await fetch(`http://127.0.0.1:${port}/healthz`)).headers.get(
        "x-request-id",
      );
      expect(id1).toMatch(UUID_RE);
      expect(id2).toMatch(UUID_RE);
      expect(id1).not.toBe(id2);
    } finally {
      server.close();
    }
  });
 
  it("shapes its 404 exactly like the protocol's error payload (EIR-API-04)", async () => {
    const server = createServer(silent);
    const port = await listen(server);
    try {
      const res = await fetch(`http://127.0.0.1:${port}/no-such-route`);
      expect(res.status).toBe(404);
      const body: unknown = await res.json();
      // One error shape, one home: the REST envelope must parse against the
      // wire contract's error payload schema — alignment by construction.
      const parsed = errorFrameSchema.shape.payload.safeParse(body);
      expect(parsed.success).toBe(true);
      if (parsed.success) expect(parsed.data.code).toBe("not_found");
    } finally {
      server.close();
    }
  });
 
  it("logs exactly one structured line per request, carrying the response's id", async () => {
    const lines: string[] = [];
    const server = createServer(createLogger("api", (line) => lines.push(line)));
    const port = await listen(server);
    try {
      const res = await fetch(`http://127.0.0.1:${port}/healthz`);
      expect(lines).toHaveLength(1);
      const entry = JSON.parse(lines[0]!) as Record<string, unknown>;
      expect(entry).toMatchObject({
        service: "api",
        msg: "request",
        path: "/healthz",
        status: 200,
      });
      expect(entry.request_id).toBe(res.headers.get("x-request-id"));
    } finally {
      server.close();
    }
  });
});
services/gateway/src/main.test.ts
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
 
import { CLOSE_CODES, frameSchema } from "@relay/protocol";
import { describe, expect, it } from "vitest";
 
import { createLogger } from "@relay/service-kit";
 
import { createServer } from "./main.js";
 
const silent = createLogger("gateway", () => {});
 
function listen(server: Server): Promise<number> {
  return new Promise((resolve) =>
    server.listen(0, () => resolve((server.address() as AddressInfo).port)),
  );
}
 
describe("gateway skeleton", () => {
  it("advertises exactly the vocabulary @relay/protocol exports", async () => {
    const server = createServer(silent);
    const port = await listen(server);
    try {
      const res = await fetch(`http://127.0.0.1:${port}/healthz`);
      expect(res.status).toBe(200);
      const body = (await res.json()) as {
        status: string;
        service: string;
        protocol: { frames: string[]; close_codes: number[] };
      };
      expect(body.status).toBe("ok");
      expect(body.service).toBe("gateway");
      // Computed from the package on both sides of this assertion — but one
      // side travelled over HTTP: the advertisement matches the contract.
      const expectedFrames = frameSchema.options.map((o) => o.shape.type.value);
      expect(body.protocol.frames).toEqual(expectedFrames);
      expect(body.protocol.frames).toContain("connection.ack");
      expect(body.protocol.frames).toHaveLength(10);
      expect(body.protocol.close_codes).toEqual(
        Object.keys(CLOSE_CODES).map(Number),
      );
    } finally {
      server.close();
    }
  });
 
  it("carries a request id and answers unknown routes with the shared 404 shape", async () => {
    const server = createServer(silent);
    const port = await listen(server);
    try {
      const res = await fetch(`http://127.0.0.1:${port}/socket-someday`);
      expect(res.status).toBe(404);
      expect(res.headers.get("x-request-id")).toBeTruthy();
      const body = (await res.json()) as Record<string, unknown>;
      expect(body.code).toBe("not_found");
      expect(typeof body.docs_url).toBe("string");
    } finally {
      server.close();
    }
  });
});

Note the API service's second test: its REST 404 must parse against the protocol package's error payload schema. That single assertion is the whole one-error-shape policy, executable — the REST envelope and the WebSocket error frame can never quietly diverge, because a test imports the one and feeds it the other. Both services consume @relay/protocol today, exactly as 1.3 said they would.

Walk the gate:

pnpm install
pnpm lint
pnpm typecheck
pnpm test

Forty tests. No Docker, no fixed ports, and a fourth consecutive chapter where the gate needs nothing but Node.

flowchart LR
    ch1["1.1 workspace<br/>part1-ch1"]
    ch2["1.2 infrastructure<br/>part1-ch2"]
    ch3["1.3 protocol<br/>part1-ch3"]
    ch4["1.4 skeleton<br/>part1-ch4"]
    done["Part 1 ✓<br/>Part 2 grows the muscles:<br/>sessions, sends, ordering"]
    ch1 --> ch2 --> ch3 --> ch4 --> done
Four chapters, four tags, one gate — Part 1 stands complete, and Part 2 has something to grow muscles onto.

Your turn

The exercise is the build: create all three members from this chapter, typing the kit yourself. Then poke the skeleton where it teaches:

  1. Start both services, kill the gateway, and curl the API service — it answers, untroubled. Two processes that fail independently is the entire reason "six services" is a plausible architecture; you just observed the smallest version of it.
  2. Make a dozen mixed requests to both services, pick one X-Request-Id from a response, and grep both terminals for it. One line, one service, the whole story — that is NFR-OBS-06 rehearsed on a system four files big.
  3. Add a temporary /version route to the API service and watch the 404 test stay green but the route go untested. Feel the gap: every route you add from now on owes the suite a test. Delete it (or test it) before moving on.

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

Takeaways

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

  • The skeleton deploys before the muscles — the API/gateway seam is the architecture's most load-bearing line, and it exists (and is tested) before any logic can blur it.
  • Observability starts at line one: request IDs on every response (EIR-API-05), one structured JSON log line per request (NFR-OBS-01), and honest deferrals for the fields that can't exist yet — because NFR-OBS-06's five-minute promise is cheap now and surgery later.
  • Operational plumbing gets one home — the second copy is where drift starts, and plumbing drifts silently until the incident that needed it.
  • Dependencies are decisions with stories: the platform's type stripping fell to a resolver wall at the first cross-package import; tsx is the smallest tool that fixes it, taken deliberately and pinned.
  • One error shape, one home, machine-verified — the REST 404 parses against the protocol's error payload schema, so the two failure vocabularies cannot diverge.