bun-monorepo-bootstrap
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseBun workspaces monorepo bootstrap
Bun workspaces monorepo 引导配置
Target layout. are runnable, are source-only (no build step —
Bun/Vite consume TypeScript directly through workspace ).
apps/*packages/*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 —
one zod schema, parsed at boot, the workspace's only reader (§3).
src/config/env.tsprocess.envSplit rule: anything imported by two workspaces lives in . may
import ; imports nothing local. Apps never import each other.
packages/dbsharedsharedReferences — read before scaffolding the matching part:
- — normative spec for
references/frontend-patterns.md,useRoutes.tswrappers, the React context-module pattern, hook-vs-context, and Better Auth wiring.lib/api/* - — normative spec for file headers, doc comments on every export, decision comments, and the
references/code-comments.md/TODO/ASSUMPTION/CONFIRMtags used to record deliberate gaps.FIXME - — how a client-rendered SPA gets real social cards and crawler-visible metadata:
references/seo-edge-worker.mdfloor,index.html, the API's<Seo>+share.html, and the Cloudflare Worker that routes crawlers to them.og-image.png
目标目录结构。 可运行, 仅包含源码(无需构建步骤——Bun/Vite 通过 workspace 的 直接消费 TypeScript)。
apps/*packages/*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}/每个读取环境变量的 workspace 都有自己的 ——每个文件包含一个 zod schema,在启动时解析,并且是该 workspace 唯一读取 的位置(§3)。
src/config/env.tsprocess.env拆分规则:任何被两个 workspace 导入的内容都放在 中。 可以导入 ; 不导入任何本地模块。Apps 之间绝不互相导入。
packages/dbsharedshared参考资料——搭建对应部分前务必阅读:
- ——
references/frontend-patterns.md、useRoutes.ts包装器、React context-module 模式、hook 与 context 的选择,以及 Better Auth 接入的规范说明。lib/api/* - ——文件头、每个导出项的文档注释、决策注释,以及用于记录有意保留缺口的
references/code-comments.md/TODO/ASSUMPTION/CONFIRM标签的规范说明。FIXME - ——客户端渲染的 SPA 如何获得真实的社交卡片和爬虫可见元数据:
references/seo-edge-worker.md基础配置、index.html、API 的<Seo>与share.html,以及将爬虫请求路由到这些资源的 Cloudflare Worker。og-image.png
1. Root
1. 根目录
package.json@<proj>checkjson
{
"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"
}
}- fails if any workspace lacks
bun run --workspaces <s>. Give every workspace<s>,lint,lint:fix,format,format:check.typecheck - is
tested, not--filter: only list workspaces that own tests.--workspaces - Cross-workspace deps use .
"@<proj>/shared": "workspace:*"
eslint.config.jsjs
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.jstailwindStylesheetjs
/** @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",
};.prettierignorenode_modulesdistoutcoverage.git**/bun.lock*.min.js**/*.md.gitignore.env.localdb/.env.local.envgitignore
node_modules
.eslintcache
.DS_Store
dist
out
coveragepackage.json@<proj>checkjson
{
"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>就会失败。必须为每个 workspace 提供bun run --workspaces <s>、lint、lint:fix、format、format:check。typecheck - 使用
test,而不是--filter:只列出拥有测试的 workspace。--workspaces - 跨 workspace 依赖使用 。
"@<proj>/shared": "workspace:*"
eslint.config.jsjs
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.jstailwindStylesheetjs
/** @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",
};.prettierignorenode_modulesdistoutcoverage.git**/bun.lock*.min.js**/*.md.gitignore.env.localdb/.env.local.envgitignore
node_modules
.eslintcache
.DS_Store
dist
out
coverageSecrets: 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 — each workspace owns a standalone config so
per workspace is exact. Bun/Vite resolve workspace imports through
, not TS .
tsconfig.base.jsontsc --noEmitpackage.jsonexportspathsServer + packages (, , ):
apps/apiapps/workerpackages/*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 , ,
, adds and
. adds to .
"lib": ["ESNext", "DOM", "DOM.Iterable"]"jsx": "react-jsx""types": ["bun", "react", "react-dom"]"include"src/**/*.tsxvite.config.tspackages/dbdrizzle.config.tsinclude不要创建根目录 ——每个 workspace 都应拥有独立配置,以便每个 workspace 的 检查精确无误。Bun/Vite 通过 的 解析 workspace 导入,而不是通过 TS 。
tsconfig.base.jsontsc --noEmitpackage.jsonexportspaths服务端与 packages(、、):
apps/apiapps/workerpackages/*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"]includesrc/**/*.tsxvite.config.tspackages/dbdrizzle.config.tsinclude3. packages/shared — zod schemas + env
3. packages/shared——zod schemas 与 env
package.json@<proj>/sharedprivate"type": "module"exports@<proj>/shared/configzoddotenvjson
"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.tsts
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.prodsourceexport package.json@<proj>/sharedprivate"type": "module"exports@<proj>/shared/configzoddotenvjson
"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.tsts
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.prodsourceexport Every workspace owns src/config/env.ts
src/config/env.ts每个 workspace 都有自己的 src/config/env.ts
src/config/env.tsNon-negotiable: each workspace that reads environment variables declares its
own , and that file is the only place in the workspace that
touches . It loads the dotenv files, declares one zod schema,
s at module load, throws with the full issue list on failure, and
exports a single frozen object. Every other file imports .
src/config/env.tsprocess.envsafeParseenv{ 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 appServer-side template — the loader call must sit above every import that reads
, and paths resolve from so the cwd is irrelevant:
process.envimport.meta.dirts
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 , never
process.env.X!at a call site — a missing variable must break startup, not surface as a 500 an hour later.?? "default" - The shared slice (anything two+ workspaces read: wallet/provider config,
network selection) lives in as a bare schema — declared there, parsed by each consumer via
packages/shared/src/config/env-schema.ts. Shared never parses on import; the consumer decides when..extend() - Coerce and normalize inside the schema: for numeric vars,
z.coerce.number()for boolean flags, trailing-slash strip for URLs, comma-split into a.transform((v) => v === "true")for lists. Downstream code receives finished values, never strings needing re-parsing.Set - Optional secrets often sit in as blank placeholders. Coerce empty to
.env.localso a blank slot reads as "unset" instead of failingundefined:.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 is generated from.
.env.example - 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 object —
configis raw-validated,envis resolved. Keep raw secrets offconfigso an accidentalconfigcan't leak them; expose them through accessor functions, and register them for log redaction at module load.logger.info({ config }) - The frontend cannot use this pattern (is compile-time, inlined by Vite).
import.meta.envinstead exports a plainapps/web/src/config/object that reads eachas constvar exactly once and applies the same trimming/defaults, so components never touchVITE_*directly.import.meta.env - runs outside the app process (drizzle-kit spawns it), so it repeats the loader + a one-key schema rather than importing the app's.
drizzle.config.ts
不可妥协:每个读取环境变量的 workspace 都必须声明自己的 ,且该文件必须是此 workspace 唯一接触 的位置。 它负责加载 dotenv 文件、声明一个 zod schema、在模块加载时调用 、失败时一次性抛出完整问题列表,并导出单个冻结的 对象。其他所有文件都导入 。
src/config/env.tsprocess.envsafeParseenv{ 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服务端模板——加载器调用必须位于所有读取 的 import 之上,并且路径应基于 解析,使 cwd 无关:
process.envimport.meta.dirts
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!——缺失变量必须中断启动,而不是一小时后才表现为 500 错误。?? "default" - 共享片段(任何两个或更多 workspace 都会读取的内容,例如 wallet/provider 配置、网络选择)应以裸 schema 的形式存放在 中——在那里声明,再由各使用者通过
packages/shared/src/config/env-schema.ts解析。Shared 绝不在导入时解析;何时解析由使用者决定。.extend() - 在 schema 中完成强制转换和规范化:数值变量使用 ,布尔标志使用
z.coerce.number(),URL 去除尾部斜杠,列表按逗号拆分为.transform((v) => v === "true")。下游代码接收到的应是处理完成的值,而不是仍需重新解析的字符串。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泄漏它们;应通过 accessor 函数暴露密钥,并在模块加载时注册日志脱敏规则。logger.info({ config }) - 前端无法采用这种模式(是编译期内联的,由 Vite 处理)。
import.meta.env应改为导出一个普通apps/web/src/config/对象,每个as const变量只读取一次,并应用相同的裁剪和默认值逻辑,使组件绝不直接接触VITE_*。import.meta.env - 在应用进程之外运行(由 drizzle-kit 启动),因此需要重复实现加载器和单键 schema,而不能导入应用中的配置。
drizzle.config.ts
Shared schemas are the HTTP contract, enforced at BOTH ends
共享 schemas 是 HTTP 契约,并在两端执行校验
src/schemas/api/*.tssrc/schemas/index.tsts
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: / /
, with the PascalCase inferred type alongside. No file
outside declares an HTTP payload shape.
<resource><Verb>RequestSchema<resource><Verb>ResponseSchema<resource>QuerySchemapackages/shared/src/schemas/One schema object is used three ways, and both ends validate at runtime —
not just at the type level:
| Where | How |
|---|---|
| Elysia |
| |
| tests | build fixtures from the same schema, so a contract change breaks tests, not production |
Client-side 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 propagating into a component three renders
later. Never a payload into place.
.parse()undefinedas ResponseChanging a payload = edit the schema in , then .
Both sides fail to typecheck until they agree.
packages/sharedbun run checksrc/schemas/api/*.tssrc/schemas/index.tsts
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>;命名规则:、、,并附带对应的 PascalCase 推导类型。除 外的任何文件都不得声明 HTTP payload 结构。
<resource><Verb>RequestSchema<resource><Verb>ResponseSchema<resource>QuerySchemapackages/shared/src/schemas/同一个 schema 对象有三种用途,且两端都在运行时校验——不只是类型层面的校验:
| 位置 | 方式 |
|---|---|
| Elysia |
| 返回前执行 |
| 测试 | 使用同一个 schema 构建 fixture,使契约变更导致测试失败,而不是在生产环境中暴露问题 |
客户端使用 正是该模式的核心:已部署的 API 若与客户端发生漂移,会在 fetch 边界立即产生明确的解析错误并指出问题路径,而不是让 传播到三个渲染周期之后的组件中。永远不要使用 强行转换 payload。
.parse()undefinedas Response修改 payload 时,应先编辑 中的 schema,然后运行 。在双方达成一致之前,两端都会出现 typecheck 失败。
packages/sharedbun run check4. packages/shared — logger
4. packages/shared——logger
One logger, defined once in , imported by every workspace. on
the server (structured NDJSON, the de-facto standard — any log sink ingests it
without a parser), a thin shim in the browser. is an
ESLint error everywhere so the shim is the only console writer in the repo.
sharedpinoconsoleno-consoleDeps in : ; dev: .
sharedpinopino-prettysrc/logger/types.tsts
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.tsts
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.tsts
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./serverexportspackages/shared/package.jsonjson
"./logger": {
"browser": "./src/logger/browser.ts",
"default": "./src/logger/server.ts"
}Env keys go in the shared slice () since every
workspace reads them:
src/config/env-schema.tsts
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,在 中定义一次,并由每个 workspace 导入。服务端使用 (结构化 NDJSON,事实上的标准——任何日志接收端都无需解析器即可摄取),浏览器使用轻量 shim。所有位置都将 设为 ESLint 错误,因此该 shim 是仓库中唯一允许写入 console 的地方。
sharedpinoconsoleno-consolesharedpinopino-prettysrc/logger/types.tsts
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.tsts
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.tsts
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./serverpackages/shared/package.jsonexportsjson
"./logger": {
"browser": "./src/logger/browser.ts",
"default": "./src/logger/server.ts"
}Env 键应放在共享片段 中,因为每个 workspace 都会读取它们:
src/config/env-schema.tsts
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 lineRequest correlation is the point
请求关联才是关键
A logger without a request id is 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.
console.logts
// 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 (before ), then handlers destructure
from context. does the same with
per loop iteration.
src/index.tscors{ log }apps/workerlogger.child({ job, runId })Rules:
- Fields first, message second — . Never interpolate values into the message string; that defeats the index.
log.info({ userId }, "username claimed") - One log line per unit of work, at its boundary. Not one per step.
- takes the serialized error under
log.error, never the bareerr(which JSON-stringifies toError).{} - No secrets on objects (§3) — redaction is a net, not a licence.
config - 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-consoleeslint.config.jsjs
{
files: ["apps/**/*.{ts,tsx}", "packages/**/*.ts"],
ignores: ["packages/shared/src/logger/browser.ts"],
rules: { "no-console": "error" },
},没有 request id 的 logger 只是多绕了几步的 。API 为每个请求附加一个 child logger,并通过 Elysia context 向下传递;路由内部任何位置都不得导入根 logger,否则关联信息就会丢失。
console.logts
// 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",
);
});在 中最先挂载它(位于 之前),然后 handler 从 context 中解构 。 也采用相同方式,每次循环迭代使用 。
src/index.tscors{ log }apps/workerlogger.child({ job, runId })规则:
- 字段在前,消息在后——使用 。永远不要把值插入消息字符串;这样会破坏日志索引。
log.info({ userId }, "username claimed") - 每个工作单元在其边界只记录一行日志,而不是每个步骤都记录一行。
- 应在
log.error下接收序列化后的错误,永远不要直接传递裸err(JSON 序列化后会变成Error)。{} - 对象中不得包含密钥(§3)——脱敏机制是安全网,而不是许可证。
config - OpenTelemetry 是补充,而不是替代方案:pino 负责输出,OTel collector 负责传输。在确实有数据接收端时再加入,不要提前预埋。
eslint.config.jsno-consolejs
{
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: , (or ), .
Dev: .
drizzle-orm@neondatabase/serverlesspostgres@<proj>/shareddrizzle-kitjson
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
}drizzle.config.tsts
export default defineConfig({
out: "./drizzle",
schema: "./src/schema/index.ts",
dialect: "postgresql",
dbCredentials: { url: env.DATABASE_URL },
verbose: true,
strict: true,
});- one file per table;
src/schema/<table>.tsissrc/schema/index.tsof each. Colocate indexes in the table's third callback arg.export * - builds the single
src/db.tsand exportsdrizzle({ client: pool, schema })+db.export type Database = typeof db - re-exports
src/index.ts,db,Database, and the drizzle operators the apps need (export * from "./schema") so apps never importand, eq, desc, sql, …directly.drizzle-orm - Never hand-write migration SQL. Run after editing a schema file, then commit the generated
bun run db:generate+drizzle/*.sql.meta/ - wraps all pending migrations in one transaction, which breaks when migration N adds a Postgres enum value and N+1 uses it (
drizzle-kit migrate). If the project uses enums, add55P04applying each file in its own transaction against the samesrc/migrate.tsbookkeeping, and expose it asdrizzle.__drizzle_migrations.db:migrate:safe
依赖:、(或 )、。开发依赖:。
drizzle-orm@neondatabase/serverlesspostgres@<proj>/shareddrizzle-kitjson
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
}drizzle.config.tsts
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,以及应用所需的 drizzle 操作符(export * from "./schema"),使 apps 绝不直接导入and, eq, desc, sql, …。drizzle-orm - 永远不要手写 migration SQL。 修改 schema 文件后运行 ,然后提交生成的
bun run db:generate和drizzle/*.sql。meta/ - 会将所有待执行 migration 包装在一个事务中,当 migration N 添加 Postgres enum 值而 N+1 使用该值时会失败(
drizzle-kit migrate)。如果项目使用 enums,应添加55P04,让每个文件在各自的事务中基于同一份src/migrate.ts记录执行,并将其暴露为drizzle.__drizzle_migrations。db:migrate:safe
6. apps/api — Elysia
6. apps/api——Elysia
Deps: , , , , ,
. Scripts: ,
, .
elysia@elysiajs/corsbetter-authjose@<proj>/db@<proj>/shared"dev": "bun --watch src/index.ts""start": "bun src/index.ts""test": "bun test"Routes are organized as one file per route group under — never
one big router. Each file exports a single instance carrying its own
, and does nothing but them:
src/routes/Elysiaprefixsrc/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 authStart each group as ; promote to
with sibling files once it outgrows one screen. Group name, , and the
key on the web side stay in lockstep — that triple is the contract.
routes/<group>.tsroutes/<group>/index.tsprefixapiRoutesElysia accepts Standard Schema, so the shared zod schemas drop straight into
// — no duplicate TypeBox definitions, and the exact
same schema object the web client parses the reply with (§3):
bodyqueryresponsets
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 (keyed by status), and every route taking input
declares or . A handler without them is unreviewable — the shape
lives only in the return statement. on the return
value gives the compile-time error to go with the runtime one.
responsebodyquerysatisfies <InferredType>src/index.tsexport type App = typeof appts
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: . holds testable pure logic —
route handlers stay thin so never needs an HTTP server.
src/{routes,lib,plugins,config,ws}/lib/bun:testAuth is Better Auth, mounted as ,
with guarded routes verifying its JWT against the JWKS endpoint via . Full
server + client + guard wiring: §4.
.all("/api/auth/*", ({ request }) => auth.handler(request))josereferences/frontend-patterns.md依赖:、、、、、。脚本:、、。
elysia@elysiajs/corsbetter-authjose@<proj>/db@<proj>/shared"dev": "bun --watch src/index.ts""start": "bun src/index.ts""test": "bun test"路由按路由组组织,在 下每个组一个文件——绝不能使用一个庞大的集中式 router。每个文件导出一个独立的 实例,并携带自己的 ; 只负责通过 挂载它们:
src/routes/Elysiaprefixsrc/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每个组先从 开始;当其内容增长到超过一个屏幕时,再升级为 并添加同级文件。组名、 与 Web 端的 键必须始终保持同步——这三者共同构成契约。
routes/<group>.tsroutes/<group>/index.tsprefixapiRoutesElysia 支持 Standard Schema,因此共享的 zod schemas 可直接用于 //——无需重复定义 TypeBox,并且 Web 客户端解析响应时使用的正是同一个 schema 对象(§3):
bodyqueryresponsets
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 } },
);每个路由都必须声明 (按状态码作为键),每个接收输入的路由都必须声明 或 。缺少这些声明的 handler 无法审查——其结构只存在于 return 语句中。在返回值上使用 ,可在运行时校验之外提供编译期错误。
responsebodyquerysatisfies <InferredType>src/index.tsexport type App = typeof appts
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;目录结构:。 存放可测试的纯逻辑——路由 handler 保持轻量,从而让 无需启动 HTTP server。
src/{routes,lib,plugins,config,ws}/lib/bun:test认证采用 Better Auth,挂载方式为 ;受保护路由通过 使用 JWKS endpoint 验证其 JWT。完整的服务端、客户端与 guard 接入方式见 §4。
.all("/api/auth/*", ({ request }) => auth.handler(request))josereferences/frontend-patterns.md7. apps/web — Vite + React + Tailwind v4
7. apps/web——Vite + React + Tailwind v4
Deps: , , , , ,
, . Dev: , ,
.
reactreact-domreact-router-domtailwindcss@tailwindcss/vitebetter-auth@<proj>/sharedvite@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 placeTailwind v4 has no and no PostCSS step — the Vite plugin
plus at the top of is the whole setup;
theme tokens go in in that same file.
tailwind.config.js@import "tailwindcss";src/styles.css@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 },
},
},
};
});Proxy only the exact REST prefixes the API owns. A broad prefix that collides
with an SPA route (e.g. when is a page) breaks client
routing — scope with regex keys when needed.
/users/users/new依赖:、、、、、、。开发依赖:、、。
reactreact-domreact-router-domtailwindcss@tailwindcss/vitebetter-auth@<proj>/sharedvite@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 placeTailwind v4 不需要 ,也不需要 PostCSS 步骤——Vite 插件加上 顶部的 就是完整配置;主题 token 也放在该文件的 中。
tailwind.config.jssrc/styles.css@import "tailwindcss";@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/newBase 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. holds only composites built out
of library primitives plus app-specific markup.
src/components/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/cossThat 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 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.
<Button> - Theming stays in one place. coss UI reads the Tailwind v4 tokens in
@theme, so a token change restyles the whole app. A bespoke component with inline colours does not move.src/styles.css - 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
, 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.
src/components/ui/Every icon comes from . One icon set, no
exceptions: no inline hand-written , no second icon package, no pasted
SVG paths from a design file.
lucide-react<svg>bash
bun add lucide-react --cwd apps/webRules:
- Import named, per icon — — so the bundle tree-shakes. Never
import { Check, ChevronDown } from "lucide-react".import * as Icons - Size and colour through Tailwind classes (), not
className="size-4 text-muted-foreground"/sizeprops, so icons inherit the samecolortokens as coss UI.@theme - Decorative icons get ; an icon that is the control (icon-only button) needs an accessible name on the button, not on the
aria-hidden="true".<svg> - If genuinely lacks the glyph: wrap the custom SVG in a component under
lucide-reactexposing the samesrc/components/ui/icons/-driven API, and comment why — known gap, not a precedent.className
Layout is flexbox. CSS grid is the exception, and it needs a reason.
Default to / with for stacks, rows, toolbars, cards,
forms, sidebars, and page shells — including the cases where grid would also
work. Why the default is one-sided:
flexflex-colgap-*- 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 () behave predictably with dynamic lists; explicit grid tracks have to be restated at every breakpoint.
flex-wrap - One layout idiom across the app means a reviewer reads spacing the same way everywhere.
Use 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 merely to centre something
() or to space a single-axis stack ().
gridgridflex items-center justify-centergap-*Every URL lives in — one module exporting
(SPA paths), (absolute REST), , plus /
derived from . Every entry is a function, and
interpolated segments are 'd there, not at call sites. No URL
literal appears anywhere else; is imported only by .
Only -prefixed vars reach the browser bundle.
src/hooks/useRoutes.tsroutesapiRouteswsRoutesAPI_BASEWS_BASEimport.meta.env.VITE_API_URLencodeURIComponentapiRouteslib/api/*.tsVITE_State: default to a plain hook in . 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
(, , , )
with an undefined-guard hook. Only truly global providers wrap the router in
; the rest wrap the page that needs them, so state resets on navigation.
src/hooks/XContext.tsxXContext.types.tsXContextController.tsxuseXContext.tsxmain.tsxAuth is Better Auth ( client), surfaced through the one
always-global , which pushes the JWT into
and backs the //
route guards.
better-auth/reactAuthContextControllerlib/api/client.tsRequireAuthRequireVerifiedEmailRequireHandleFull 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。 只存放由库中 primitive 和应用专用标记组成的复合组件。
src/components/该库以 Claude skill 的形式提供。在 bootstrap 期间将其安装到仓库中,并在编写每个组件时遵循该 skill:
bash
bunx --bun skills add cosscom/coss该 skill 是安装步骤、组件名称、props、主题 hook 以及可用 primitive 清单的权威依据——添加任何 UI 前都应阅读它,不要凭记忆猜测组件 API。 这条规则不可妥协,原因如下:
- 临时手写一个 会在焦点环、禁用状态以及键盘/ARIA 行为上与组件库产生差异,而且这种差异会悄无声息地出现。无障碍支持正是在时间紧张、临时实现 primitive 时最容易被省略的部分。
<Button> - 主题集中在一个位置。coss UI 读取 中的 Tailwind v4
src/styles.csstoken,因此修改 token 即可重新设置整个应用的样式。带内联颜色的定制组件则不会随之变化。@theme - 设计审查变成对组合方式的审查,而不是对重新实现的 primitive 的审查。
如果确实缺少某个 primitive:在 中构建它,严格匹配库的 props 和 variant 约定,并添加注释说明缺少什么——将其记录为已知缺口和迁移路径,而不是随手形成先例。
src/components/ui/所有图标都来自 。 只使用一套图标,不设例外:不得内联手写 、不得引入第二个图标包,也不得粘贴设计文件中的 SVG path。
lucide-react<svg>bash
bun add lucide-react --cwd apps/web规则:
- 按图标进行命名导入——例如 ——以便 bundle tree-shaking。绝不能使用
import { Check, ChevronDown } from "lucide-react"。import * as Icons - 通过 Tailwind class 设置尺寸和颜色(),而不是使用
className="size-4 text-muted-foreground"/sizeprops,让图标继承与 coss UI 相同的colortoken。@theme - 装饰性图标使用 ;如果图标本身就是控件(仅含图标的按钮),则应在按钮上提供无障碍名称,而不是在
aria-hidden="true"上提供。<svg> - 如果 确实缺少所需字形:将自定义 SVG 包装为
lucide-react下的组件,暴露相同的、由src/components/ui/icons/驱动的 API,并注释说明原因——这是已知缺口,而不是先例。className
布局使用 flexbox。CSS grid 是例外,并且必须有充分理由。
对于堆叠、行、工具栏、卡片、表单、侧边栏和页面外壳,默认使用 / 与 ,即使 grid 同样可以完成。只有真正需要二维对齐时才使用 grid——例如数据表、固定仪表盘布局、等宽卡片画廊。使用 grid 时,应添加一行注释,说明是哪项二维约束迫使你这样做。绝不能仅仅为了居中内容()或为单轴堆叠添加间距()而使用 grid。
flexflex-colgap-*flex items-center justify-centergap-*Flex 之所以是默认选择,是因为它在组合方面更稳定。Flex 子元素本身也可以是 flex container,因此嵌套组件能保持自包含;grid 则会将子元素的放置与父级轨道定义耦合,一旦组件移动,这种耦合就会失效。对于内容尺寸不确定的行和换行行为(),flex 的表现更可预测;而显式 grid 轨道必须在每个断点重新声明。在整个应用中使用统一的布局习惯,可以让审查者始终以相同方式理解间距。
flex-wrap只在真正需要二维布局、且行和列必须在彼此独立的子元素之间对齐时使用 ——例如数据表、固定仪表盘布局、等宽卡片画廊。使用时,应添加一行注释,说明是哪项二维约束迫使你这样做。绝不能仅仅为了居中内容()或为单轴堆叠添加间距()而使用 grid。
gridflex items-center justify-centergap-*所有 URL 都存放在 中——该模块导出 (SPA 路径)、(绝对 REST 路径)、,以及根据 推导的 /。每个条目都是函数,插值段必须在该处通过 编码,而不能在调用点编码。其他任何位置都不得出现 URL 字面量; 只能由 导入。只有带 前缀的变量可以进入浏览器 bundle。
src/hooks/useRoutes.tsroutesapiRouteswsRoutesimport.meta.env.VITE_API_URLAPI_BASEWS_BASEencodeURIComponentapiRouteslib/api/*.tsVITE_状态管理默认使用 中的普通 hook。 只有当两个或更多同级子树需要同一个实例时,才使用 context module,例如 session、wallet、多步骤向导或共享 socket。Context module 始终由四个文件组成(、、、),并包含 undefined 防护 hook。只有真正全局的 provider 才在 中包裹 router;其余 provider 应包裹需要它们的页面,使状态在导航后重置。
src/hooks/XContext.tsxXContext.types.tsXContextController.tsxuseXContext.tsxmain.tsx认证采用 Better Auth( 客户端),并通过唯一且始终全局的 对外提供。该 Controller 会将 JWT 写入 ,并为 // 路由 guard 提供支持。
better-auth/reactAuthContextControllerlib/api/client.tsRequireAuthRequireVerifiedEmailRequireHandle这三部分的完整模板与规则见 。
references/frontend-patterns.mdSEO — 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:
- — full brand default
index.html(absolute<head>/og:image).og:url - component — per-route title/canonical/
<Seo>, hoisted natively by React 19. Fixes in-app titles and Googlebot; not social cards.noindex - — a Cloudflare Worker on the entity route that sniffs the User-Agent, fetches the API's per-entity
edge-worker/for known crawlers, andshare.htmlfor everyone else. The API also servesreturn fetch(request). SEO copy lives once inog-image.png, imported by both.packages/shared
The Worker is deliberately not a Bun workspace: separate deploy lifecycle,
own , instead of , outside
. Skip layer 3 entirely if nothing is publicly shareable; skip all
of it and use SSR/prerender if the app is content-driven.
bun.lock@cloudflare/workers-typesbunbun run checkFull worker, renderer, escaping/canonical rules, ,
DNS prerequisites, robots.txt and verification: .
share.htmlwrangler.tomlreferences/seo-edge-worker.mdVite SPA 只包含静态 shell,而社交媒体和 AI 爬虫不会执行 JavaScript,因此每次分享都会渲染同一张通用卡片。无需 SSR 即可通过三层机制解决:
- ——提供完整的品牌默认
index.html(使用绝对的<head>/og:image)。og:url - 组件——按路由设置标题、canonical URL 和
<Seo>,由 React 19 原生提升。它能修复应用内标题和 Googlebot 展示,但不能修复社交卡片。noindex - ——Cloudflare Worker 位于实体路由上,通过 User-Agent 识别爬虫;针对已知爬虫获取 API 为对应实体生成的
edge-worker/,其他人则执行share.html。API 同时提供return fetch(request)。SEO 文案只在og-image.png中维护一次,并由两端导入。packages/shared
该 Worker 有意不作为 Bun workspace:它采用独立的部署生命周期、独立的 、使用 而不是 ,并且不纳入 。如果没有任何公开可分享的内容,可以完全省略第 3 层;如果应用由内容驱动,则应省略所有这些机制,改用 SSR/预渲染。
bun.lock@cloudflare/workers-typesbunbun run check完整 Worker、 渲染器、转义/canonical 规则、、DNS 前置条件、robots.txt 和验证方式见 。
share.htmlwrangler.tomlreferences/seo-edge-worker.md8. apps/worker
8. apps/worker
Bun long-running process, no HTTP port: .
Own like every other workspace, exporting a resolved
alongside (poll intervals, thresholds, kill switches) with raw
secrets kept off it.
Loop body must be idempotent — restarts it mid-cycle. If it touches
money or external side effects, guard it (see the dev.sh interlock below).
"dev": "bun --watch src/index.ts"src/config/env.tsconfigenv--watchBun 长时间运行进程,不使用 HTTP port:。与其他 workspace 一样,它拥有自己的 ,并在导出 的同时导出解析后的 (轮询间隔、阈值、kill switch),且原始密钥不得放入其中。
"dev": "bun --watch src/index.ts"src/config/env.tsenvconfig循环主体必须是幂等的—— 可能会在循环中途重启进程。如果循环涉及资金或外部副作用,必须设置保护机制(见下文 dev.sh 中的联锁检查)。
--watch9. dev.sh
9. dev.sh
Runs the whole stack on ports derived from so parallel
workspaces don't collide. .
CONDUCTOR_PORTchmod +x dev.shbash
#!/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根据 推导端口并运行整个技术栈,避免并行 workspace 发生端口冲突。执行 。
CONDUCTOR_PORTchmod +x dev.shbash
#!/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
fiProduction-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 -nwait -n
is unavailable in macOS's bundled bash 3.2 — poll instead.
wait -nwhile 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
.env*.env.prodNever use a bare .env*
glob: it also matches .env.prod
, putting the
.env*.env.prodproduction credential on disk in every workspace while run
starts the worker.
runproduction credential on disk in every workspace while run
starts the worker.
runfile_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"
undefinedfile_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"
undefined11. 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:
. The three obligations:
references/code-comments.md- 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).
- 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.
- 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- 每个文件开头都要有 header block——2 至 5 行:说明文件用途、职责范围,以及明确不承担的职责。不要复述文件路径,也不要描述当前实现(后者会在第一次重构后过时)。
- 每个导出的函数、常量、类型和组件都必须带有文档注释——说明用途,以及类型无法表达的信息:单位、范围、副作用、失败模式、顺序要求。裸露的数值常量没有人能安全修改;应说明它的含义,以及修改后会破坏什么。
- 决策注释应放在体现该决策的那一行——函数签名说明做什么,注释则说明为什么选择这种做法,而不是显而易见的替代方案。被否决的替代方案在代码中不可见,因此注释是唯一能阻止下一位 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:
| Tag | Means |
|---|---|
| deferred work — say what it blocks or degrades |
| spec was silent — say what was assumed and what changes if it's wrong |
| needs a human decision — say the question and the current placeholder behaviour |
| 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 — a tracker entry with no
code anchor gets closed without the code being touched. Prefer an
/ over stopping to ask: make the reasonable call, document
it, finish the work. Before handing work over, run the sweep and surface the
hits in the PR body:
TODO.mdASSUMPTIONCONFIRMCONFIRM: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 一定会作出判断并推迟部分工作。这些内容绝不能静默处理,也不能只写在聊天回复中——回复会被滚动淹没,而注释会随代码一起交付。四种标签都必须说明其后果:
| 标签 | 含义 |
|---|---|
| 已推迟的工作——说明它会阻塞或削弱什么 |
| 规范未作说明——说明采用了什么假设,以及假设错误时会发生什么 |
| 需要人工决策——说明问题以及当前占位行为 |
| 已知错误但仍然交付——说明原因以及正确形态 |
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.在确切位置添加标签,绝不能单独维护 ——没有代码锚点的跟踪项可能在代码尚未修改时就被关闭。优先使用 /,而不是停下来询问:作出合理判断、记录判断并完成工作。交付工作前,应运行检查并在 PR 正文中列出所有 :
TODO.mdASSUMPTIONCONFIRMCONFIRM:bash
git grep -nE "TODO:|FIXME:|ASSUMPTION:|CONFIRM:"修改函数时,应在同一次处理中重新阅读其文档注释——与代码相矛盾的注释比没有注释更糟糕,因为人们会相信它。
12. Tests
12. 测试
bun:testsrc/**/<unit>.test.tslib/ts
import { describe, expect, test } from "bun:test";使用 ,不需要 runner 配置。将 与对应单元就近 colocate。测试 中的纯函数和共享 schemas;跳过 HTTP 往返测试。
bun:testsrc/**/<unit>.test.tslib/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 ( can be a symlink
to ):
CLAUDE.mdAGENTS.md- Run at the repo root after any TS/JS edit; fix all errors.
bun run check - 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:tag at the exact site — stating its consequence. Never leave a gap only in the chat reply.FIXME: - Editing a drizzle schema: run , never hand-write migrations.
bun run db:generate - New API routes go in their own file under and declare
apps/api/src/routes//body/queryusing schemas fromresponse— never an inline shape. Payload shapes are declared there and nowhere else.@<proj>/shared/schemas - Every frontend URL goes through ; components call
useRoutes.ts, notlib/api/*. Those wrappersfetchthe reply with the same shared schema — never cast a payload..parse() - Base UI primitives come from coss UI (); consult that skill before writing any component. Never hand-roll a button, input, dialog, or other primitive the library already ships.
bunx --bun skills add cosscom/coss - Every icon is a named import from , sized/coloured with Tailwind classes. No inline SVG, no second icon package.
lucide-react - 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 zod schema (with a comment and a default where sensible) and to
src/config/env.ts. Never read.env.example/process.envanywhere else.import.meta.env - Never . Import
console.logfromlogger, or take@<proj>/shared/loggeroff the Elysia context inside a route so the request id is carried.{ log } - Auth is Better Auth. Don't add a second auth SDK or hand-roll sessions.
- Layout is flexbox (,
flex,flex-col), not CSS grid — see the layout rule above.gap-*only for a genuine two-axis table-like layout, with a comment.grid - Add props to significant HTML elements/components.
id
将工作约定提交到版本库,确保 agent 遵循它( 可以是指向 的符号链接):
CLAUDE.mdAGENTS.md- 每次编辑 TS/JS 后,在仓库根目录运行 ;修复所有错误。
bun run check - 按 §11 的要求添加注释:每个文件都有 header block,每个导出项都有文档注释,并在每一个不明显选择出现的位置添加决策注释。所有推迟事项、规范未明确时的静默判断,以及需要人工确认的内容,都必须在确切位置标记为 /
TODO:/ASSUMPTION:/CONFIRM:,并说明后果。绝不能只在聊天回复中留下缺口。FIXME: - 编辑 drizzle schema 时:运行 ,永远不要手写 migrations。
bun run db:generate - 新 API 路由放在 下的独立文件中,并使用
apps/api/src/routes/中的 schema 声明@<proj>/shared/schemas/body/query——绝不能内联定义结构。Payload 结构只能在那里声明。response - 所有前端 URL 都必须经过 ;组件调用
useRoutes.ts,而不是lib/api/*。这些包装器使用同一个共享 schema 对响应执行fetch——绝不能强制转换 payload。.parse() - 基础 UI primitive 来自 coss UI();编写任何组件前都要查阅该 skill。绝不能手工实现库中已经提供的 button、input、dialog 或其他 primitive。
bunx --bun skills add cosscom/coss - 所有图标都从 按名称导入,并使用 Tailwind class 设置尺寸和颜色。不得内联 SVG,也不得引入第二个图标包。
lucide-react - 新增共享状态时:默认使用普通 hook;只有需要共享同一实例时才使用 context module,并且必须包含全部四个文件。
- 新增 env 变量时:将其添加到对应 workspace 的 zod schema 中(添加注释,并在合理时提供默认值),同时添加到
src/config/env.ts。其他任何地方都不得读取.env.example/process.env。import.meta.env - 永远不要使用 。从
console.log导入@<proj>/shared/logger,或者在路由中从 Elysia context 取出logger,从而携带 request id。{ log } - 认证使用 Better Auth。不要引入第二个 auth SDK,也不要手工实现 session。
- 布局使用 flexbox(、
flex、flex-col),而不是 CSS grid——遵循上述布局规则。只有真正需要二维表格式布局时才使用gap-*,并添加注释。grid - 为重要的 HTML 元素/组件添加 props。
id
Bootstrap order
Bootstrap 顺序
- the tree, write root files,
mkdir.git init - first (env loader + schemas + logger), then
packages/shared(it imports shared).packages/db - ,
apps/api,apps/web. Runapps/workerbefore writing anybunx --bun skills add cosscom/cossUI, then follow that skill's install steps.apps/web - Better Auth: config in ,
apps/apiintobunx @better-auth/cli generate, re-export frompackages/db/src/schema/auth.ts.schema/index.ts - listing every key in every zod env schema;
.env.examplelocally..env.local - at root, then
bun install.bun run --filter '@<proj>/db' db:generate && db:migrate - — must pass clean before the first commit, and
bun run checkreviewed (§11): every gap left in the scaffold is tagged at its site, and thegit grep -nE "TODO:|FIXME:|ASSUMPTION:|CONFIRM:"hits are the handover list.CONFIRM:
- 创建目录树,编写根目录文件,执行
mkdir。git init - 先完成 (env loader + schemas + logger),然后完成
packages/shared(它会导入 shared)。packages/db - 完成 、
apps/api、apps/web。编写任何apps/workerUI 之前,先运行apps/web,然后遵循该 skill 的安装步骤。bunx --bun skills add cosscom/coss - Better Auth:在 中配置,运行
apps/api生成到bunx @better-auth/cli generate,并从packages/db/src/schema/auth.ts重新导出。schema/index.ts - 创建 ,列出每个 zod env schema 中的所有键;本地创建
.env.example。.env.local - 在根目录运行 ,然后运行
bun install。bun run --filter '@<proj>/db' db:generate && db:migrate - 运行 ——首次提交前必须干净通过,并审查
bun run check(§11):脚手架中遗留的每个缺口都应在对应位置标记,所有git grep -nE "TODO:|FIXME:|ASSUMPTION:|CONFIRM:"项都应作为交付清单列出。CONFIRM: