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ốn 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ốn: 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"
  },
  "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"
  }
}

Có hai 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ó. Và 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. 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.

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<br/>tsconfig.base.json · eslint.config.mjs · vitest.config.ts"]
    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,
);

Còn test runner — Vitest, một cấu hình duy nhất, pattern include phủ toàn workspace:

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

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

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"
  }
}
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";
 
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/
*.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. 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 · …"]
    code --> lint --> types --> test --> tag
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.

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.
  • 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à.