bun-monorepo-bootstrap

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Bun workspaces monorepo bootstrap

Bun workspaces monorepo 引导配置

Target layout.
apps/*
are runnable,
packages/*
are source-only (no build step — Bun/Vite consume TypeScript directly through workspace
exports
).
<root>/
├── package.json            # workspaces + aggregate scripts
├── eslint.config.js        # flat config, root-only
├── prettier.config.js      # root-only
├── .prettierignore .gitignore .env.example
├── dev.sh                  # full local stack, port-derived
├── .conductor/settings.toml
├── .vscode/{settings,extensions}.json
├── AGENTS.md CLAUDE.md README.md
├── apps/
│   ├── api/     # Bun + Elysia modular monolith, exports `type App`
│   ├── web/     # Vite + React + Tailwind v4 + coss UI + lucide-react
│   └── worker/  # Bun long-running loop (indexer/outbox/cron)
├── packages/
│   ├── shared/  # zod schemas, env schema+loader, logger, pure domain logic
│   └── db/      # drizzle schema, migrations, seed/backfill scripts
├── edge-worker/ # optional; Cloudflare Worker, NOT a workspace (own lockfile)
└── docs/{adr,runbooks}/
Every workspace that reads env vars carries its own
src/config/env.ts
— one zod schema, parsed at boot, the workspace's only
process.env
reader (§3).
Split rule: anything imported by two workspaces lives in
packages/
.
db
may import
shared
;
shared
imports nothing local. Apps never import each other.
References — read before scaffolding the matching part:
  • references/frontend-patterns.md
    — normative spec for
    useRoutes.ts
    ,
    lib/api/*
    wrappers, the React context-module pattern, hook-vs-context, and Better Auth wiring.
  • references/code-comments.md
    — normative spec for file headers, doc comments on every export, decision comments, and the
    TODO
    /
    ASSUMPTION
    /
    CONFIRM
    /
    FIXME
    tags used to record deliberate gaps.
  • references/seo-edge-worker.md
    — how a client-rendered SPA gets real social cards and crawler-visible metadata:
    index.html
    floor,
    <Seo>
    , the API's
    share.html
    +
    og-image.png
    , and the Cloudflare Worker that routes crawlers to them.
目标目录结构。
apps/*
可运行,
packages/*
仅包含源码(无需构建步骤——Bun/Vite 通过 workspace 的
exports
直接消费 TypeScript)。
<root>/
├── package.json            # workspaces + aggregate scripts
├── eslint.config.js        # flat config, root-only
├── prettier.config.js      # root-only
├── .prettierignore .gitignore .env.example
├── dev.sh                  # full local stack, port-derived
├── .conductor/settings.toml
├── .vscode/{settings,extensions}.json
├── AGENTS.md CLAUDE.md README.md
├── apps/
│   ├── api/     # Bun + Elysia modular monolith, exports `type App`
│   ├── web/     # Vite + React + Tailwind v4 + coss UI + lucide-react
│   └── worker/  # Bun long-running loop (indexer/outbox/cron)
├── packages/
│   ├── shared/  # zod schemas, env schema+loader, logger, pure domain logic
│   └── db/      # drizzle schema, migrations, seed/backfill scripts
├── edge-worker/ # optional; Cloudflare Worker, NOT a workspace (own lockfile)
└── docs/{adr,runbooks}/
每个读取环境变量的 workspace 都有自己的
src/config/env.ts
——每个文件包含一个 zod schema,在启动时解析,并且是该 workspace 唯一读取
process.env
的位置(§3)。
拆分规则:任何被两个 workspace 导入的内容都放在
packages/
中。
db
可以导入
shared
;
shared
不导入任何本地模块。Apps 之间绝不互相导入。
参考资料——搭建对应部分前务必阅读:
  • references/frontend-patterns.md
    ——
    useRoutes.ts
    、
    lib/api/*
    包装器、React context-module 模式、hook 与 context 的选择,以及 Better Auth 接入的规范说明。
  • references/code-comments.md
    ——文件头、每个导出项的文档注释、决策注释,以及用于记录有意保留缺口的
    TODO
    /
    ASSUMPTION
    /
    CONFIRM
    /
    FIXME
    标签的规范说明。
  • references/seo-edge-worker.md
    ——客户端渲染的 SPA 如何获得真实的社交卡片和爬虫可见元数据:
    index.html
    基础配置、
    <Seo>
    、API 的
    share.html
    与
    og-image.png
    ,以及将爬虫请求路由到这些资源的 Cloudflare Worker。

1. Root

1. 根目录

package.json
— scope
@<proj>
.
check
runs everything and aggregates failures instead of short-circuiting, so one run reports all breakage.
json
{
  "name": "@<proj>/root",
  "private": true,
  "type": "module",
  "workspaces": ["apps/*", "packages/*"],
  "scripts": {
    "lint": "bun run --workspaces lint:fix",
    "eslint": "bun run --workspaces lint",
    "format": "bun run --workspaces format",
    "format:check": "bun run --workspaces format:check",
    "typecheck": "bun run --workspaces typecheck",
    "test": "bun run --filter '@<proj>/shared' --filter '@<proj>/api' test",
    "check": "F=0; bun run lint || F=1; bun run format || F=1; bun run typecheck || F=1; bun run test || F=1; exit $F",
    "db:generate": "bun run --filter '@<proj>/db' db:generate",
    "db:migrate": "bun run --filter '@<proj>/db' db:migrate",
    "dev:api": "bun run --filter '@<proj>/api' dev",
    "dev:web": "bun run --filter '@<proj>/web' dev"
  },
  "devDependencies": {
    "@eslint/js": "latest",
    "@types/bun": "latest",
    "eslint": "latest",
    "eslint-config-prettier": "latest",
    "eslint-plugin-react-hooks": "latest",
    "eslint-plugin-simple-import-sort": "latest",
    "eslint-plugin-unused-imports": "latest",
    "globals": "latest",
    "prettier": "latest",
    "prettier-plugin-tailwindcss": "latest",
    "typescript": "latest",
    "typescript-eslint": "latest"
  }
}
  • bun run --workspaces <s>
    fails if any workspace lacks
    <s>
    . Give every workspace
    lint
    ,
    lint:fix
    ,
    format
    ,
    format:check
    ,
    typecheck
    .
  • test
    is
    --filter
    ed, not
    --workspaces
    : only list workspaces that own tests.
  • Cross-workspace deps use
    "@<proj>/shared": "workspace:*"
    .
eslint.config.js
(root, flat — no per-workspace configs):
js
import eslint from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier";
import reactHooks from "eslint-plugin-react-hooks";
import simpleImportSort from "eslint-plugin-simple-import-sort";
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
import tseslint from "typescript-eslint";

export default tseslint.config(
  { ignores: ["**/node_modules/**", "**/dist/**", "**/coverage/**", "apps/web/public/**"] },
  eslint.configs.recommended,
  ...tseslint.configs.recommended,
  eslintConfigPrettier,
  {
    rules: {
      "padding-line-between-statements": [
        "error",
        { blankLine: "always", prev: "*", next: "return" },
      ],
    },
  },
  {
    files: ["apps/**/*.{ts,tsx}", "packages/**/*.ts"],
    plugins: { "simple-import-sort": simpleImportSort, "unused-imports": unusedImports },
    rules: {
      "simple-import-sort/imports": "error",
      "simple-import-sort/exports": "error",
      "@typescript-eslint/no-unused-vars": "off",
      "unused-imports/no-unused-imports": "error",
      "unused-imports/no-unused-vars": [
        "warn",
        { vars: "all", varsIgnorePattern: "^_", args: "after-used", argsIgnorePattern: "^_" },
      ],
      "@typescript-eslint/ban-ts-comment": [
        "error",
        { "ts-nocheck": "allow-with-description", minimumDescriptionLength: 10 },
      ],
    },
  },
  {
    files: ["apps/**/*.{ts,tsx}", "packages/**/*.ts"],
    // The shared logger is the only sanctioned writer (§4).
    ignores: ["packages/shared/src/logger/browser.ts"],
    rules: { "no-console": "error" },
  },
  {
    files: ["apps/api/**/*.ts", "apps/worker/**/*.ts", "packages/**/*.ts"],
    languageOptions: { globals: { ...globals.node, ...globals.es2021, Bun: "readonly" } },
  },
  {
    files: ["apps/web/**/*.{ts,tsx}"],
    plugins: { "react-hooks": reactHooks },
    rules: { ...reactHooks.configs["recommended-latest"].rules },
    languageOptions: { globals: { ...globals.browser, ...globals.es2021 } },
  },
);
prettier.config.js
—
tailwindStylesheet
must point at the real Tailwind v4 entry stylesheet or the class-sorting plugin silently no-ops:
js
/** @type {import("prettier").Config} */
export default {
  semi: true,
  singleQuote: false,
  trailingComma: "all",
  printWidth: 100,
  tabWidth: 2,
  plugins: ["prettier-plugin-tailwindcss"],
  tailwindStylesheet: "./apps/web/src/styles.css",
};
.prettierignore
:
node_modules
,
dist
,
out
,
coverage
,
.git
,
**/bun.lock
,
*.min.js
,
**/*.md
.
.gitignore
— deny every dotenv file everywhere, then re-allow templates. Enumerating paths (
.env.local
,
db/.env.local
) leaves
.env
and nested variants trackable, which is how credentials leak:
gitignore
node_modules
.eslintcache
.DS_Store
dist
out
coverage
package.json
——作用域为
@<proj>
。
check
会运行所有检查并汇总失败,而不是遇到错误就提前终止,因此一次运行即可报告所有问题。
json
{
  "name": "@<proj>/root",
  "private": true,
  "type": "module",
  "workspaces": ["apps/*", "packages/*"],
  "scripts": {
    "lint": "bun run --workspaces lint:fix",
    "eslint": "bun run --workspaces lint",
    "format": "bun run --workspaces format",
    "format:check": "bun run --workspaces format:check",
    "typecheck": "bun run --workspaces typecheck",
    "test": "bun run --filter '@<proj>/shared' --filter '@<proj>/api' test",
    "check": "F=0; bun run lint || F=1; bun run format || F=1; bun run typecheck || F=1; bun run test || F=1; exit $F",
    "db:generate": "bun run --filter '@<proj>/db' db:generate",
    "db:migrate": "bun run --filter '@<proj>/db' db:migrate",
    "dev:api": "bun run --filter '@<proj>/api' dev",
    "dev:web": "bun run --filter '@<proj>/web' dev"
  },
  "devDependencies": {
    "@eslint/js": "latest",
    "@types/bun": "latest",
    "eslint": "latest",
    "eslint-config-prettier": "latest",
    "eslint-plugin-react-hooks": "latest",
    "eslint-plugin-simple-import-sort": "latest",
    "eslint-plugin-unused-imports": "latest",
    "globals": "latest",
    "prettier": "latest",
    "prettier-plugin-tailwindcss": "latest",
    "typescript": "latest",
    "typescript-eslint": "latest"
  }
}
  • 如果任何 workspace 缺少
    <s>
    ,
    bun run --workspaces <s>
    就会失败。必须为每个 workspace 提供
    lint
    、
    lint:fix
    、
    format
    、
    format:check
    、
    typecheck
    。
  • test
    使用
    --filter
    ,而不是
    --workspaces
    :只列出拥有测试的 workspace。
  • 跨 workspace 依赖使用
    "@<proj>/shared": "workspace:*"
    。
eslint.config.js
(根目录使用 flat config——不允许各 workspace 单独配置):
js
import eslint from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier";
import reactHooks from "eslint-plugin-react-hooks";
import simpleImportSort from "eslint-plugin-simple-import-sort";
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
import tseslint from "typescript-eslint";

export default tseslint.config(
  { ignores: ["**/node_modules/**", "**/dist/**", "**/coverage/**", "apps/web/public/**"] },
  eslint.configs.recommended,
  ...tseslint.configs.recommended,
  eslintConfigPrettier,
  {
    rules: {
      "padding-line-between-statements": [
        "error",
        { blankLine: "always", prev: "*", next: "return" },
      ],
    },
  },
  {
    files: ["apps/**/*.{ts,tsx}", "packages/**/*.ts"],
    plugins: { "simple-import-sort": simpleImportSort, "unused-imports": unusedImports },
    rules: {
      "simple-import-sort/imports": "error",
      "simple-import-sort/exports": "error",
      "@typescript-eslint/no-unused-vars": "off",
      "unused-imports/no-unused-imports": "error",
      "unused-imports/no-unused-vars": [
        "warn",
        { vars: "all", varsIgnorePattern: "^_", args: "after-used", argsIgnorePattern: "^_" },
      ],
      "@typescript-eslint/ban-ts-comment": [
        "error",
        { "ts-nocheck": "allow-with-description", minimumDescriptionLength: 10 },
      ],
    },
  },
  {
    files: ["apps/**/*.{ts,tsx}", "packages/**/*.ts"],
    // The shared logger is the only sanctioned writer (§4).
    ignores: ["packages/shared/src/logger/browser.ts"],
    rules: { "no-console": "error" },
  },
  {
    files: ["apps/api/**/*.ts", "apps/worker/**/*.ts", "packages/**/*.ts"],
    languageOptions: { globals: { ...globals.node, ...globals.es2021, Bun: "readonly" } },
  },
  {
    files: ["apps/web/**/*.{ts,tsx}"],
    plugins: { "react-hooks": reactHooks },
    rules: { ...reactHooks.configs["recommended-latest"].rules },
    languageOptions: { globals: { ...globals.browser, ...globals.es2021 } },
  },
);
prettier.config.js
——
tailwindStylesheet
必须指向真实的 Tailwind v4 入口样式表,否则 class 排序插件会静默失效:
js
/** @type {import("prettier").Config} */
export default {
  semi: true,
  singleQuote: false,
  trailingComma: "all",
  printWidth: 100,
  tabWidth: 2,
  plugins: ["prettier-plugin-tailwindcss"],
  tailwindStylesheet: "./apps/web/src/styles.css",
};
.prettierignore
:
node_modules
、
dist
、
out
、
coverage
、
.git
、
**/bun.lock
、
*.min.js
、
**/*.md
。
.gitignore
——先拒绝所有 dotenv 文件,再仅重新允许模板文件。仅枚举路径(
.env.local
、
db/.env.local
)会遗漏
.env
及其嵌套变体,而这正是凭据泄漏的方式:
gitignore
node_modules
.eslintcache
.DS_Store
dist
out
coverage

Secrets: deny all, re-allow templates only.

Secrets: deny all, re-allow templates only.

.env .env.* /.env */.env. !.env.example !/.env.example .dev.vars **/.dev.vars
.env .env.* /.env */.env. !.env.example !/.env.example .dev.vars **/.dev.vars

Agent/tooling scratch space — may hold pasted credentials or customer data.

Agent/tooling scratch space — may hold pasted credentials or customer data.

.context/

`.vscode/settings.json`: `editor.formatOnSave`, default formatter
`esbenp.prettier-vscode`, `editor.codeActionsOnSave: {"source.fixAll.eslint": "explicit"}`,
`eslint.useFlatConfig: true`, `prettier.requireConfig: true`.
`.vscode/extensions.json`: recommend `dbaeumer.vscode-eslint`, `esbenp.prettier-vscode`.
.context/

`.vscode/settings.json`:`editor.formatOnSave`、默认格式化程序 `esbenp.prettier-vscode`、`editor.codeActionsOnSave: {"source.fixAll.eslint": "explicit"}`、`eslint.useFlatConfig: true`、`prettier.requireConfig: true`。`.vscode/extensions.json`:推荐 `dbaeumer.vscode-eslint`、`esbenp.prettier-vscode`。

2. tsconfig per workspace

2. 每个 workspace 的 tsconfig

No root
tsconfig.base.json
— each workspace owns a standalone config so
tsc --noEmit
per workspace is exact. Bun/Vite resolve workspace imports through
package.json
exports
, not TS
paths
.
Server + packages (
apps/api
,
apps/worker
,
packages/*
):
json
{
  "compilerOptions": {
    "lib": ["ESNext"],
    "target": "ESNext",
    "module": "Preserve",
    "moduleDetection": "force",
    "types": ["bun"],
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "noEmit": true,
    "strict": true,
    "skipLibCheck": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true
  },
  "include": ["src/**/*.ts"]
}
Web: same, plus
"lib": ["ESNext", "DOM", "DOM.Iterable"]
,
"jsx": "react-jsx"
,
"types": ["bun", "react", "react-dom"]
,
"include"
adds
src/**/*.tsx
and
vite.config.ts
.
packages/db
adds
drizzle.config.ts
to
include
.
不要创建根目录
tsconfig.base.json
——每个 workspace 都应拥有独立配置,以便每个 workspace 的
tsc --noEmit
检查精确无误。Bun/Vite 通过
package.json
的
exports
解析 workspace 导入,而不是通过 TS
paths
。
服务端与 packages(
apps/api
、
apps/worker
、
packages/*
):
json
{
  "compilerOptions": {
    "lib": ["ESNext"],
    "target": "ESNext",
    "module": "Preserve",
    "moduleDetection": "force",
    "types": ["bun"],
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "noEmit": true,
    "strict": true,
    "skipLibCheck": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true
  },
  "include": ["src/**/*.ts"]
}
Web:配置相同,但增加
"lib": ["ESNext", "DOM", "DOM.Iterable"]
、
"jsx": "react-jsx"
、
"types": ["bun", "react", "react-dom"]
,且
include
增加
src/**/*.tsx
和
vite.config.ts
。
packages/db
还要将
drizzle.config.ts
加入
include
。

3. packages/shared — zod schemas + env

3. packages/shared——zod schemas 与 env

package.json
: named
@<proj>/shared
,
private
,
"type": "module"
, subpath
exports
so consumers import
@<proj>/shared/config
etc. Deps:
zod
,
dotenv
.
json
"exports": {
  ".": "./src/index.ts",
  "./config": "./src/config/index.ts",
  "./logger": {
    "browser": "./src/logger/browser.ts",
    "default": "./src/logger/server.ts"
  },
  "./schemas": "./src/schemas/index.ts"
}
src/config/load-env-files.ts
— env layering used by every workspace. First file wins; already-exported shell vars always win over files:
ts
import fs from "node:fs";
import path from "node:path";

import { parse as parseDotenv } from "dotenv";

const stripExportPrefixes = (raw: string) => raw.replace(/^\s*export\s+/gm, "");

const loadEnvFile = (filePath: string): void => {
  if (!fs.existsSync(filePath)) return;
  const parsed = parseDotenv(stripExportPrefixes(fs.readFileSync(filePath, "utf8")));
  for (const [key, value] of Object.entries(parsed)) {
    if (process.env[key] === undefined) process.env[key] = value;
  }
};

export const loadEnvFiles = (paths: string[]): void => {
  for (const filePath of paths) loadEnvFile(filePath);
};
stripExportPrefixes
matters:
.env.prod
files are often written to be
source
d, so their lines start with
export 
.
package.json
:命名为
@<proj>/shared
,设置
private
、
"type": "module"
,并使用子路径
exports
,使使用者可以导入
@<proj>/shared/config
等。依赖:
zod
、
dotenv
。
json
"exports": {
  ".": "./src/index.ts",
  "./config": "./src/config/index.ts",
  "./logger": {
    "browser": "./src/logger/browser.ts",
    "default": "./src/logger/server.ts"
  },
  "./schemas": "./src/schemas/index.ts"
}
src/config/load-env-files.ts
——所有 workspace 共用的 env 分层加载方式。先出现的文件优先;已经导出的 shell 变量始终优先于文件:
ts
import fs from "node:fs";
import path from "node:path";

import { parse as parseDotenv } from "dotenv";

const stripExportPrefixes = (raw: string) => raw.replace(/^\s*export\s+/gm, "");

const loadEnvFile = (filePath: string): void => {
  if (!fs.existsSync(filePath)) return;
  const parsed = parseDotenv(stripExportPrefixes(fs.readFileSync(filePath, "utf8")));
  for (const [key, value] of Object.entries(parsed)) {
    if (process.env[key] === undefined) process.env[key] = value;
  }
};

export const loadEnvFiles = (paths: string[]): void => {
  for (const filePath of paths) loadEnvFile(filePath);
};
stripExportPrefixes
很重要:
.env.prod
文件通常会被写成可供
source
的形式,因此其行以
export 
开头。

Every workspace owns
src/config/env.ts

每个 workspace 都有自己的
src/config/env.ts

Non-negotiable: each workspace that reads environment variables declares its own
src/config/env.ts
, and that file is the only place in the workspace that touches
process.env
.
It loads the dotenv files, declares one zod schema,
safeParse
s at module load, throws with the full issue list on failure, and exports a single frozen
env
object. Every other file imports
{ env }
.
apps/api/src/config/env.ts        apiEnvSchema      -> export const env
apps/worker/src/config/env.ts     workerEnvSchema   -> export const env (+ config)
packages/db/src/config/env.ts     dbEnvSchema       -> export const env
packages/shared/src/config/env-schema.ts            -> shared slice, no parse
apps/web/src/config/index.ts      import.meta.env   -> export const config
packages/db/drizzle.config.ts     own loader        -> runs outside the app
Server-side template — the loader call must sit above every import that reads
process.env
, and paths resolve from
import.meta.dir
so the cwd is irrelevant:
ts
import path from "node:path";

import { loadEnvFiles, sharedEnvSchema } from "@<proj>/shared/config";
import { z } from "zod";

const appDir = path.resolve(import.meta.dir, "..", "..");
const rootDir = path.resolve(appDir, "..", "..");

loadEnvFiles([
  path.join(appDir, ".env.local"),
  path.join(appDir, ".env"),
  path.join(rootDir, ".env.local"),
  path.join(rootDir, ".env"),
]);

const apiEnvSchema = sharedEnvSchema.extend({
  NODE_ENV: z.string().default("development"),
  PORT: z.coerce.number().int().positive().default(4000),
  HOST: z.string().min(1).default("0.0.0.0"),
  DATABASE_URL: z.string().min(1),
  BETTER_AUTH_SECRET: z.string().min(1),
  /** Public SPA origin. No trailing slash — normalized here, not at call sites. */
  CORS_ORIGIN: z
    .string()
    .min(1)
    .default("http://localhost:5174")
    .transform((v) => v.replace(/^["']+|["']+$/g, "").trim().replace(/\/+$/, "")),
});

const parsed = apiEnvSchema.safeParse(process.env);
if (!parsed.success) {
  const details = parsed.error.issues
    .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
    .join("; ");
  throw new Error(`Invalid API environment configuration: ${details}`);
}

export const env = parsed.data;
Rules:
  • Fail fast at boot with every issue at once. Never
    process.env.X!
    , never
    ?? "default"
    at a call site — a missing variable must break startup, not surface as a 500 an hour later.
  • The shared slice (anything two+ workspaces read: wallet/provider config, network selection) lives in
    packages/shared/src/config/env-schema.ts
    as a bare schema — declared there, parsed by each consumer via
    .extend()
    . Shared never parses on import; the consumer decides when.
  • Coerce and normalize inside the schema:
    z.coerce.number()
    for numeric vars,
    .transform((v) => v === "true")
    for boolean flags, trailing-slash strip for URLs, comma-split into a
    Set
    for lists. Downstream code receives finished values, never strings needing re-parsing.
  • Optional secrets often sit in
    .env.local
    as blank placeholders. Coerce empty to
    undefined
    so a blank slot reads as "unset" instead of failing
    .min(1)
    :
ts
const optionalSecret = z
  .string()
  .trim()
  .transform((v) => (v.length > 0 ? v : undefined))
  .optional();
  • Comment every non-obvious variable in the schema: what it gates, its unit, and why the default is the default. This file doubles as the env documentation and is what
    .env.example
    is generated from.
  • When a workspace needs derived values (chain ids from a network name, fallback endpoints, feature booleans), do that derivation in the same module and export a second
    config
    object —
    env
    is raw-validated,
    config
    is resolved. Keep raw secrets off
    config
    so an accidental
    logger.info({ config })
    can't leak them; expose them through accessor functions, and register them for log redaction at module load.
  • The frontend cannot use this pattern (
    import.meta.env
    is compile-time, inlined by Vite).
    apps/web/src/config/
    instead exports a plain
    as const
    object that reads each
    VITE_*
    var exactly once and applies the same trimming/defaults, so components never touch
    import.meta.env
    directly.
  • drizzle.config.ts
    runs outside the app process (drizzle-kit spawns it), so it repeats the loader + a one-key schema rather than importing the app's.
不可妥协:每个读取环境变量的 workspace 都必须声明自己的
src/config/env.ts
,且该文件必须是此 workspace 唯一接触
process.env
的位置。
它负责加载 dotenv 文件、声明一个 zod schema、在模块加载时调用
safeParse
、失败时一次性抛出完整问题列表,并导出单个冻结的
env
对象。其他所有文件都导入
{ env }
。
apps/api/src/config/env.ts        apiEnvSchema      -> export const env
apps/worker/src/config/env.ts     workerEnvSchema   -> export const env (+ config)
packages/db/src/config/env.ts     dbEnvSchema       -> export const env
packages/shared/src/config/env-schema.ts            -> shared slice, no parse
apps/web/src/config/index.ts      import.meta.env   -> export const config
packages/db/drizzle.config.ts     own loader        -> runs outside the app
服务端模板——加载器调用必须位于所有读取
process.env
的 import 之上,并且路径应基于
import.meta.dir
解析,使 cwd 无关:
ts
import path from "node:path";

import { loadEnvFiles, sharedEnvSchema } from "@<proj>/shared/config";
import { z } from "zod";

const appDir = path.resolve(import.meta.dir, "..", "..");
const rootDir = path.resolve(appDir, "..", "..");

loadEnvFiles([
  path.join(appDir, ".env.local"),
  path.join(appDir, ".env"),
  path.join(rootDir, ".env.local"),
  path.join(rootDir, ".env"),
]);

const apiEnvSchema = sharedEnvSchema.extend({
  NODE_ENV: z.string().default("development"),
  PORT: z.coerce.number().int().positive().default(4000),
  HOST: z.string().min(1).default("0.0.0.0"),
  DATABASE_URL: z.string().min(1),
  BETTER_AUTH_SECRET: z.string().min(1),
  /** Public SPA origin. No trailing slash — normalized here, not at call sites. */
  CORS_ORIGIN: z
    .string()
    .min(1)
    .default("http://localhost:5174")
    .transform((v) => v.replace(/^["']+|["']+$/g, "").trim().replace(/\/+$/, "")),
});

const parsed = apiEnvSchema.safeParse(process.env);
if (!parsed.success) {
  const details = parsed.error.issues
    .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
    .join("; ");
  throw new Error(`Invalid API environment configuration: ${details}`);
}

export const env = parsed.data;
规则:
  • 启动时快速失败,并一次报告所有问题。 永远不要使用
    process.env.X!
    ,也不要依赖调用点的
    ?? "default"
    ——缺失变量必须中断启动,而不是一小时后才表现为 500 错误。
  • 共享片段(任何两个或更多 workspace 都会读取的内容,例如 wallet/provider 配置、网络选择)应以裸 schema 的形式存放在
    packages/shared/src/config/env-schema.ts
    中——在那里声明,再由各使用者通过
    .extend()
    解析。Shared 绝不在导入时解析;何时解析由使用者决定。
  • 在 schema 中完成强制转换和规范化:数值变量使用
    z.coerce.number()
    ,布尔标志使用
    .transform((v) => v === "true")
    ,URL 去除尾部斜杠,列表按逗号拆分为
    Set
    。下游代码接收到的应是处理完成的值,而不是仍需重新解析的字符串。
  • 可选密钥经常以空白占位符的形式写在
    .env.local
    中。将空值强制转换为
    undefined
    ,使空白配置项表示“未设置”,而不是触发
    .min(1)
    失败:
ts
const optionalSecret = z
  .string()
  .trim()
  .transform((v) => (v.length > 0 ? v : undefined))
  .optional();
  • 在 schema 中注释每个不明显的变量:它控制什么、单位是什么,以及为什么采用当前默认值。该文件同时充当 env 文档,也是生成
    .env.example
    的来源。
  • 当 workspace 需要派生值时(根据网络名称得到 chain id、回退 endpoint、功能开关等),应在同一模块中完成派生,并额外导出一个
    config
    对象——
    env
    保存原始校验值,
    config
    保存解析后的值。原始密钥不得放入
    config
    ,以免意外的
    logger.info({ config })
    泄漏它们;应通过 accessor 函数暴露密钥,并在模块加载时注册日志脱敏规则。
  • 前端无法采用这种模式(
    import.meta.env
    是编译期内联的,由 Vite 处理)。
    apps/web/src/config/
    应改为导出一个普通
    as const
    对象,每个
    VITE_*
    变量只读取一次,并应用相同的裁剪和默认值逻辑,使组件绝不直接接触
    import.meta.env
    。
  • drizzle.config.ts
    在应用进程之外运行(由 drizzle-kit 启动),因此需要重复实现加载器和单键 schema,而不能导入应用中的配置。

Shared schemas are the HTTP contract, enforced at BOTH ends

共享 schemas 是 HTTP 契约,并在两端执行校验

src/schemas/api/*.ts
— one file per resource, each exporting the zod schema and its inferred type;
src/schemas/index.ts
re-exports both.
ts
export const publicFightersQuerySchema = z.object({
  sort: z.enum(["latest", "wins", "rating"]).optional(),
  limit: z.coerce.number().int().min(1).max(100).default(30),
});
export type PublicFightersQuery = z.infer<typeof publicFightersQuerySchema>;

export const publicFightersResponseSchema = z.object({ fighters: z.array(publicFighterSchema) });
export type PublicFightersResponse = z.infer<typeof publicFightersResponseSchema>;
Naming:
<resource><Verb>RequestSchema
/
<resource><Verb>ResponseSchema
/
<resource>QuerySchema
, with the PascalCase inferred type alongside. No file outside
packages/shared/src/schemas/
declares an HTTP payload shape.
One schema object is used three ways, and both ends validate at runtime — not just at the type level:
WhereHow
apps/api
route
Elysia
body
/
query
/
response
(Standard Schema) — rejects bad input, and a handler returning the wrong shape fails loudly in dev
apps/web
lib/api/*
schema.parse(await response.json())
before returning, and the inferred type as the function's return type
testsbuild fixtures from the same schema, so a contract change breaks tests, not production
Client-side
.parse()
is the point of the pattern: a deployed API drifting from the client surfaces as a loud parse error at the fetch boundary with the offending path named, instead of
undefined
propagating into a component three renders later. Never
as Response
a payload into place.
Changing a payload = edit the schema in
packages/shared
, then
bun run check
. Both sides fail to typecheck until they agree.
src/schemas/api/*.ts
——每个资源一个文件,每个文件导出 zod schema 及其推导类型;
src/schemas/index.ts
同时重新导出两者。
ts
export const publicFightersQuerySchema = z.object({
  sort: z.enum(["latest", "wins", "rating"]).optional(),
  limit: z.coerce.number().int().min(1).max(100).default(30),
});
export type PublicFightersQuery = z.infer<typeof publicFightersQuerySchema>;

export const publicFightersResponseSchema = z.object({ fighters: z.array(publicFighterSchema) });
export type PublicFightersResponse = z.infer<typeof publicFightersResponseSchema>;
命名规则:
<resource><Verb>RequestSchema
、
<resource><Verb>ResponseSchema
、
<resource>QuerySchema
,并附带对应的 PascalCase 推导类型。除
packages/shared/src/schemas/
外的任何文件都不得声明 HTTP payload 结构。
同一个 schema 对象有三种用途,且两端都在运行时校验——不只是类型层面的校验:
位置方式
apps/api
路由
Elysia
body
/
query
/
response
(Standard Schema)——拒绝非法输入;handler 返回错误结构时会在开发环境中明确失败
apps/web
lib/api/*
返回前执行
schema.parse(await response.json())
,并将推导类型作为函数返回类型
测试使用同一个 schema 构建 fixture,使契约变更导致测试失败,而不是在生产环境中暴露问题
客户端使用
.parse()
正是该模式的核心:已部署的 API 若与客户端发生漂移,会在 fetch 边界立即产生明确的解析错误并指出问题路径,而不是让
undefined
传播到三个渲染周期之后的组件中。永远不要使用
as Response
强行转换 payload。
修改 payload 时,应先编辑
packages/shared
中的 schema,然后运行
bun run check
。在双方达成一致之前,两端都会出现 typecheck 失败。

4. packages/shared — logger

4. packages/shared——logger

One logger, defined once in
shared
, imported by every workspace.
pino
on the server (structured NDJSON, the de-facto standard — any log sink ingests it without a parser), a thin
console
shim in the browser.
no-console
is an ESLint error everywhere so the shim is the only console writer in the repo.
Deps in
shared
:
pino
; dev:
pino-pretty
.
src/logger/types.ts
— the seam. Both implementations satisfy it, so call sites are identical on both sides of the network and neither can drift:
ts
export type LogFields = Record<string, unknown>;

export interface Logger {
  trace(fields: LogFields, msg?: string): void;
  debug(fields: LogFields, msg?: string): void;
  info(fields: LogFields, msg?: string): void;
  warn(fields: LogFields, msg?: string): void;
  error(fields: LogFields, msg?: string): void;
  child(bindings: LogFields): Logger;
}
src/logger/server.ts
:
ts
import pino from "pino";
import type { Logger } from "./types";

// Reads the shared env slice — the logger is a config consumer like any other
// module, it never touches process.env itself (§3).
import { env } from "../config";

export const logger: Logger = pino({
  level: env.LOG_LEVEL,
  base: { service: env.SERVICE_NAME, env: env.NODE_ENV },
  // Redaction is structural, not per-call-site: assume someone will eventually
  // log a whole request or config object, and make that safe in advance.
  redact: {
    paths: [
      "req.headers.authorization",
      "req.headers.cookie",
      "*.password",
      "*.token",
      "*.secret",
      "*.privateKey",
    ],
    censor: "[redacted]",
  },
  // Prod emits raw NDJSON for the collector; dev gets a human transport.
  transport:
    env.NODE_ENV === "development"
      ? { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss" } }
      : undefined,
});
src/logger/browser.ts
— pino must not reach the web bundle, and a production SPA should not narrate itself to the user's console:
ts
import type { Logger, LogFields } from "./types";

// Dev: everything. Prod: warn and error only.
const MIN_LEVEL = import.meta.env?.DEV ? 0 : 3;

const make = (bindings: LogFields): Logger => {
  const emit =
    (level: number, method: "debug" | "info" | "warn" | "error") =>
    (fields: LogFields, msg?: string) => {
      if (level < MIN_LEVEL) return;
      // eslint-disable-next-line no-console -- the single sanctioned console writer
      console[method](msg ?? "", { ...bindings, ...fields });
    };

  return {
    trace: emit(0, "debug"),
    debug: emit(1, "debug"),
    info: emit(2, "info"),
    warn: emit(3, "warn"),
    error: emit(4, "error"),
    child: (extra) => make({ ...bindings, ...extra }),
  };
};

export const logger = make({});
src/logger/index.ts
re-exports
./types
plus
./server
. The split is wired through conditional
exports
in
packages/shared/package.json
, so Vite resolves the browser file and Bun resolves pino with the same import specifier:
json
"./logger": {
  "browser": "./src/logger/browser.ts",
  "default": "./src/logger/server.ts"
}
Env keys go in the shared slice (
src/config/env-schema.ts
) since every workspace reads them:
ts
LOG_LEVEL: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
SERVICE_NAME: z.string().min(1).default("api"), // shows up as `service` on every line
只使用一个 logger,在
shared
中定义一次,并由每个 workspace 导入。服务端使用
pino
(结构化 NDJSON,事实上的标准——任何日志接收端都无需解析器即可摄取),浏览器使用轻量
console
shim。所有位置都将
no-console
设为 ESLint 错误,因此该 shim 是仓库中唯一允许写入 console 的地方。
shared
的依赖:
pino
;开发依赖:
pino-pretty
。
src/logger/types.ts
——扩展接缝。两种实现都满足该接口,因此网络两侧的调用点完全相同,也不会发生漂移:
ts
export type LogFields = Record<string, unknown>;

export interface Logger {
  trace(fields: LogFields, msg?: string): void;
  debug(fields: LogFields, msg?: string): void;
  info(fields: LogFields, msg?: string): void;
  warn(fields: LogFields, msg?: string): void;
  error(fields: LogFields, msg?: string): void;
  child(bindings: LogFields): Logger;
}
src/logger/server.ts
:
ts
import pino from "pino";
import type { Logger } from "./types";

// Reads the shared env slice — the logger is a config consumer like any other
// module, it never touches process.env itself (§3).
import { env } from "../config";

export const logger: Logger = pino({
  level: env.LOG_LEVEL,
  base: { service: env.SERVICE_NAME, env: env.NODE_ENV },
  // Redaction is structural, not per-call-site: assume someone will eventually
  // log a whole request or config object, and make that safe in advance.
  redact: {
    paths: [
      "req.headers.authorization",
      "req.headers.cookie",
      "*.password",
      "*.token",
      "*.secret",
      "*.privateKey",
    ],
    censor: "[redacted]",
  },
  // Prod emits raw NDJSON for the collector; dev gets a human transport.
  transport:
    env.NODE_ENV === "development"
      ? { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss" } }
      : undefined,
});
src/logger/browser.ts
——pino 不得进入 Web bundle,且生产环境的 SPA 不应向用户控制台不停输出信息:
ts
import type { Logger, LogFields } from "./types";

// Dev: everything. Prod: warn and error only.
const MIN_LEVEL = import.meta.env?.DEV ? 0 : 3;

const make = (bindings: LogFields): Logger => {
  const emit =
    (level: number, method: "debug" | "info" | "warn" | "error") =>
    (fields: LogFields, msg?: string) => {
      if (level < MIN_LEVEL) return;
      // eslint-disable-next-line no-console -- the single sanctioned console writer
      console[method](msg ?? "", { ...bindings, ...fields });
    };

  return {
    trace: emit(0, "debug"),
    debug: emit(1, "debug"),
    info: emit(2, "info"),
    warn: emit(3, "warn"),
    error: emit(4, "error"),
    child: (extra) => make({ ...bindings, ...extra }),
  };
};

export const logger = make({});
src/logger/index.ts
重新导出
./types
和
./server
。通过
packages/shared/package.json
中的条件
exports
完成分流,使 Vite 解析浏览器文件,而 Bun 使用相同的 import specifier 解析 pino:
json
"./logger": {
  "browser": "./src/logger/browser.ts",
  "default": "./src/logger/server.ts"
}
Env 键应放在共享片段
src/config/env-schema.ts
中,因为每个 workspace 都会读取它们:
ts
LOG_LEVEL: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
SERVICE_NAME: z.string().min(1).default("api"), // shows up as `service` on every line

Request correlation is the point

请求关联才是关键

A logger without a request id is
console.log
with extra steps. The API attaches one child logger per request and hands it down through the Elysia context; nothing inside a route imports the root logger, or the correlation is lost.
ts
// apps/api/src/plugins/logging.ts
import { Elysia } from "elysia";
import { logger } from "@<proj>/shared/logger";

export const loggingPlugin = new Elysia({ name: "logging" })
  .derive({ as: "global" }, ({ request, set }) => {
    // Honour an inbound id so a trace survives across services; echo it back so
    // a user can quote it in a bug report and land on the exact request.
    const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID();
    set.headers["x-request-id"] = requestId;

    return {
      log: logger.child({
        requestId,
        method: request.method,
        path: new URL(request.url).pathname,
      }),
    };
  })
  .onAfterResponse({ as: "global" }, ({ log, set }) => {
    log.info({ status: set.status }, "request");
  })
  .onError({ as: "global" }, ({ log, code, error }) => {
    log.error(
      { code, err: error instanceof Error ? { message: error.message, stack: error.stack } : error },
      "unhandled",
    );
  });
Mount it first in
src/index.ts
(before
cors
), then handlers destructure
{ log }
from context.
apps/worker
does the same with
logger.child({ job, runId })
per loop iteration.
Rules:
  • Fields first, message second —
    log.info({ userId }, "username claimed")
    . Never interpolate values into the message string; that defeats the index.
  • One log line per unit of work, at its boundary. Not one per step.
  • log.error
    takes the serialized error under
    err
    , never the bare
    Error
    (which JSON-stringifies to
    {}
    ).
  • No secrets on
    config
    objects (§3) — redaction is a net, not a licence.
  • OpenTelemetry is a complement, not a replacement: pino emits, an OTel collector ships. Add it when there's somewhere to ship to; don't pre-wire it.
no-console
in
eslint.config.js
, scoped to source and exempting the shim:
js
{
  files: ["apps/**/*.{ts,tsx}", "packages/**/*.ts"],
  ignores: ["packages/shared/src/logger/browser.ts"],
  rules: { "no-console": "error" },
},
没有 request id 的 logger 只是多绕了几步的
console.log
。API 为每个请求附加一个 child logger,并通过 Elysia context 向下传递;路由内部任何位置都不得导入根 logger,否则关联信息就会丢失。
ts
// apps/api/src/plugins/logging.ts
import { Elysia } from "elysia";
import { logger } from "@<proj>/shared/logger";

export const loggingPlugin = new Elysia({ name: "logging" })
  .derive({ as: "global" }, ({ request, set }) => {
    // Honour an inbound id so a trace survives across services; echo it back so
    // a user can quote it in a bug report and land on the exact request.
    const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID();
    set.headers["x-request-id"] = requestId;

    return {
      log: logger.child({
        requestId,
        method: request.method,
        path: new URL(request.url).pathname,
      }),
    };
  })
  .onAfterResponse({ as: "global" }, ({ log, set }) => {
    log.info({ status: set.status }, "request");
  })
  .onError({ as: "global" }, ({ log, code, error }) => {
    log.error(
      { code, err: error instanceof Error ? { message: error.message, stack: error.stack } : error },
      "unhandled",
    );
  });
在
src/index.ts
中最先挂载它(位于
cors
之前),然后 handler 从 context 中解构
{ log }
。
apps/worker
也采用相同方式,每次循环迭代使用
logger.child({ job, runId })
。
规则:
  • 字段在前,消息在后——使用
    log.info({ userId }, "username claimed")
    。永远不要把值插入消息字符串;这样会破坏日志索引。
  • 每个工作单元在其边界只记录一行日志,而不是每个步骤都记录一行。
  • log.error
    应在
    err
    下接收序列化后的错误,永远不要直接传递裸
    Error
    (JSON 序列化后会变成
    {}
    )。
  • config
    对象中不得包含密钥(§3)——脱敏机制是安全网,而不是许可证。
  • OpenTelemetry 是补充,而不是替代方案:pino 负责输出,OTel collector 负责传输。在确实有数据接收端时再加入,不要提前预埋。
eslint.config.js
中的
no-console
规则仅作用于源码,并豁免 shim:
js
{
  files: ["apps/**/*.{ts,tsx}", "packages/**/*.ts"],
  ignores: ["packages/shared/src/logger/browser.ts"],
  rules: { "no-console": "error" },
},

5. packages/db — Drizzle

5. packages/db——Drizzle

Deps:
drizzle-orm
,
@neondatabase/serverless
(or
postgres
),
@<proj>/shared
. Dev:
drizzle-kit
.
json
"scripts": {
  "db:generate": "drizzle-kit generate",
  "db:migrate": "drizzle-kit migrate",
  "db:push": "drizzle-kit push",
  "db:studio": "drizzle-kit studio"
}
drizzle.config.ts
loads env itself (drizzle-kit runs outside the app process):
ts
export default defineConfig({
  out: "./drizzle",
  schema: "./src/schema/index.ts",
  dialect: "postgresql",
  dbCredentials: { url: env.DATABASE_URL },
  verbose: true,
  strict: true,
});
  • src/schema/<table>.ts
    one file per table;
    src/schema/index.ts
    is
    export *
    of each. Colocate indexes in the table's third callback arg.
  • src/db.ts
    builds the single
    drizzle({ client: pool, schema })
    and exports
    db
    +
    export type Database = typeof db
    .
  • src/index.ts
    re-exports
    db
    ,
    Database
    ,
    export * from "./schema"
    , and the drizzle operators the apps need (
    and, eq, desc, sql, …
    ) so apps never import
    drizzle-orm
    directly.
  • Never hand-write migration SQL. Run
    bun run db:generate
    after editing a schema file, then commit the generated
    drizzle/*.sql
    +
    meta/
    .
  • drizzle-kit migrate
    wraps all pending migrations in one transaction, which breaks when migration N adds a Postgres enum value and N+1 uses it (
    55P04
    ). If the project uses enums, add
    src/migrate.ts
    applying each file in its own transaction against the same
    drizzle.__drizzle_migrations
    bookkeeping, and expose it as
    db:migrate:safe
    .
依赖:
drizzle-orm
、
@neondatabase/serverless
(或
postgres
)、
@<proj>/shared
。开发依赖:
drizzle-kit
。
json
"scripts": {
  "db:generate": "drizzle-kit generate",
  "db:migrate": "drizzle-kit migrate",
  "db:push": "drizzle-kit push",
  "db:studio": "drizzle-kit studio"
}
drizzle.config.ts
自行加载 env(drizzle-kit 在应用进程之外运行):
ts
export default defineConfig({
  out: "./drizzle",
  schema: "./src/schema/index.ts",
  dialect: "postgresql",
  dbCredentials: { url: env.DATABASE_URL },
  verbose: true,
  strict: true,
});
  • src/schema/<table>.ts
    每个表一个文件;
    src/schema/index.ts
    使用
    export *
    导出所有表。索引与对应表放在第三个回调参数中。
  • src/db.ts
    创建唯一的
    drizzle({ client: pool, schema })
    ,并导出
    db
    与
    export type Database = typeof db
    。
  • src/index.ts
    重新导出
    db
    、
    Database
    、
    export * from "./schema"
    ,以及应用所需的 drizzle 操作符(
    and, eq, desc, sql, …
    ),使 apps 绝不直接导入
    drizzle-orm
    。
  • 永远不要手写 migration SQL。 修改 schema 文件后运行
    bun run db:generate
    ,然后提交生成的
    drizzle/*.sql
    和
    meta/
    。
  • drizzle-kit migrate
    会将所有待执行 migration 包装在一个事务中,当 migration N 添加 Postgres enum 值而 N+1 使用该值时会失败(
    55P04
    )。如果项目使用 enums,应添加
    src/migrate.ts
    ,让每个文件在各自的事务中基于同一份
    drizzle.__drizzle_migrations
    记录执行,并将其暴露为
    db:migrate:safe
    。

6. apps/api — Elysia

6. apps/api——Elysia

Deps:
elysia
,
@elysiajs/cors
,
better-auth
,
jose
,
@<proj>/db
,
@<proj>/shared
. Scripts:
"dev": "bun --watch src/index.ts"
,
"start": "bun src/index.ts"
,
"test": "bun test"
.
Routes are organized as one file per route group under
src/routes/
— never one big router. Each file exports a single
Elysia
instance carrying its own
prefix
, and
src/index.ts
does nothing but
.use()
them:
src/routes/
├── public/            # unauthenticated; one file per resource
│   ├── config.ts      # publicConfigRoutes   — prefix "/public"
│   └── fighters.ts    # publicFighterRoutes  — prefix "/public/fighters"
├── users/index.ts     # userRoutes           — prefix "/users"
├── wallet/index.ts    # walletRoutes         — prefix "/wallet"
└── internal/wallet.ts # service-to-service, separate auth
Start each group as
routes/<group>.ts
; promote to
routes/<group>/index.ts
with sibling files once it outgrows one screen. Group name,
prefix
, and the
apiRoutes
key on the web side stay in lockstep — that triple is the contract.
Elysia accepts Standard Schema, so the shared zod schemas drop straight into
body
/
query
/
response
— no duplicate TypeBox definitions, and the exact same schema object the web client parses the reply with (§3):
ts
export const publicConfigRoutes = new Elysia({ prefix: "/public" }).get(
  "/config",
  () => ({ feeBps: getFeeBps() }) satisfies PublicConfigResponse,
  { response: { 200: publicConfigResponseSchema } },
);

export const userRoutes = new Elysia({ prefix: "/users" }).post(
  "/me/username",
  async ({ body }) => await claimUsername(body.username),
  { body: claimUsernameRequestSchema, response: { 200: userProfileResponseSchema } },
);
Every route declares
response
(keyed by status), and every route taking input declares
body
or
query
. A handler without them is unreviewable — the shape lives only in the return statement.
satisfies <InferredType>
on the return value gives the compile-time error to go with the runtime one.
src/index.ts
: group auth-guarded routes into one child instance, mount public routes on the root, and
export type App = typeof app
for end-to-end client types.
ts
const guarded = new Elysia({ name: "guarded-http" }).use(userRoutes).use(walletRoutes);

const app = new Elysia()
  .use(loggingPlugin)
  .use(cors({ origin: (req) => req.headers.get("Origin")?.replace(/\/+$/, "") === env.CORS_ORIGIN }))
  .get("/health", () => ({ ok: true }))
  .use(authRoutes)
  .use(publicRoutes)
  .use(guarded);

app.listen({ port: env.PORT, hostname: env.HOST });

export type App = typeof app;
Layout:
src/{routes,lib,plugins,config,ws}/
.
lib/
holds testable pure logic — route handlers stay thin so
bun:test
never needs an HTTP server.
Auth is Better Auth, mounted as
.all("/api/auth/*", ({ request }) => auth.handler(request))
, with guarded routes verifying its JWT against the JWKS endpoint via
jose
. Full server + client + guard wiring:
references/frontend-patterns.md
§4.
依赖:
elysia
、
@elysiajs/cors
、
better-auth
、
jose
、
@<proj>/db
、
@<proj>/shared
。脚本:
"dev": "bun --watch src/index.ts"
、
"start": "bun src/index.ts"
、
"test": "bun test"
。
路由按路由组组织,在
src/routes/
下每个组一个文件
——绝不能使用一个庞大的集中式 router。每个文件导出一个独立的
Elysia
实例,并携带自己的
prefix
;
src/index.ts
只负责通过
.use()
挂载它们:
src/routes/
├── public/            # unauthenticated; one file per resource
│   ├── config.ts      # publicConfigRoutes   — prefix "/public"
│   └── fighters.ts    # publicFighterRoutes  — prefix "/public/fighters"
├── users/index.ts     # userRoutes           — prefix "/users"
├── wallet/index.ts    # walletRoutes         — prefix "/wallet"
└── internal/wallet.ts # service-to-service, separate auth
每个组先从
routes/<group>.ts
开始;当其内容增长到超过一个屏幕时,再升级为
routes/<group>/index.ts
并添加同级文件。组名、
prefix
与 Web 端的
apiRoutes
键必须始终保持同步——这三者共同构成契约。
Elysia 支持 Standard Schema,因此共享的 zod schemas 可直接用于
body
/
query
/
response
——无需重复定义 TypeBox,并且 Web 客户端解析响应时使用的正是同一个 schema 对象(§3):
ts
export const publicConfigRoutes = new Elysia({ prefix: "/public" }).get(
  "/config",
  () => ({ feeBps: getFeeBps() }) satisfies PublicConfigResponse,
  { response: { 200: publicConfigResponseSchema } },
);

export const userRoutes = new Elysia({ prefix: "/users" }).post(
  "/me/username",
  async ({ body }) => await claimUsername(body.username),
  { body: claimUsernameRequestSchema, response: { 200: userProfileResponseSchema } },
);
每个路由都必须声明
response
(按状态码作为键),每个接收输入的路由都必须声明
body
或
query
。缺少这些声明的 handler 无法审查——其结构只存在于 return 语句中。在返回值上使用
satisfies <InferredType>
,可在运行时校验之外提供编译期错误。
src/index.ts
:将受认证保护的路由组合到一个 child instance 中,将公开路由挂载到根实例,并
export type App = typeof app
,以提供端到端客户端类型。
ts
const guarded = new Elysia({ name: "guarded-http" }).use(userRoutes).use(walletRoutes);

const app = new Elysia()
  .use(loggingPlugin)
  .use(cors({ origin: (req) => req.headers.get("Origin")?.replace(/\/+$/, "") === env.CORS_ORIGIN }))
  .get("/health", () => ({ ok: true }))
  .use(authRoutes)
  .use(publicRoutes)
  .use(guarded);

app.listen({ port: env.PORT, hostname: env.HOST });

export type App = typeof app;
目录结构:
src/{routes,lib,plugins,config,ws}/
。
lib/
存放可测试的纯逻辑——路由 handler 保持轻量,从而让
bun:test
无需启动 HTTP server。
认证采用 Better Auth,挂载方式为
.all("/api/auth/*", ({ request }) => auth.handler(request))
;受保护路由通过
jose
使用 JWKS endpoint 验证其 JWT。完整的服务端、客户端与 guard 接入方式见
references/frontend-patterns.md
§4。

7. apps/web — Vite + React + Tailwind v4

7. apps/web——Vite + React + Tailwind v4

Deps:
react
,
react-dom
,
react-router-dom
,
tailwindcss
,
@tailwindcss/vite
,
better-auth
,
@<proj>/shared
. Dev:
vite
,
@vitejs/plugin-react
,
@types/react{,-dom}
.
apps/web/src/
├── main.tsx        # global providers wrap <RouterProvider>
├── router.tsx      # route tree; guards compose here
├── styles.css      # @import "tailwindcss"; + @theme tokens
├── pages/          # one file per screen
├── components/     # app-specific composites only; primitives come from coss UI
├── context/<Name>/ # 4-file context modules (see reference)
├── hooks/          # useRoutes.ts + plain state hooks
├── lib/api/        # typed fetch wrappers + client.ts token holder
└── config/         # import.meta.env reads, in one place
Tailwind v4 has no
tailwind.config.js
and no PostCSS step
— the Vite plugin plus
@import "tailwindcss";
at the top of
src/styles.css
is the whole setup; theme tokens go in
@theme { }
in that same file.
ts
export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, __dirname, "");
  const apiTarget = env.VITE_API_PROXY_TARGET ?? env.VITE_API_URL ?? "http://localhost:4000";

  return {
    plugins: [react(), tailwindcss()],
    server: {
      port: 5174,
      proxy: {
        "/health": { target: apiTarget, changeOrigin: true },
        "/ws": { target: apiTarget, ws: true, changeOrigin: true },
      },
    },
  };
});
Proxy only the exact REST prefixes the API owns. A broad prefix that collides with an SPA route (e.g.
/users
when
/users/new
is a page) breaks client routing — scope with regex keys when needed.
依赖:
react
、
react-dom
、
react-router-dom
、
tailwindcss
、
@tailwindcss/vite
、
better-auth
、
@<proj>/shared
。开发依赖:
vite
、
@vitejs/plugin-react
、
@types/react{,-dom}
。
apps/web/src/
├── main.tsx        # global providers wrap <RouterProvider>
├── router.tsx      # route tree; guards compose here
├── styles.css      # @import "tailwindcss"; + @theme tokens
├── pages/          # one file per screen
├── components/     # app-specific composites only; primitives come from coss UI
├── context/<Name>/ # 4-file context modules (see reference)
├── hooks/          # useRoutes.ts + plain state hooks
├── lib/api/        # typed fetch wrappers + client.ts token holder
└── config/         # import.meta.env reads, in one place
Tailwind v4 不需要
tailwind.config.js
,也不需要 PostCSS 步骤
——Vite 插件加上
src/styles.css
顶部的
@import "tailwindcss";
就是完整配置;主题 token 也放在该文件的
@theme { }
中。
ts
export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, __dirname, "");
  const apiTarget = env.VITE_API_PROXY_TARGET ?? env.VITE_API_URL ?? "http://localhost:4000";

  return {
    plugins: [react(), tailwindcss()],
    server: {
      port: 5174,
      proxy: {
        "/health": { target: apiTarget, changeOrigin: true },
        "/ws": { target: apiTarget, ws: true, changeOrigin: true },
      },
    },
  };
});
只代理 API 明确拥有的 REST 前缀。过于宽泛的前缀如果与 SPA 路由冲突(例如
/users
同时存在
/users/new
页面),就会破坏客户端路由——必要时使用正则键限定范围。

Base UI is a component library, not hand-rolled

Base UI 是组件库,而不是手工实现

Every base UI primitive comes from the component library — coss UI. Button, input, select, dialog, dropdown, tooltip, tabs, toast, table, form field: never hand-write these, never re-implement one that already exists, never copy a variant from another project.
src/components/
holds only composites built out of library primitives plus app-specific markup.
The library ships as a Claude skill. Install it into the repo during bootstrap and follow it for every component:
bash
bunx --bun skills add cosscom/coss
That skill is the authority for install steps, component names, props, theming hooks, and which primitives exist — read it before adding any UI, and don't guess a component API from memory. Reasons the rule is absolute:
  • A one-off hand-rolled
    <Button>
    diverges on focus rings, disabled states, and keyboard/ARIA behaviour, and it diverges silently. Accessibility is the part that gets skipped when a primitive is improvised under time pressure.
  • Theming stays in one place. coss UI reads the Tailwind v4
    @theme
    tokens in
    src/styles.css
    , so a token change restyles the whole app. A bespoke component with inline colours does not move.
  • Design review becomes a diff of composition, not of re-derived primitives.
If a needed primitive genuinely isn't in the library: build it in
src/components/ui/
, match the library's prop and variant conventions exactly, and leave a comment naming what's missing — so it's a known gap with a migration path, not an accident.
Every icon comes from
lucide-react
.
One icon set, no exceptions: no inline hand-written
<svg>
, no second icon package, no pasted SVG paths from a design file.
bash
bun add lucide-react --cwd apps/web
Rules:
  • Import named, per icon —
    import { Check, ChevronDown } from "lucide-react"
    — so the bundle tree-shakes. Never
    import * as Icons
    .
  • Size and colour through Tailwind classes (
    className="size-4 text-muted-foreground"
    ), not
    size
    /
    color
    props, so icons inherit the same
    @theme
    tokens as coss UI.
  • Decorative icons get
    aria-hidden="true"
    ; an icon that is the control (icon-only button) needs an accessible name on the button, not on the
    <svg>
    .
  • If
    lucide-react
    genuinely lacks the glyph: wrap the custom SVG in a component under
    src/components/ui/icons/
    exposing the same
    className
    -driven API, and comment why — known gap, not a precedent.
Layout is flexbox. CSS grid is the exception, and it needs a reason. Default to
flex
/
flex-col
with
gap-*
for stacks, rows, toolbars, cards, forms, sidebars, and page shells — including the cases where grid would also work. Why the default is one-sided:
  • Flex composes. A flex child is itself a flex container, so nested components stay self-contained; grid couples a child's placement to the parent's track definition, and that coupling breaks the moment a component moves.
  • Content-sized rows and wrapping (
    flex-wrap
    ) behave predictably with dynamic lists; explicit grid tracks have to be restated at every breakpoint.
  • One layout idiom across the app means a reviewer reads spacing the same way everywhere.
Use
grid
only for a genuinely two-axis layout where rows and columns must align across independent children — data tables, a fixed dashboard mosaic, an equal-column card gallery. When you do, leave a one-line comment saying which two-axis constraint forced it. Never reach for
grid
merely to centre something (
flex items-center justify-center
) or to space a single-axis stack (
gap-*
).
Every URL lives in
src/hooks/useRoutes.ts
— one module exporting
routes
(SPA paths),
apiRoutes
(absolute REST),
wsRoutes
, plus
API_BASE
/
WS_BASE
derived from
import.meta.env.VITE_API_URL
. Every entry is a function, and interpolated segments are
encodeURIComponent
'd there, not at call sites. No URL literal appears anywhere else;
apiRoutes
is imported only by
lib/api/*.ts
. Only
VITE_
-prefixed vars reach the browser bundle.
State: default to a plain hook in
src/hooks/
.
Reach for a context module only when two+ sibling subtrees need the same instance — session, wallet, a multi-step wizard, a shared socket. Context modules are always four files (
XContext.tsx
,
XContext.types.ts
,
XContextController.tsx
,
useXContext.tsx
) with an undefined-guard hook. Only truly global providers wrap the router in
main.tsx
; the rest wrap the page that needs them, so state resets on navigation.
Auth is Better Auth (
better-auth/react
client), surfaced through the one always-global
AuthContextController
, which pushes the JWT into
lib/api/client.ts
and backs the
RequireAuth
/
RequireVerifiedEmail
/
RequireHandle
route guards.
Full templates and rules for all three:
references/frontend-patterns.md
.
所有基础 UI primitive 都必须来自组件库——coss UI。 Button、input、select、dialog、dropdown、tooltip、tabs、toast、table、form field:绝不能手工编写、重复实现已有组件,或从其他项目复制 variant。
src/components/
只存放由库中 primitive 和应用专用标记组成的复合组件。
该库以 Claude skill 的形式提供。在 bootstrap 期间将其安装到仓库中,并在编写每个组件时遵循该 skill:
bash
bunx --bun skills add cosscom/coss
该 skill 是安装步骤、组件名称、props、主题 hook 以及可用 primitive 清单的权威依据——添加任何 UI 前都应阅读它,不要凭记忆猜测组件 API。 这条规则不可妥协,原因如下:
  • 临时手写一个
    <Button>
    会在焦点环、禁用状态以及键盘/ARIA 行为上与组件库产生差异,而且这种差异会悄无声息地出现。无障碍支持正是在时间紧张、临时实现 primitive 时最容易被省略的部分。
  • 主题集中在一个位置。coss UI 读取
    src/styles.css
    中的 Tailwind v4
    @theme
    token,因此修改 token 即可重新设置整个应用的样式。带内联颜色的定制组件则不会随之变化。
  • 设计审查变成对组合方式的审查,而不是对重新实现的 primitive 的审查。
如果确实缺少某个 primitive:在
src/components/ui/
中构建它,严格匹配库的 props 和 variant 约定,并添加注释说明缺少什么——将其记录为已知缺口和迁移路径,而不是随手形成先例。
所有图标都来自
lucide-react
。
只使用一套图标,不设例外:不得内联手写
<svg>
、不得引入第二个图标包,也不得粘贴设计文件中的 SVG path。
bash
bun add lucide-react --cwd apps/web
规则:
  • 按图标进行命名导入——例如
    import { Check, ChevronDown } from "lucide-react"
    ——以便 bundle tree-shaking。绝不能使用
    import * as Icons
    。
  • 通过 Tailwind class 设置尺寸和颜色(
    className="size-4 text-muted-foreground"
    ),而不是使用
    size
    /
    color
    props,让图标继承与 coss UI 相同的
    @theme
    token。
  • 装饰性图标使用
    aria-hidden="true"
    ;如果图标本身就是控件(仅含图标的按钮),则应在按钮上提供无障碍名称,而不是在
    <svg>
    上提供。
  • 如果
    lucide-react
    确实缺少所需字形:将自定义 SVG 包装为
    src/components/ui/icons/
    下的组件,暴露相同的、由
    className
    驱动的 API,并注释说明原因——这是已知缺口,而不是先例。
布局使用 flexbox。CSS grid 是例外,并且必须有充分理由。 对于堆叠、行、工具栏、卡片、表单、侧边栏和页面外壳,默认使用
flex
/
flex-col
与
gap-*
,即使 grid 同样可以完成。只有真正需要二维对齐时才使用 grid——例如数据表、固定仪表盘布局、等宽卡片画廊。使用 grid 时,应添加一行注释,说明是哪项二维约束迫使你这样做。绝不能仅仅为了居中内容(
flex items-center justify-center
)或为单轴堆叠添加间距(
gap-*
)而使用 grid。
Flex 之所以是默认选择,是因为它在组合方面更稳定。Flex 子元素本身也可以是 flex container,因此嵌套组件能保持自包含;grid 则会将子元素的放置与父级轨道定义耦合,一旦组件移动,这种耦合就会失效。对于内容尺寸不确定的行和换行行为(
flex-wrap
),flex 的表现更可预测;而显式 grid 轨道必须在每个断点重新声明。在整个应用中使用统一的布局习惯,可以让审查者始终以相同方式理解间距。
只在真正需要二维布局、且行和列必须在彼此独立的子元素之间对齐时使用
grid
——例如数据表、固定仪表盘布局、等宽卡片画廊。使用时,应添加一行注释,说明是哪项二维约束迫使你这样做。绝不能仅仅为了居中内容(
flex items-center justify-center
)或为单轴堆叠添加间距(
gap-*
)而使用 grid。
所有 URL 都存放在
src/hooks/useRoutes.ts
中
——该模块导出
routes
(SPA 路径)、
apiRoutes
(绝对 REST 路径)、
wsRoutes
,以及根据
import.meta.env.VITE_API_URL
推导的
API_BASE
/
WS_BASE
。每个条目都是函数,插值段必须在该处通过
encodeURIComponent
编码,而不能在调用点编码。其他任何位置都不得出现 URL 字面量;
apiRoutes
只能由
lib/api/*.ts
导入。只有带
VITE_
前缀的变量可以进入浏览器 bundle。
状态管理默认使用
src/hooks/
中的普通 hook。
只有当两个或更多同级子树需要同一个实例时,才使用 context module,例如 session、wallet、多步骤向导或共享 socket。Context module 始终由四个文件组成(
XContext.tsx
、
XContext.types.ts
、
XContextController.tsx
、
useXContext.tsx
),并包含 undefined 防护 hook。只有真正全局的 provider 才在
main.tsx
中包裹 router;其余 provider 应包裹需要它们的页面,使状态在导航后重置。
认证采用 Better Auth(
better-auth/react
客户端),并通过唯一且始终全局的
AuthContextController
对外提供。该 Controller 会将 JWT 写入
lib/api/client.ts
,并为
RequireAuth
/
RequireVerifiedEmail
/
RequireHandle
路由 guard 提供支持。
这三部分的完整模板与规则见
references/frontend-patterns.md
。

SEO — only if the app has public, shareable pages

SEO——仅当应用存在公开、可分享的页面时启用

A Vite SPA ships a static shell, and social/AI crawlers don't run JS, so every share renders the same generic card. Three layers fix it without SSR:
  1. index.html
    — full brand default
    <head>
    (absolute
    og:image
    /
    og:url
    ).
  2. <Seo>
    component — per-route title/canonical/
    noindex
    , hoisted natively by React 19. Fixes in-app titles and Googlebot; not social cards.
  3. edge-worker/
    — a Cloudflare Worker on the entity route that sniffs the User-Agent, fetches the API's per-entity
    share.html
    for known crawlers, and
    return fetch(request)
    for everyone else. The API also serves
    og-image.png
    . SEO copy lives once in
    packages/shared
    , imported by both.
The Worker is deliberately not a Bun workspace: separate deploy lifecycle, own
bun.lock
,
@cloudflare/workers-types
instead of
bun
, outside
bun run check
. Skip layer 3 entirely if nothing is publicly shareable; skip all of it and use SSR/prerender if the app is content-driven.
Full worker,
share.html
renderer, escaping/canonical rules,
wrangler.toml
, DNS prerequisites, robots.txt and verification:
references/seo-edge-worker.md
.
Vite SPA 只包含静态 shell,而社交媒体和 AI 爬虫不会执行 JavaScript,因此每次分享都会渲染同一张通用卡片。无需 SSR 即可通过三层机制解决:
  1. index.html
    ——提供完整的品牌默认
    <head>
    (使用绝对的
    og:image
    /
    og:url
    )。
  2. <Seo>
    组件——按路由设置标题、canonical URL 和
    noindex
    ,由 React 19 原生提升。它能修复应用内标题和 Googlebot 展示,但不能修复社交卡片。
  3. edge-worker/
    ——Cloudflare Worker 位于实体路由上,通过 User-Agent 识别爬虫;针对已知爬虫获取 API 为对应实体生成的
    share.html
    ,其他人则执行
    return fetch(request)
    。API 同时提供
    og-image.png
    。SEO 文案只在
    packages/shared
    中维护一次,并由两端导入。
该 Worker 有意不作为 Bun workspace:它采用独立的部署生命周期、独立的
bun.lock
、使用
@cloudflare/workers-types
而不是
bun
,并且不纳入
bun run check
。如果没有任何公开可分享的内容,可以完全省略第 3 层;如果应用由内容驱动,则应省略所有这些机制,改用 SSR/预渲染。
完整 Worker、
share.html
渲染器、转义/canonical 规则、
wrangler.toml
、DNS 前置条件、robots.txt 和验证方式见
references/seo-edge-worker.md
。

8. apps/worker

8. apps/worker

Bun long-running process, no HTTP port:
"dev": "bun --watch src/index.ts"
. Own
src/config/env.ts
like every other workspace, exporting a resolved
config
alongside
env
(poll intervals, thresholds, kill switches) with raw secrets kept off it. Loop body must be idempotent —
--watch
restarts it mid-cycle. If it touches money or external side effects, guard it (see the dev.sh interlock below).
Bun 长时间运行进程,不使用 HTTP port:
"dev": "bun --watch src/index.ts"
。与其他 workspace 一样,它拥有自己的
src/config/env.ts
,并在导出
env
的同时导出解析后的
config
(轮询间隔、阈值、kill switch),且原始密钥不得放入其中。
循环主体必须是幂等的——
--watch
可能会在循环中途重启进程。如果循环涉及资金或外部副作用,必须设置保护机制(见下文 dev.sh 中的联锁检查)。

9. dev.sh

9. dev.sh

Runs the whole stack on ports derived from
CONDUCTOR_PORT
so parallel workspaces don't collide.
chmod +x dev.sh
.
bash
#!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"

if [[ -f "$ROOT/.env.local" ]]; then
  echo "==> Loading env from .env.local"
  set -a
  # shellcheck disable=SC1091
  source "$ROOT/.env.local"
  set +a
fi
根据
CONDUCTOR_PORT
推导端口并运行整个技术栈,避免并行 workspace 发生端口冲突。执行
chmod +x dev.sh
。
bash
#!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"

if [[ -f "$ROOT/.env.local" ]]; then
  echo "==> Loading env from .env.local"
  set -a
  # shellcheck disable=SC1091
  source "$ROOT/.env.local"
  set +a
fi

Production-DB interlock: this script starts the worker, which performs real

Production-DB interlock: this script starts the worker, which performs real

side effects with no leader election. A shell that sourced .env.prod for a

side effects with no leader election. A shell that sourced .env.prod for a

migration still exports the prod DATABASE_URL. Compare hosts, refuse on match.

migration still exports the prod DATABASE_URL. Compare hosts, refuse on match.

db_host_of() { printf '%s' "$1" | sed -E 's#^[a-z+]+://([^@]@)?([^:/?]+).#\2#'; } if [[ -n "${DATABASE_URL:-}" && -f "$ROOT/.env.prod" && "${ALLOW_PROD_DB:-}" != "1" ]]; then prod_url="$(grep -E '^[[:space:]](export[[:space:]]+)?DATABASE_URL=' "$ROOT/.env.prod" | tail -1 | sed -E 's#^[^=]=##; s#^"##; s#"$##')" if [[ -n "$prod_url" && "$(db_host_of "$DATABASE_URL")" == "$(db_host_of "$prod_url")" ]]; then echo "ERROR: dev.sh refuses to run against the PRODUCTION database." >&2 echo " Open a clean shell. Override: ALLOW_PROD_DB=1 ./dev.sh" >&2 exit 1 fi fi
BASE_PORT="${CONDUCTOR_PORT:-5174}" WEB_PORT="$BASE_PORT" API_PORT="$((BASE_PORT + 1))"
export PORT="$API_PORT" export VITE_API_URL="http://localhost:$API_PORT" export VITE_API_PROXY_TARGET="http://localhost:$API_PORT" export VITE_SITE_URL="http://localhost:$WEB_PORT" export CORS_ORIGIN="http://localhost:$WEB_PORT"
pids=() cleanup() { trap - INT TERM EXIT; kill "${pids[@]}" 2>/dev/null || true; } trap cleanup INT TERM EXIT
echo "==> api on :$API_PORT" bun run --filter '@<proj>/api' dev & pids+=($!) echo "==> worker" bun run --filter '@<proj>/worker' dev & pids+=($!) echo "==> web on :$WEB_PORT" bun run --filter '@<proj>/web' dev -- --port "$WEB_PORT" & pids+=($!)
db_host_of() { printf '%s' "$1" | sed -E 's#^[a-z+]+://([^@]@)?([^:/?]+).#\2#'; } if [[ -n "${DATABASE_URL:-}" && -f "$ROOT/.env.prod" && "${ALLOW_PROD_DB:-}" != "1" ]]; then prod_url="$(grep -E '^[[:space:]](export[[:space:]]+)?DATABASE_URL=' "$ROOT/.env.prod" | tail -1 | sed -E 's#^[^=]=##; s#^"##; s#"$##')" if [[ -n "$prod_url" && "$(db_host_of "$DATABASE_URL")" == "$(db_host_of "$prod_url")" ]]; then echo "ERROR: dev.sh refuses to run against the PRODUCTION database." >&2 echo " Open a clean shell. Override: ALLOW_PROD_DB=1 ./dev.sh" >&2 exit 1 fi fi
BASE_PORT="${CONDUCTOR_PORT:-5174}" WEB_PORT="$BASE_PORT" API_PORT="$((BASE_PORT + 1))"
export PORT="$API_PORT" export VITE_API_URL="http://localhost:$API_PORT" export VITE_API_PROXY_TARGET="http://localhost:$API_PORT" export VITE_SITE_URL="http://localhost:$WEB_PORT" export CORS_ORIGIN="http://localhost:$WEB_PORT"
pids=() cleanup() { trap - INT TERM EXIT; kill "${pids[@]}" 2>/dev/null || true; } trap cleanup INT TERM EXIT
echo "==> api on :$API_PORT" bun run --filter '@<proj>/api' dev & pids+=($!) echo "==> worker" bun run --filter '@<proj>/worker' dev & pids+=($!) echo "==> web on :$WEB_PORT" bun run --filter '@<proj>/web' dev -- --port "$WEB_PORT" & pids+=($!)

wait -n
is unavailable in macOS's bundled bash 3.2 — poll instead.

wait -n
is unavailable in macOS's bundled bash 3.2 — poll instead.

while kill -0 "${pids[@]}" 2>/dev/null; do sleep 1; done

Every port-derived value must also have a default in the app's env schema, so
running an app directly (without dev.sh) still works.
while kill -0 "${pids[@]}" 2>/dev/null; do sleep 1; done

每个根据端口推导的值都必须在应用的 env schema 中提供默认值,以确保不通过 dev.sh 直接运行应用时仍然可用。

10. .conductor/settings.toml

10. .conductor/settings.toml

toml
"$schema" = "https://conductor.build/schemas/settings.repo.schema.json"
toml
"$schema" = "https://conductor.build/schemas/settings.repo.schema.json"

Copy gitignored LOCAL env files into every new workspace.

Copy gitignored LOCAL env files into every new workspace.

Never use a bare
.env*
glob: it also matches
.env.prod
, putting the

Never use a bare
.env*
glob: it also matches
.env.prod
, putting the

production credential on disk in every workspace while
run
starts the worker.

production credential on disk in every workspace while
run
starts the worker.

file_include_globs = ".env.local\npackages/db/.env.local\n"
[scripts] setup = "git -C "$CONDUCTOR_ROOT_PATH" fetch --prune origin && git -C "$CONDUCTOR_ROOT_PATH" pull --ff-only || true; bun install" run = "./dev.sh" run_mode = "concurrent"
undefined
file_include_globs = ".env.local\npackages/db/.env.local\n"
[scripts] setup = "git -C "$CONDUCTOR_ROOT_PATH" fetch --prune origin && git -C "$CONDUCTOR_ROOT_PATH" pull --ff-only || true; bun install" run = "./dev.sh" run_mode = "concurrent"
undefined

11. Code comments

11. 代码注释

Non-negotiable, and the reason is structural: the agent that writes a file has a conversation's worth of context that the next reader — human or agent — does not get. Comments are the only channel that survives. Full spec and examples:
references/code-comments.md
. The three obligations:
  1. Every file opens with a header block — 2-5 lines: what the file is for, what it owns, and what it deliberately does not own. Not a restatement of the path, not a description of the current implementation (that rots on the first refactor).
  2. Every exported function, constant, type, and component carries a doc comment — purpose plus whatever the types cannot say: units, ranges, side effects, failure modes, ordering requirements. A bare numeric constant is a value nobody can safely change; say what it means and what breaks if it moves.
  3. Decision comments sit at the line that embodies the decision — the signature says what, the comment says why this and not the obvious alternative. Rejected alternatives are invisible in code, so the comment is the only thing stopping the next agent from "simplifying" a deliberate choice back into a bug. Required for workarounds, ordering constraints, deliberate-looking redundancy, and correctness/cost trades.
这一要求不可妥协,其原因源于结构性事实:编写文件的 agent 拥有一整段对话上下文,而下一位读者——无论是人还是 agent——无法获得这些信息。注释是唯一能够保留的信息通道。完整规范与示例见
references/code-comments.md
。三项义务如下:
  1. 每个文件开头都要有 header block——2 至 5 行:说明文件用途、职责范围,以及明确不承担的职责。不要复述文件路径,也不要描述当前实现(后者会在第一次重构后过时)。
  2. 每个导出的函数、常量、类型和组件都必须带有文档注释——说明用途,以及类型无法表达的信息:单位、范围、副作用、失败模式、顺序要求。裸露的数值常量没有人能安全修改;应说明它的含义,以及修改后会破坏什么。
  3. 决策注释应放在体现该决策的那一行——函数签名说明做什么,注释则说明为什么选择这种做法,而不是显而易见的替代方案。被否决的替代方案在代码中不可见,因此注释是唯一能阻止下一位 agent 通过“简化”将有意的选择重新变回 bug 的机制。变通方案、顺序约束、看似冗余但有意保留的逻辑,以及正确性/成本权衡都必须添加此类注释。

Deliberate gaps are always tagged in code

有意保留的缺口始终需要在代码中标记

An agent working from an incomplete spec will make judgement calls and will defer work. Those are never silent, and never left only in the chat reply — the reply scrolls away, the comment ships. Four tags, each naming a consequence:
TagMeans
TODO:
deferred work — say what it blocks or degrades
ASSUMPTION:
spec was silent — say what was assumed and what changes if it's wrong
CONFIRM:
needs a human decision — say the question and the current placeholder behaviour
FIXME:
known-wrong, shipped anyway — say why, and the correct shape
ts
// ASSUMPTION: fees are charged on the gross amount, not net of gas. The spec
// didn't say. If it's net, `feeBps` moves below the gas subtraction here and
// every stored fee row needs a backfill.
Tag at the exact site, never in a separate
TODO.md
— a tracker entry with no code anchor gets closed without the code being touched. Prefer an
ASSUMPTION
/
CONFIRM
over stopping to ask: make the reasonable call, document it, finish the work. Before handing work over, run the sweep and surface the
CONFIRM:
hits in the PR body:
bash
git grep -nE "TODO:|FIXME:|ASSUMPTION:|CONFIRM:"
Editing a function means re-reading its doc comment in the same pass — a comment that contradicts the code is worse than no comment, because it gets believed.
基于不完整规范工作的 agent 一定会作出判断并推迟部分工作。这些内容绝不能静默处理,也不能只写在聊天回复中——回复会被滚动淹没,而注释会随代码一起交付。四种标签都必须说明其后果:
标签含义
TODO:
已推迟的工作——说明它会阻塞或削弱什么
ASSUMPTION:
规范未作说明——说明采用了什么假设,以及假设错误时会发生什么
CONFIRM:
需要人工决策——说明问题以及当前占位行为
FIXME:
已知错误但仍然交付——说明原因以及正确形态
ts
// ASSUMPTION: fees are charged on the gross amount, not net of gas. The spec
// didn't say. If it's net, `feeBps` moves below the gas subtraction here and
// every stored fee row needs a backfill.
在确切位置添加标签,绝不能单独维护
TODO.md
——没有代码锚点的跟踪项可能在代码尚未修改时就被关闭。优先使用
ASSUMPTION
/
CONFIRM
,而不是停下来询问:作出合理判断、记录判断并完成工作。交付工作前,应运行检查并在 PR 正文中列出所有
CONFIRM:
:
bash
git grep -nE "TODO:|FIXME:|ASSUMPTION:|CONFIRM:"
修改函数时,应在同一次处理中重新阅读其文档注释——与代码相矛盾的注释比没有注释更糟糕,因为人们会相信它。

12. Tests

12. 测试

bun:test
, no runner config. Colocate
src/**/<unit>.test.ts
next to the unit. Test pure functions in
lib/
and shared schemas; skip HTTP round-trips.
ts
import { describe, expect, test } from "bun:test";
使用
bun:test
,不需要 runner 配置。将
src/**/<unit>.test.ts
与对应单元就近 colocate。测试
lib/
中的纯函数和共享 schemas;跳过 HTTP 往返测试。
ts
import { describe, expect, test } from "bun:test";

13. AGENTS.md / CLAUDE.md

13. AGENTS.md / CLAUDE.md

Commit the working agreement so agents follow it (
CLAUDE.md
can be a symlink to
AGENTS.md
):
  • Run
    bun run check
    at the repo root after any TS/JS edit; fix all errors.
  • Comment as specified in §11: a header block on every file, a doc comment on every export, and a decision comment wherever the choice isn't the obvious one. Record every deferred item, silent-spec judgement call, or thing needing human sign-off as a
    TODO:
    /
    ASSUMPTION:
    /
    CONFIRM:
    /
    FIXME:
    tag at the exact site — stating its consequence. Never leave a gap only in the chat reply.
  • Editing a drizzle schema: run
    bun run db:generate
    , never hand-write migrations.
  • New API routes go in their own file under
    apps/api/src/routes/
    and declare
    body
    /
    query
    /
    response
    using schemas from
    @<proj>/shared/schemas
    — never an inline shape. Payload shapes are declared there and nowhere else.
  • Every frontend URL goes through
    useRoutes.ts
    ; components call
    lib/api/*
    , not
    fetch
    . Those wrappers
    .parse()
    the reply with the same shared schema — never cast a payload.
  • Base UI primitives come from coss UI (
    bunx --bun skills add cosscom/coss
    ); consult that skill before writing any component. Never hand-roll a button, input, dialog, or other primitive the library already ships.
  • Every icon is a named import from
    lucide-react
    , sized/coloured with Tailwind classes. No inline SVG, no second icon package.
  • New shared state: plain hook by default; context module only for a shared instance, and then all four files.
  • New env var: add it to that workspace's
    src/config/env.ts
    zod schema (with a comment and a default where sensible) and to
    .env.example
    . Never read
    process.env
    /
    import.meta.env
    anywhere else.
  • Never
    console.log
    . Import
    logger
    from
    @<proj>/shared/logger
    , or take
    { log }
    off the Elysia context inside a route so the request id is carried.
  • Auth is Better Auth. Don't add a second auth SDK or hand-roll sessions.
  • Layout is flexbox (
    flex
    ,
    flex-col
    ,
    gap-*
    ), not CSS grid — see the layout rule above.
    grid
    only for a genuine two-axis table-like layout, with a comment.
  • Add
    id
    props to significant HTML elements/components.
将工作约定提交到版本库,确保 agent 遵循它(
CLAUDE.md
可以是指向
AGENTS.md
的符号链接):
  • 每次编辑 TS/JS 后,在仓库根目录运行
    bun run check
    ;修复所有错误。
  • 按 §11 的要求添加注释:每个文件都有 header block,每个导出项都有文档注释,并在每一个不明显选择出现的位置添加决策注释。所有推迟事项、规范未明确时的静默判断,以及需要人工确认的内容,都必须在确切位置标记为
    TODO:
    /
    ASSUMPTION:
    /
    CONFIRM:
    /
    FIXME:
    ,并说明后果。绝不能只在聊天回复中留下缺口。
  • 编辑 drizzle schema 时:运行
    bun run db:generate
    ,永远不要手写 migrations。
  • 新 API 路由放在
    apps/api/src/routes/
    下的独立文件中,并使用
    @<proj>/shared/schemas
    中的 schema 声明
    body
    /
    query
    /
    response
    ——绝不能内联定义结构。Payload 结构只能在那里声明。
  • 所有前端 URL 都必须经过
    useRoutes.ts
    ;组件调用
    lib/api/*
    ,而不是
    fetch
    。这些包装器使用同一个共享 schema 对响应执行
    .parse()
    ——绝不能强制转换 payload。
  • 基础 UI primitive 来自 coss UI(
    bunx --bun skills add cosscom/coss
    );编写任何组件前都要查阅该 skill。绝不能手工实现库中已经提供的 button、input、dialog 或其他 primitive。
  • 所有图标都从
    lucide-react
    按名称导入,并使用 Tailwind class 设置尺寸和颜色。不得内联 SVG,也不得引入第二个图标包。
  • 新增共享状态时:默认使用普通 hook;只有需要共享同一实例时才使用 context module,并且必须包含全部四个文件。
  • 新增 env 变量时:将其添加到对应 workspace 的
    src/config/env.ts
    zod schema 中(添加注释,并在合理时提供默认值),同时添加到
    .env.example
    。其他任何地方都不得读取
    process.env
    /
    import.meta.env
    。
  • 永远不要使用
    console.log
    。从
    @<proj>/shared/logger
    导入
    logger
    ,或者在路由中从 Elysia context 取出
    { log }
    ,从而携带 request id。
  • 认证使用 Better Auth。不要引入第二个 auth SDK,也不要手工实现 session。
  • 布局使用 flexbox(
    flex
    、
    flex-col
    、
    gap-*
    ),而不是 CSS grid——遵循上述布局规则。只有真正需要二维表格式布局时才使用
    grid
    ,并添加注释。
  • 为重要的 HTML 元素/组件添加
    id
    props。

Bootstrap order

Bootstrap 顺序

  1. mkdir
    the tree, write root files,
    git init
    .
  2. packages/shared
    first (env loader + schemas + logger), then
    packages/db
    (it imports shared).
  3. apps/api
    ,
    apps/web
    ,
    apps/worker
    . Run
    bunx --bun skills add cosscom/coss
    before writing any
    apps/web
    UI, then follow that skill's install steps.
  4. Better Auth: config in
    apps/api
    ,
    bunx @better-auth/cli generate
    into
    packages/db/src/schema/auth.ts
    , re-export from
    schema/index.ts
    .
  5. .env.example
    listing every key in every zod env schema;
    .env.local
    locally.
  6. bun install
    at root, then
    bun run --filter '@<proj>/db' db:generate && db:migrate
    .
  7. bun run check
    — must pass clean before the first commit, and
    git grep -nE "TODO:|FIXME:|ASSUMPTION:|CONFIRM:"
    reviewed (§11): every gap left in the scaffold is tagged at its site, and the
    CONFIRM:
    hits are the handover list.
  1. mkdir
    创建目录树,编写根目录文件,执行
    git init
    。
  2. 先完成
    packages/shared
    (env loader + schemas + logger),然后完成
    packages/db
    (它会导入 shared)。
  3. 完成
    apps/api
    、
    apps/web
    、
    apps/worker
    。编写任何
    apps/web
    UI 之前,先运行
    bunx --bun skills add cosscom/coss
    ,然后遵循该 skill 的安装步骤。
  4. Better Auth:在
    apps/api
    中配置,运行
    bunx @better-auth/cli generate
    生成到
    packages/db/src/schema/auth.ts
    ,并从
    schema/index.ts
    重新导出。
  5. 创建
    .env.example
    ,列出每个 zod env schema 中的所有键;本地创建
    .env.local
    。
  6. 在根目录运行
    bun install
    ,然后运行
    bun run --filter '@<proj>/db' db:generate && db:migrate
    。
  7. 运行
    bun run check
    ——首次提交前必须干净通过,并审查
    git grep -nE "TODO:|FIXME:|ASSUMPTION:|CONFIRM:"
    (§11):脚手架中遗留的每个缺口都应在对应位置标记,所有
    CONFIRM:
    项都应作为交付清单列出。