Files
fullstack-starter/AGENTS.md
T
imbytecat e28fe9dc7b perf(compile): 启用 bytecode + minify + inline sourcemap
- Bun 官方 bytecode caching:中型应用 startup ~2x(docs.bun.sh/docs/bundler/bytecode)
- minify:减小 bytecode 体积,二进制仅 +2MB sourcemap
- sourcemap inline:嵌入二进制,保证错误堆栈可读,并在 compile.ts 清理 bundler 残留的 *.js.map
2026-04-25 14:05:35 +08:00

18 KiB

AGENTS.md

Compact, repo-specific notes for AI agents. Generic language/framework knowledge is omitted — only things that will bite you if you don't know. Pair this with README.md (user-facing quick-start + add-a-feature checklist + deploy flow).

Stack & runtime

  • Bun-only (mise.toml pins bun = 1.3.13). Never invoke npm/npx/node/yarn/pnpm. Use bun run <script> (bare bun <script> can collide with Bun built-in subcommands).
  • TanStack Start (React 19 SSR, file-routed) + Vite 8 + Nitro (nightly, preset bun). Vite dev port is strict 3000.
  • PostgreSQL + Drizzle ORM 0.45.2 (0.x, NOT 1.0 beta) — see "Drizzle" section, this matters a lot.
  • ORPC (contract-first), TanStack Query v5, Tailwind v4.

Scripts

bun run dev          # bunx --bun vite dev  (localhost:3000)
bun run build        # bunx --bun vite build → .output/
bun run compile      # bun compile.ts → out/server-<target> (standalone CLI binary)
bun run cli <cmd>    # bun bin.ts <cmd>  — run a CLI subcommand in source (dev)
bun run typecheck    # tsc --noEmit
bun run test         # bun test  — runs all *.test.ts files (colocated with source)
bun run fix          # biome check --write  (lint + format + organize imports)
bun run db:push      # dev only — push schema to DB, no migration file
bun run db:generate  # produce SQL migration files in ./drizzle
bun run db:migrate   # apply migrations via drizzle-kit (local convenience)
bun run db:studio    # Drizzle Studio

Cross-compile targets live under compile:{linux,darwin,windows}[:arch]. compile.ts accepts --target bun-<os>-<arch>; default derives from host.

Before committing: bun run fix && bun run typecheck && bun run test. No CI, no pre-commit hooks, no lint-staged — so these are on you.

Drizzle (v0.x — critical)

Why it matters: the project was on 1.0 beta and was rolled back. Online docs default to 1.0 beta APIs that do NOT exist here. If typecheck complains, you are probably importing a 1.0 beta API.

  • Driver: drizzle-orm/postgres-js. Do NOT use drizzle-orm/bun-sql.
  • drizzle() is called with { connection, schema } where schema = import * as schema from '@/server/db/schema'. There is no relations.ts and no defineRelations in 0.x.
  • Zod generators live in the separate drizzle-zod package (^0.8.3). Import from drizzle-zod, not drizzle-orm/zod (that subpath only exists in 1.0 beta).
  • Relational queries use RQB v1 callback syntax:
    db.query.todoTable.findMany({
      orderBy: (t, { desc }) => desc(t.createdAt),
    })
    
    Do NOT use the v2 object form (orderBy: { createdAt: 'desc' }, where: { id }) — it won't type-check.
  • To add relations later: declare per-table with relations() from drizzle-orm and export them from the same file as the table; they get picked up automatically because index.ts does drizzle({ schema }) via import *.
  • Every table must spread ...generatedFields from src/server/db/fields.ts (id UUIDv7 via $defaultFn(uuidv7), createdAt, updatedAt with $onUpdateFn). generatedFieldKeys (hand-written as const) feeds createInsertSchema(...).omit(...).
  • src/server/db/index.ts exports a module-level const db = drizzle(...) — not a lazy singleton. On Bun this is a long-lived process, so top-level side effects are fine and requested. Don't reintroduce getDB/closeDB ceremony; the Nitro shutdown plugin calls db.$client.end() directly. (Cloudflare Workers would need per-request init — we don't support that deployment target.)
  • drizzle.config.ts runs outside Vite — @/* path aliases do NOT resolve there. It currently does import { env } from './src/env' (relative). Preserve that.
  • The ./drizzle/ migrations directory ships empty with a .gitkeep. Migrations are applied via the CLI (./server migrate, see "CLI & single-binary deploy" below), NOT at server startup. Dev uses db:push. Don't mix push and migrate on the same DB.

CLI & single-binary deploy

bun run compile produces a single executable that dispatches subcommands via citty. Entry is bin.ts at repo root, subcommands live in src/cli/.

./server [serve]   # default — start the HTTP server
./server migrate   # apply migrations from ./drizzle
./server --help

Nitro side-effect pitfall (important). Under the bun preset, .output/server/index.mjs has a top-level serve(...) call — merely importing it starts the HTTP server. bin.ts therefore must not eager-import any subcommand module, and src/cli/serve.ts reaches .output/server/index.mjs through the src/cli/_serve-nitro.mjs bridge (with _serve-nitro.d.mts for types, since .output/ doesn't exist at typecheck time). Citty's subCommands: { x: () => import('...') } lazy-loader is what keeps --help and migrate from booting the server.

Citty eager-loads subcommand modules for --help to read each subcommand's meta. So every src/cli/*.ts module body must be side-effect-free: do NOT static-import @/env, @/server/db/*, or anything that reads env at module-load time. Use await import('@/env') inside run(). Otherwise ./server --help (or any subcommand's help) will fail with env validation errors before printing.

Add a subcommand: drop a file in src/cli/ that default-exports defineCommand({...}), then register it in bin.ts's subCommands with a () => import(...) thunk. Keep top-level imports limited to citty + Node built-ins; pull env / db / etc. via await import(...) inside run().

Deploy flow is always migrate-then-serve. The compiled binary bundles neither the ./drizzle/ SQL files nor the app schema migrations at runtime — they're read from disk next to the binary. Dockerfile copies drizzle/ alongside ./server, and compose.yaml models this with a one-shot migrate service that app depends_on: service_completed_successfully. On k8s, run ./server migrate as an initContainer or a Helm pre-upgrade Job; run ./server (= ./server serve) as the main container.

Compile flags

compile.ts builds with --minify --bytecode --sourcemap=inline:

  • bytecode — pre-compiles JS to bytecode and embeds it in the binary; ~2x startup on app-sized binaries (Bun docs benchmark). Requires --compile; top-level await must live inside async functions (it already does in this repo).
  • minify — shrinks the binary and the bytecode it derives from.
  • sourcemap: 'inline' — embeds the source map in the binary so error stack traces stay decodable. Bun also writes a residual out/bin.js.map next to the output; compile.ts removes it so the binary is the only artifact.

ORPC

Contract → Router → Handler → Client, all type-safe from a single contract.

  • os is built in src/server/api/server.ts via implement(contract).$context<BaseContext>(). Always import os from @/server/api/server, never from @orpc/server directly. ORPCError, onError, ValidationError come from @orpc/server.
  • Contracts (src/server/api/contracts/*.contract.ts) generate Zod from Drizzle tables via drizzle-zod:
    const insertSchema = createInsertSchema(todoTable).omit(generatedFieldKeys)
    
    Barrel-aggregated in contracts/index.ts as export const contract = { todo }.
  • Routers (src/server/api/routers/*.router.ts) import db directly from @/server/db in their handlers. There is no middlewares/ directory by default — db doesn't need one (module-level const). When you actually need per-request context (auth, tenant, rate-limit), create src/server/api/middlewares/<name>.middleware.ts with os.middleware(...) and extend BaseContext in context.ts.
  • Interceptors are attached at the handler level, not in server.ts and not on os. Both src/routes/api/rpc.$.ts (RPCHandler) and src/routes/api/$.ts (OpenAPIHandler) register [onError(logError)] (server) and [onError(handleValidationError)] (client). The validation interceptor rewrites BAD_REQUEST + ValidationError into INPUT_VALIDATION_FAILED (422) and output validation errors into OUTPUT_VALIDATION_FAILED. logError calls logger.error from @/server/logger — never console.* directly.
  • OpenAPI/Scalar: docs at /api/docs, spec at /api/spec.json (handler prefix /api, plugin paths /docs and /spec.json).
  • SSR isomorphism (src/client/orpc.ts): createIsomorphicFn().server(createRouterClient(...)).client(new RPCLink(...)). Server branch reads getRequestHeaders() for context; client branch POSTs to ${origin}/api/rpc.
  • Mutation invalidation is colocated at the call site via mutationOptions({ onSuccess }), not in src/client/orpc.ts. orpc in src/client/orpc.ts is a plain createTanstackQueryUtils(client) — no experimental_defaults. Per-feature query helpers live in src/client/queries/<feature>.ts (e.g. useInvalidateTodos); routes/components compose those hooks rather than holding query keys inline. See src/client/queries/todo.ts + src/routes/index.tsx for the canonical shape.
  • SSR prefetch in route loaders: await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions()). Components use useSuspenseQuery(orpc.feature.list.queryOptions()).

Code style (Biome)

  • 2-space, LF, single quotes, semicolons as-needed (omitted unless required), 120-col, arrow parens always, useArrowFunction: "error" (covers function expressions only). Also noReactPropAssignments: "error".
  • Route components use function Foo() declarations, placed below the Route config so the file reads top-down (route on top, component below). This is the official TanStack Router/Start pattern and relies on hoisting — const Foo = () => {} would TDZ-error when referenced from createFileRoute({ component: Foo }) above it. Inline arrows are fine for trivial leaf components (e.g. plain redirect routes). Non-route components (UI primitives in src/components/) use const Foo = () => {}.
  • Imports are auto-organized into two groups (external, then @/*), each alphabetical, with import type interleaved (NOT a separate group). bun run fix handles this; don't hand-sort.
  • Files: utils kebab-case.ts, components PascalCase.tsx.
  • routeTree.gen.ts is generated — ignored by Biome, never edit.

Testing

bun test runs all *.test.ts files. Tests are colocated next to the source they exercise (e.g. src/server/api/contracts/todo.contract.test.ts). Use import { describe, expect, test } from 'bun:test'. The @/* alias resolves in tests via tsconfig paths. No Vitest, no Jest, no separate test config — keep it that way unless you need a browser/JSDOM environment.

Endpoints

  • / — Todos UI (file route).
  • /health — bare GET returning ok (200, text/plain). Liveness only — no DB check, so it stays green even when Postgres is down. Add a separate /ready if you ever need readiness.
  • /api/rpc — ORPC RPC handler (POST).
  • /api/* — ORPC OpenAPI handler. Docs at /api/docs, spec at /api/spec.json.

TypeScript

Strict mode, plus noUncheckedIndexedAccess, verbatimModuleSyntax, erasableSyntaxOnly, noImplicitOverride. No as any / @ts-ignore / @ts-expect-error.

Path alias: @/* → src/*. For files outside src/ use @/../<file> (example in the codebase: src/routes/api/$.ts imports name, version from @/../package.json).

Env

src/env.ts via @t3-oss/env-core. Server: DATABASE_URL (required, z.url()). client: {} is empty by default — any client-side env must be VITE_-prefixed. Never commit .env.

Docker / deploy

  • Multi-stage: oven/bun:1.3.13 builds and runs bun compile.ts, then gcr.io/distroless/cc-debian13:nonroot runs the single ./server binary. The cc (glibc) distroless variant is required because Bun's compiled binary links glibc.
  • drizzle/ folder is copied into the runtime image so ./server migrate can find migrations at runtime.
  • compose.yaml: one-shot migrate service runs ./server migrate with restart: "no", then app starts (depends_on: migrate: service_completed_successfully). DATABASE_URL=postgres://postgres:postgres@db:5432/postgres for both.
  • Distroless has no shell, so any init-then-serve pattern must use exec-form command: [...], not sh -c.

Layout (non-obvious parts only)

src/
├── client/
│   ├── orpc.ts                 # isomorphic ORPC client + TanStack Query utils (no global invalidation defaults)
│   └── queries/                # per-feature query hooks: keys, options, `useInvalidate<Feature>` helpers
├── cli/                        # CLI subcommands (loaded lazily by bin.ts via citty)
│   ├── serve.ts                # `./server serve` — imports the Nitro bridge on demand
│   ├── migrate.ts              # `./server migrate` — drizzle migrate against ./drizzle
│   ├── _serve-nitro.mjs        # bridge: `import('../../.output/server/index.mjs')`
│   └── _serve-nitro.d.mts      # types for the bridge (build output has no .d.ts)
├── routes/
│   ├── __root.tsx              # root route + RootDocument shell
│   ├── index.tsx               # Todos UI
│   ├── health.ts               # GET /health → "ok" (no DB)
│   └── api/
│       ├── $.ts                # OpenAPI + Scalar; interceptors registered here
│       └── rpc.$.ts            # RPC; interceptors registered here
├── server/
│   ├── logger.ts               # the only log entrypoint — wrap before swapping to pino/otel
│   ├── api/
│   │   ├── server.ts           # the ONLY place to build `os`
│   │   ├── context.ts          # BaseContext (add per-request fields when you add middlewares)
│   │   ├── interceptors.ts     # logError (→ logger), handleValidationError
│   │   ├── types.ts            # Router{Client,Inputs,Outputs} derived from Contract
│   │   ├── contracts/          # Zod schemas from Drizzle tables (barrel: contract); colocated *.test.ts
│   │   └── routers/            # os.* handlers (barrel: router) — import db directly
│   ├── db/
│   │   ├── index.ts            # module-level `export const db = drizzle({...})`
│   │   ├── fields.ts           # generatedFields (id/createdAt/updatedAt) + generatedFieldKeys
│   │   └── schema/             # pgTable definitions; also put `relations()` here when adding
│   └── plugins/
│       └── shutdown.ts         # SIGINT/SIGTERM → db.$client.end() with 500ms delay (prod only)
├── components/                 # non-route UI primitives (PascalCase, arrow const)
├── env.ts                      # t3-oss env validation
├── router.tsx                  # QueryClient + setupRouterSsrQueryIntegration
├── styles.css                  # Tailwind v4 entry
└── routeTree.gen.ts            # auto-generated, do not edit
bin.ts                          # citty entry (root) — keep imports minimal (see "CLI" section)
compile.ts                      # `bun build --compile` driver; resolves --target
drizzle/                        # SQL migrations (.gitkeep until first `bun run db:generate`)

Nitro plugins are wired in vite.config.ts (nitro({ plugins: [...] })), not via a Nitro config file.

Don'ts (specific, non-obvious)

  • Don't edit routeTree.gen.ts.
  • Don't eager-import anything from .output/ in bin.ts or any module it statically imports — it starts the HTTP server as a side effect. Subcommands must be lazy via citty's () => import(...) thunks.
  • Don't re-add an auto-migrate Nitro plugin. Migrations are an explicit deploy step via ./server migrate.
  • Don't reintroduce getDB/closeDB or any "lazy DB init" pattern — that's a Cloudflare Workers shape; we deploy on Bun processes.
  • Don't import os from @orpc/server in middleware/routers — always @/server/api/server.
  • Don't import from drizzle-orm/zod (1.0 beta only). Use drizzle-zod.
  • Don't use RQB v2 object syntax, defineRelations, or pass relations to drizzle(). All are 1.0 beta.
  • Don't use drizzle-orm/bun-sql.
  • Don't use @/* aliases in drizzle.config.ts.
  • Don't commit .env.

Room-to-grow rules (discipline for the first real feature)

These keep the starter from setting bad precedents as it grows. Append, don't restructure.

  1. Per-feature client query code — keys, options, useInvalidate<Feature> helpers — lives in src/client/queries/<feature>.ts. Routes/components compose these hooks; they don't hold query keys inline.
  2. Mutation invalidation stays explicit at the mutation call site (mutationOptions({ onSuccess })) or in a feature query helper. Do not reintroduce global experimental_defaults or any implicit cache policy.
  3. Client state defaults to route/component local state. Create a store (zustand or equivalent) only when state is shared across routes or needs persistence.
  4. Middlewares (src/server/api/middlewares/<name>.middleware.ts) derive request-scoped context or gate access. They do NOT orchestrate business flow, hold side effects, or replace handlers/services.
  5. Interceptors (src/server/api/interceptors.ts) do cross-cutting error logging, transport normalization, and validation rewrites. They do NOT read business data.
  6. One file per Drizzle table. Relations live in the same file and are exported as <entity>Relations. No global relations.ts.
  7. One router file per feature (routers/<feature>.router.ts). Only introduce routers/<domain>/index.ts when a domain grows past ~5 router files or needs shared domain helpers.
  8. All server-side logging goes through src/server/logger.ts. Do not call console.error/info/warn directly from business code.
  9. CLI subcommand modules keep top-level imports to citty + Node built-ins. Env, db, and server code are await import(...)-ed inside run() (see bin.ts comment for why).
  10. Every new business feature ships with at least one bun test covering a contract schema, a pure helper, or a router behavior.