Building Relay

Phần 1 · Chương 1.1

Monorepo và bộ công cụ

Bạn sẽ tạo ra: Một pnpm workspace chạy được — TypeScript, lint, và bộ test xanh · khoảng 90 phút, bao gồm bài tập

Tài liệu gốc: SAD — Tài liệu kiến trúc phần mềm · ADR — phân tích chuyên sâu (tiếng Anh)

Phần 0 khép lại bằng một câu mà giờ chúng ta phải giữ lời: công cuộc xây dựng bắt đầu. Chương này chính là commit đầu tiên — và nó khiêm tốn một cách có chủ đích. Hôm nay chúng ta sẽ không viết một dòng logic chat nào. Chúng ta xây phần nền mà hơn bốn mươi chương sắp tới sẽ đứng lên trên: một repository, một ngôn ngữ, một bộ công cụ, với những quy tắc tự nó thực thi. Đến cuối chương, bạn sẽ có một workspace nơi ba câu lệnh chạy xanh — và bạn hiểu rõ vì sao mọi thứ đều chỉ có một.

Các quyết định, dành cho ai đã bỏ qua phần lập luận

Phần 0 để lại bộ tài liệu mà cả loạt bài này lấy làm chuẩn. Nếu bạn đã đọc, danh sách dưới đây là mười giây ôn lại; nếu bạn bỏ qua, đây là tấm vé vào cửa — mỗi mục là một quyết định ràng buộc, với lập luận được ghi lại trong chương tương ứng.

  • Sản phẩm (0.1): Relay là hạ tầng chat — một API và SDK để các đội sản phẩm nhúng vào, không phải một ứng dụng chat. Danh sách non-goals là một bản cam kết; file media nằm trong phạm vi (một non-goal đã được đảo ngược sau khi mọi lý do được giải đáp), cuộc gọi thoại và mã hóa đầu-cuối nằm ngoài.
  • Con người (0.2): Mai chọn dùng, David phê duyệt, Priya vận hành, Tuan trải nghiệm. Trật tự phân xử khi họ va chạm: độ tin cậy của Tuan thắng tất cả.
  • Khoảnh khắc (0.3): ba dấu ★ định đoạt sản phẩm — tin nhắn đầu tiên của Mai, cú tái dựng của Priya, đường hầm của Tuan.
  • Lời hứa (0.4): 224 yêu cầu với ID, độ ưu tiên và phương pháp kiểm chứng. FR-TEN-05 (cách ly tenant) là dòng quan trọng nhất của cả tài liệu.
  • Quyết định (0.5): tám driver chưng cất từ những yêu cầu ấy; mười bảy bản ADR, bất biến một khi đã accepted, mỗi bản gọi tên các phương án bị bác bỏ và điều kiện sụp đổ của chính nó.

Hôm nay chúng ta hiện thực hóa bản đầu tiên trong số mười bảy: ADR-01.

ADR-01 — vì sao một ngôn ngữ là quyết định, không phải mặc định

Trực giác microservices quen thuộc mách bảo "việc nào công cụ nấy": Go cho gateway, có khi Rust cho những đoạn hot path, Node cho dashboard. Bản SAD của Relay ghi nhận sáu service — trực giác ấy sẽ dúi vào tay chúng ta ba bộ công cụ trước cả khi gửi được một tin nhắn. ADR-01 bác bỏ nó, và lập luận trong bản deep dive đáng để nắm cho chắc trước khi bạn gõ dòng đầu tiên.

Điểm mấu chốt nằm ở một quan sát: SDK đằng nào cũng phải là TypeScript — FR-SDK-01 nhắm đến trình duyệt, Node và React Native. Giao thức WebSocket có hai đầu, và kiểu frame, ngữ nghĩa cursor, logic idempotency key tồn tại ở cả hai. Nếu server cũng là TypeScript, bản giao kèo ấy nằm gọn trong một package dùng chung duy nhất, được gateway, API service và SDK cùng sử dụng. Nói theo đúng lời bản deep dive: đổi một kiểu frame chỉ tốn một commit, và sai lệch giữa serialization của server và client "trở thành lỗi biên dịch thay vì sự cố ngoài production." Nếu server là Go, bản giao kèo đó phải được duy trì hai lần, mãi mãi — bằng tay, hoặc bằng cỗ máy codegen mà bản thân nó cũng là thêm một gánh bảo trì.

flowchart TB
    proto["@relay/protocol<br/>kiểu frame · ngữ nghĩa cursor ·<br/>logic idempotency key (một gói duy nhất)"]
    gw["Gateway service"]
    apisvc["API service"]
    sdk["SDK JavaScript<br/>(trình duyệt · Node · React Native)"]
    proto --> gw
    proto --> apisvc
    proto --> sdk
    note["Đổi một kiểu frame chỉ tốn MỘT commit —<br/>sai lệch trở thành lỗi biên dịch,<br/>không phải sự cố ngoài production"]
    proto ~~~ note
Quả ngọt của ADR-01, vẽ thành hình: một package protocol nuôi cả gateway, API service và SDK — bản giao kèo chỉ có một mái nhà, nên không thể sai lệch.

Những lợi thế thật của Go cho một gateway — socket rẻ hơn, không có khoảng dừng GC đáng kể — là có thật nhưng không mang tính quyết định ở quy mô v1; những socket phần lớn thời gian nằm im chính là loại công việc mà event loop của Node sinh ra để phục vụ. Còn phương án "trình diễn đa ngôn ngữ" nhận lời bác bỏ sắc nhất của bản deep dive: reviewer có kinh nghiệm production đọc "năm ngôn ngữ, một tác giả" thành năm bộ công cụ chăm nửa vời và chẳng có chiều sâu ở đâu cả. Driver D8 — một kỹ sư phải vận hành và hiểu thấu tất cả — khiến một stack duy nhất trở thành lựa chọn bền vững duy nhất. Điều kiện đảo ngược cũng nằm trên giấy: xem lại khi profiling cho thấy hơn 20% thời gian event loop của gateway tiêu vào crypto hoặc serialization ở tải mục tiêu.

Workspace, từ một thư mục trống

Kiểm tra công cụ, rồi tạo thư mục. Mọi thứ bên dưới giả định Node 22+ và pnpm 10+.

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

package.json ở gốc khai báo danh tính của workspace: package manager được ghim phiên bản (để mọi máy phân giải dependency giống hệt nhau), mức Node tối thiểu, và — quan trọng nhất — ba script sẽ đứng gác cho mọi chương của loạt bài này:

package.json
{
  "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"
  }
}

Có ba chi tiết đáng dừng lại một nhịp. "private": true vì bản thân gốc workspace không bao giờ được publish — còn các package bên trong thì có. Phiên bản TypeScript được ghim vào dòng 5.9 một cách có chủ đích: tại thời điểm viết, bản major mới nhất của TypeScript đã đi trước những gì bộ công cụ lint hỗ trợ, và một workspace mải chạy đua theo mọi thứ mới nhất sẽ nướng hết buổi sáng này đến buổi sáng khác vào việc "khảo cổ" các đợt nâng cấp. Và mức sàn Node là >=22.12 chứ không phải con số tròn >=22: một năng lực runtime mà workspace này sẽ tựa vào xuất hiện đúng ở phiên bản đó — chương 1.4 sẽ gọi tên nó khi nhu cầu xuất hiện. Các con số phiên bản trong file này sẽ trôi dần theo tuổi của loạt bài — tag của chương, chứ không phải phần chữ, mới luôn là sự thật.

Giờ hãy nhìn kỹ xem các script của cửa ải thực chất là gì: tất cả đều gọi turbo run. Những câu lệnh bạn gõ không bao giờ đổi — pnpm lint, pnpm typecheck, pnpm test — nhưng thứ chạy bên dưới chúng là một task graph, và đó là một quyết định có hồ sơ riêng (ADR-17), được lập luận đàng hoàng vài mục bên dưới. Trước hết, hãy dựng những mảnh ghép mà task graph ấy sẽ chạy.

Bản đồ workspace là một file duy nhất. Hai dòng glob, và chúng là một lời hứa về tương lai:

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

packages/ chứa code mà code khác import — package protocol sẽ đến ở chương 1.3. services/ chứa sáu service triển khai được từ góc nhìn service của bản SAD — những bộ khung đầu tiên đến ở 1.4. Hôm nay services/ chỉ chứa một .gitkeep, và điều đó không có gì đáng ngượng: đó là tấm bản đồ khớp với tài liệu kiến trúc trước khi những tòa nhà mọc lên.

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/>(trống cho đến chương 1.4)"]
    config["@relay/config<br/>hằng số dùng chung + bài smoke test<br/>(hôm nay)"]
    protocol["@relay/protocol<br/>kiểu frame, mã lỗi<br/>(chương 1.3)"]
    api["api · gateway · worker…<br/>(từ chương 1.4 trở đi)"]
    root --> pkgs
    root --> svcs
    pkgs --> config
    pkgs -.-> protocol
    svcs -.-> api
Workspace ở cuối chương này — mỗi loại cấu hình một bản ở gốc, một package thật, và thư mục services đang giữ một lời hứa.

Một compiler nghiêm ngặt, một cấu hình lint, một test runner

Cấu hình TypeScript nền chỉ có đúng một bản, đặt ở gốc, và mọi package đều kế thừa từ đó. Siết chặt ngay từ ngày đầu là lúc rẻ nhất — vì chưa có dòng code nào để phàn nàn cả:

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 là cái tên nổi bật nhất, nhưng hai dòng ngay sau nó mới là thứ các chương sau này phải cảm ơn: noUncheckedIndexedAccess buộc mọi phép truy cập mảng hay record phải thừa nhận khả năng không có giá trị, còn exactOptionalPropertyTypes không cho "thiếu hẳn trường" và "trường mang giá trị undefined" bị đánh đồng — cả hai đều cực kỳ hệ trọng trong một package protocol, nơi một trường bị thiếu và một trường null mang hai ý nghĩa khác nhau trên đường truyền.

Lint theo đúng nguyên tắc "mỗi luật một mái nhà" — một flat config duy nhất ở gốc:

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,
);

Test runner vẫn là Vitest ở mọi nơi — nhưng hãy để ý thứ vắng mặt: không có cấu hình Vitest nào ở gốc. Mỗi package tự sở hữu một script hai chữ "test": "vitest run", và các mặc định của Vitest tự tìm thấy test trong src của package đó. "Một test runner" chưa bao giờ có nghĩa là một file cấu hình; nó có nghĩa là một công cụ duy nhất — còn thứ chạy script của từng package, theo đúng thứ tự, chính là task graph.

Cài bộ công cụ (lệnh này ghi ra đúng phần devDependencies bạn thấy phía trên):

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

Task graph — ADR-17, và một cái cache bạn được phép tin

Vì sao một workspace mới có một package lại cần đến task runner? Bây giờ thì chưa cần. Nó cần trước thời điểm mọi thứ bắt đầu đau, vì thời điểm ấy rơi đúng vào một chương đã xuất bản: 1.4 trao cho workspace này bước build thật đầu tiên, và một bước build kéo theo một thứ tự build (package protocol phải biên dịch xong trước các service import nó). Script đệ quy trần trụi không có trí nhớ và không có thứ tự — sửa một dòng tài liệu là cả thế giới bị kiểm lại. Câu trả lời của ADR-17 là Turborepo: mỗi task vẫn là một script bình thường của package, turbo run quyết định thứ gì cần chạy và ghi nhớ thứ gì đã chạy rồi. Ngày mai xóa turbo.json đi thì pnpm -r vẫn chạy đúng những script ấy — chậm, và đúng. Chính vì có lối lui ấy mà quyết định này mới an toàn để đưa ra sớm đến vậy.

Cả task graph nằm gọn trong một file:

turbo.json
{
  "$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"
      ]
    }
  }
}

Đọc từ trên xuống là thấy cả tương lai của workspace: builddev chưa có đích để chạy — chúng là phần khung được khai báo trước, như services/ là một thư mục được khai báo trước, và chương 1.4 sẽ lấp đầy cả hai. dependsOn: ["^build"] là luật thứ tự ("build của các dependency phải xong trước tôi"). Lint vẫn nguyên như trước nay — MỘT lần chạy ESLint phủ cả repository, đăng ký thành root task để nó cũng được cache như mọi thứ khác. Còn test khai báo thêm một input trỏ đến một file chưa hề tồn tại: chương 1.2 sẽ tạo compose.yaml, và một bài test sẽ đọc nó. Khai báo input trước khi file ra đời không phải thói tỉ mẩn — đó chính là toàn bộ kỷ luật mà chiếc hộp ngay dưới đây nói tới.

Package đầu tiên — và một bài test có ý nghĩa thật

Một bộ công cụ trống thì chẳng chứng minh được gì; những phép kiểm cần một thứ gì đó để kiểm. Package đầu tiên của chúng ta là @relay/config — các hằng số dùng chung của workspace, và là mái nhà được chỉ định cho các mảnh cấu hình lint và test khi số package nhân lên. Cần vạch rõ ranh giới, vì cái BẪY phía trên vẫn còn đó: cấu hình nền của compiler nằm nguyên ở gốc làm nguồn duy nhất; package này chỉ export các hằng số runtime của workspace — và bài test của nó mới là phần thú vị.

packages/config/package.json
{
  "name": "@relay/config",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "exports": {
    ".": "./src/index.ts"
  },
  "scripts": {
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  }
}

Hai script ấy là toàn bộ "quyền công dân" của package trong task graph: turbo nhận diện task theo tên script, nên chỉ cần script test là package này đã kiểm được, cache được, sắp thứ tự được — không cần đăng ký ở bất cứ đâu.

packages/config/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "include": ["src"]
}

Để ý đường dẫn extends: đường dẫn tương đối, trỏ về gốc, về bản cấu hình nền duy nhất. Mã nguồn của package là ba hằng số:

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.12";
 
export const WORKSPACE_GLOBS = ["packages/*", "services/*"] as const;
 
export const TOOLCHAIN_CHECKS = ["lint", "typecheck", "test"] as const;

Và đây là bài smoke test — không phải một expect(true).toBe(true) chiếu lệ, mà một khẳng định thật: các hằng số package này export buộc phải khớp với các manifest thật trong repository. Nếu package.json ở gốc và @relay/config một ngày kể hai câu chuyện khác nhau, bộ test lập tức báo trượt:

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}"`);
    }
  });
});

Dọn nốt phần việc nhà — một .gitignore để sản phẩm build không bao giờ lọt vào lịch sử:

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

— cộng thêm một .nvmrc ghi 22, một .prettierrc theo nếp nhà, một services/.gitkeep, và một README kể cho khách ghé thăm biết repository này là gì và các tag chương vận hành ra sao. Cả bốn đều nằm trong tag nếu bạn cần đúng từng chữ.

Cửa ải

Ba câu lệnh. Từ đây đến hết loạt bài, một chương chưa được coi là xong chừng nào chúng chưa chạy xanh:

pnpm install
pnpm lint
pnpm typecheck
pnpm test

Trên workspace này, câu lệnh cuối chạy một file, ba phép khẳng định, và báo xanh trong chưa đầy một giây. Chạy nó lần nữa và xem task graph tự kiếm phần cơm của mình:

@relay/config:test: cache hit, replaying logs 11d54803264fc27c
 Tasks:    1 successful, 1 total
Cached:    1 cached, 1 total
  Time:    7ms >>> FULL TURBO

Bảy mili giây, vì không có gì thay đổi và turbo chứng minh được điều đó. Khi bạn muốn lời chứng ấy được kiếm lại thay vì phát lại — trước một cú tag, chẳng hạn — hãy ép nó: pnpm turbo run test --force chạy lạnh lại toàn bộ, và nó buộc phải xanh y như thế. Nhỏ — nhưng là điểm chịu lực: cửa ải giờ đã tồn tại, và mọi chương tương lai đều phải vượt qua nó.

flowchart LR
    code["code của chương"]
    lint["pnpm lint<br/>một cấu hình ESLint"]
    types["pnpm typecheck<br/>một tsconfig nghiêm ngặt"]
    test["pnpm test<br/>một test runner (Vitest)"]
    tag["tag của chương<br/>part1-ch1 · part1-ch2 · …"]
    turbo["turbo run — sắp thứ tự task graph,<br/>cache những gì chứng minh được là đã chạy (ADR-17)"]
    code --> lint --> types --> test --> tag
    turbo -.-> lint
    turbo -.-> types
    turbo -.-> test
Cửa ải mọi chương phải vượt qua từ nay về sau: lint, typecheck, test — rồi mới đến, và chỉ khi đó mới đến, tag của chương.

Đến lượt bạn

Bài tập của Phần 0 xây một dự án song song. Từ chương này trở đi, quy ước thay đổi: bài tập chính là công trình — bạn tự tay dựng Relay, song hành cùng chương, và thành quả của bạn chính là trạng thái chạy được ở cuối chương.

Vậy nên: hãy dựng workspace phía trên, từ thư mục trống, tự gõ thay vì copy-dán ở bất cứ chỗ nào bạn đủ kiên nhẫn. Rồi tự kiểm tra mình trước cửa ải:

  1. pnpm lint, pnpm typecheckpnpm test có cùng chạy xanh từ một lần pnpm install sạch không?
  2. Xóa "lint" khỏi scripts ở gốc rồi chạy test. Bài smoke test có trượt không? (Nhớ đặt lại.) Nếu có — bài test của bạn là thật: nó nhận ra thực tế vừa thay đổi.
  3. Đổi strict thành false trong một bản sao của tsconfig gốc đặt trong packages/config/, rồi trỏ package vào đó. Cảm nhận xem việc ấy dễ đến mức nào — rồi xóa bản sao đi và đọc lại cái BẪY thứ nhất.
  4. Chạy pnpm test hai lần và đọc kết quả lần hai: FULL TURBO. Giờ đánh dấu bài này lại, chờ đến cuối chương 1.2: xóa dòng $TURBO_ROOT$/compose.yaml khỏi turbo.json, sửa một ký tự trong compose.yaml, rồi chạy pnpm test. Nhìn cái cache nói dối bạn bằng màu xanh. Đặt dòng ấy lại, chạy lần nữa, và nhìn nó thú nhận (cache miss, executing). Đó là cái BẪY thứ hai, diễn trực tiếp.

Nếu bạn kẹt, tag của chương đang giữ sẵn đáp án.

Những điều đọng lại

Nếu không đọc gì khác trong chương này, hãy giữ lấy những điều sau:

  • Một ngôn ngữ là một quyết định có lập luận, không phải một mặc định: SDK đằng nào cũng phải là TypeScript, nên server TypeScript biến giao thức thành một package dùng chung — sai lệch trở thành lỗi biên dịch, không phải sự cố ngoài production (ADR-01).
  • Mỗi luật có đúng một mái nhà: một tsconfig nền, một cấu hình lint, một test runner. Các package kế thừa; không bao giờ sao chép — bản sao sẽ trôi dạt, và sự trôi dạt giết chết quả ngọt của kiểu dùng chung.
  • Cửa ải chính là luật định dạng: pnpm lint && pnpm typecheck && pnpm test, xanh ở mọi tag chương, từ part1-ch1 đến cuối loạt bài.
  • Cache là một lời tuyên bố, và lời tuyên bố cần input trung thực (ADR-17): turbo bỏ qua phần việc nó chứng minh được là đã chạy; mỗi file một task đọc đều phải được khai báo, bằng không sẽ có ngày cái cache báo xanh về một thứ nó chưa hề kiểm.
  • Bài test viết từ ngày đầu nên khẳng định một điều có thật về thực tế — bài của chúng ta trượt nếu manifest của workspace và các hằng số của nó một ngày bất đồng.
  • Thư mục trống cũng có thể là kiến trúc: services/ không chứa gì ngoài một lời hứa khớp với góc nhìn service của bản SAD — tấm bản đồ có trước những tòa nhà.