A starter template for full-stack apps on Cloudflare Workers with Vite 8, React 19, TanStack Start, Drizzle, Vitest 4, and pnpm. See AGENTS.md for the full command reference.
This README is both descriptive (what the repo enforces today) and prescriptive (the conventions you should keep if you adopt this starter). Commands and config details live in AGENTS.md; this file explains why they exist.
Most sections below carry two extra blocks so the conventions are machine-actionable: Rules (MUST / SHOULD) — the constraints to uphold — and Aligning an existing repo — concrete steps to retrofit a codebase to the convention. Exact dependency versions are intentionally omitted from the prose; package.json is the single source of truth for them.
- Runtime: Cloudflare Workers (via
wrangler+@cloudflare/vite-plugin) - App: React 19, TanStack Start/Router, Chakra UI
- Build: Vite 8
- Tests: Vitest 4 (unit + browser + integration), Playwright (e2e), Stryker (mutation)
- DB: Drizzle ORM + Postgres
- Auth: better-auth
- Lint / format: oxlint, oxfmt
- Typecheck: full source plus layered (
tsconfig.domain.json,tsconfig.infra.json,tsconfig.api.json,tsconfig.webapp.json) - Codebase intelligence: fallow
- Git hooks: lefthook
Versions are pinned in package.json — read them from there, not from this document. See Reproducing this setup in your own repo for grouped install commands.
This repo is developed exclusively inside a dev container. The container owns the inner loop — Postgres, the Vite dev server, Vitest, Playwright browsers, and the testcontainers daemon all run inside it, so two workspaces side-by-side never collide on host ports or share state. Local pnpm install / pnpm dev on the host is unsupported.
Open the workspace and Reopen in Container (VS Code / Cursor with the Dev Containers extension), or:
devcontainer up --workspace-folder .
devcontainer exec --workspace-folder . pnpm devThe first build runs .devcontainer/post-create.sh, which installs pnpm deps and Playwright browsers into per-workspace named volumes, then runs docker compose up -d --wait against the root docker-compose.yml on the container's nested docker daemon. Postgres (and any future app services) are reachable inside the container at localhost:5432 and are not published to the real host.
Run the test suites:
pnpm test # watch mode, all projects
pnpm test:unit # unit
pnpm test:browser # browser (Vitest + Playwright)
pnpm test:e2e # Playwright e2e
pnpm test:coverage # Vitest coverage
pnpm test:mutate # Stryker mutation testsFull static quality gate (lint, format, layer typechecks, full source typecheck, test typecheck, codebase audit):
pnpm run ciSee AGENTS.md for the complete command list and configuration details.
Business logic lives in pure TypeScript. Frameworks, databases, and external services are adapters plugged into ports. The goal is to express domain models and use cases without depending on the runtime, the HTTP server, the database driver, or the UI framework — so any of those can be swapped with near-zero rewrite to the core.
Layers:
src/domain/— one folder per subdomain of the application (billing/,auth/,account/, …). Each subdomain contains its own entities, value objects, use cases, and aports/folder holding the interfaces it needs from the outside world (user-repository.types.ts,event-publisher.types.ts). No imports fromsrc/infra/, no framework globals, no Node/Workers APIs. May import fromsrc/utils/.src/infra/— adapter implementations, grouped by technology concept and then by specific technology:database/postgres/user-repository.ts,messaging/kafka/event-publisher.ts. Files are named after the entity or capability (user-repository.ts,event-publisher.ts) — never the technology, because the folder already denotes it. Nesting the concrete tech inside the concept makes it obvious what each adapter is fulfilling and keeps the swap path (e.g.postgres/→sqlite/) local. Infra depends on domain ports; the reverse is never allowed.src/utils/— thin wrappers over third-party libraries (lodash,date-fns, …). The escape hatch that lets domain code use common utilities without defining a port per library. The utility module is the port; the library is its implementation, swappable at the utility boundary.src/api/— HTTP composition root (Hono). Wires concrete adapters into use cases and exposes them as routes.src/webapp/— React + TanStack Start UI. Calls into the API; composes its own adapters where needed.
The boundary is enforced four times, on purpose:
- Path aliases —
@domain/*and@infra/*intsconfig.jsonmake the layer of every import readable at a glance. - Layered typechecks —
tsconfig.domain.jsoncompiles the domain alone;tsconfig.infra.jsoncompiles domain + infra;tsconfig.api.jsoncompiles the HTTP composition layer with its allowed dependencies;tsconfig.webapp.jsoncompiles the UI layer with its allowed dependencies. If a lower layer reaches upward, the relevant layer typecheck fails before anything else runs.pnpm typecheck:layersruns all four, whilepnpm typecheckchecks the full source project andpnpm typecheck:testchecks tests. - Custom oxlint rule —
starter/domain-no-infra-imports(intools/oxlint-plugins/rules/) blocks both static and dynamic imports fromsrc/infra/**insidesrc/domain/**files. - Fallow custom boundaries —
.fallowrc.jsondefines explicit zones fordomain,infra,api,webapp, andserver, then failsboundary-violationissues in CI.
Any one of the four would catch most mistakes; all four together make the violation loud and local.
Rules (MUST / SHOULD):
src/domain/**MUST NOT import fromsrc/infra/**, framework globals, or Node/Workers APIs. It MAY importsrc/utils/**.- Dependencies MUST flow downward only: infra → domain; api → domain + infra; webapp → domain + api; server → all. (This is the
boundaries.rulestable in.fallowrc.json.) - The domain→infra boundary MUST stay enforced in all four places — don't disable one and lean on the others.
- Infra adapters SHOULD be named for the entity/capability (
user-repository.ts), not the technology; the folder denotes the tech. - Interfaces the domain needs (ports) MUST live in the domain as
*.types.tsand be implemented by infra.
Aligning an existing repo:
- Create
src/domain,src/infra,src/api,src/webapp(andsrc/utilsfor third-party wrappers); add@domain/*and@infra/*totsconfig.jsonpaths. - Add the four layer tsconfigs and a
typecheck:layersscript that runs them in order. - Copy
tools/oxlint-plugins/, rename thestarternamespace, and enable<ns>/domain-no-infra-importsinoxlint.config.ts. - Add
boundaries.zones+boundaries.rulesto.fallowrc.jsonmirroring the dependency direction, withboundary-violation: error.
src/
domain/ # one folder per subdomain; pure business logic + ports
billing/
ports/
invoice-repository.types.ts
invoice.ts
auth/
ports/
user-repository.types.ts
user.ts
account/
...
infra/ # adapters grouped by technology concept, then specific tech
database/
postgres/
user-repository.ts # named for the entity; folder denotes the tech
messaging/
kafka/
event-publisher.ts
utils/ # thin wrappers over 3rd-party libs (lodash, date-fns, ...)
api/ # Hono server — composes domain + infra
webapp/ # React + TanStack Start UI
server.ts # Cloudflare Workers entry
tools/
oxlint-plugins/ # custom lint rules (e.g. domain-no-infra-imports)
test/ # shared test setup (database harness, factories)
e2e/ # Playwright specs
Configuration is read from the runtime, not from process.env scattered through the code. On Workers, Cloudflare injects an Env object (typed as Cloudflare.Env) that Hono exposes per-request as c.env — c.env.HYPERDRIVE.connectionString, c.env.BETTER_AUTH_SECRET, and so on. Bindings and plain vars are declared in wrangler.jsonc (hyperdrive, vars); pnpm typegen regenerates the Cloudflare.Env types from that file so the bindings stay typed.
Secrets and local overrides live in untracked .env / .dev.vars, with .env.example / .dev.vars.example as the committed templates. In Conductor workspaces, .conductor/setup.sh symlinks .env and .dev.vars from the root repo so every parallel workspace shares one set of credentials. Tooling that needs a value at config time fails fast when it's missing — drizzle.config.ts throws if DATABASE_URL is unset rather than running against an undefined database.
Local Postgres comes from the root docker-compose.yml (postgres:18, reachable at localhost:5432), started inside the dev container — never on the host (see Getting started).
wrangler types preserves literal values for plain vars, so a config value like "false" can be emitted as the literal type "false" instead of string. When code intentionally compares an env flag against another value, widen it at the boundary with String(c.env.FEATURE_FLAG) === 'true'. For third-party services, prefer an env-gated stub mode at the src/api/ composition boundary (for example <SERVICE>_STUB_MODE) so local dev and E2E can run without live external credentials while production wiring remains explicit.
Rules (MUST / SHOULD):
- App code MUST read config from the injected runtime env (
c.env), not module-levelprocess.env. Required values MUST fail fast when absent. - Every binding/var MUST be declared in
wrangler.jsonc; runpnpm typegenafter changing it. - Secrets MUST NOT be committed. New ones MUST be added to
.env.example/.dev.vars.exampleas empty placeholders. - Env flag comparisons SHOULD widen literal
wrangler typesoutput withString(...)at the composition boundary. - External adapters SHOULD provide an env-gated in-memory stub for local dev and E2E when the real service is not essential to the flow.
Aligning an existing repo:
- Funnel scattered
process.env.*reads to a single composition boundary that receives the runtime env. - Declare bindings + vars in
wrangler.jsonc; commit.exampletemplates and gitignore the real files. - On Conductor, add
.conductor/setup.shto symlink env files; otherwise document acp .env.example .envstep.
Drizzle ORM is the database adapter (the database/postgres slot in the hexagon). The schema is defined as TypeScript in src/infra/drizzle/schema/, and the row types are inferred from the schema (InferSelectModel / InferInsertModel in src/infra/drizzle/types.ts) — the schema is the single source of truth, so types can never drift from the table definition. The connection is built by a createDatabase(connectionString) factory (src/infra/drizzle/client.ts), so the driver is injected rather than imported as a global singleton.
Migrations are generated from the schema and applied with the db:* scripts (db:generate, db:push, db:migrate, db:studio); drizzle.config.ts points at the schema and reads DATABASE_URL. In production, Cloudflare Hyperdrive pools the Postgres connection at the edge. Integration tests run the real adapter against a Postgres testcontainer (see Testing strategy) — the database is never mocked. The test database schema is created by running the committed drizzle/*.sql migrations through Drizzle's migrator, and test cleanup truncates the public tables discovered from Postgres metadata, so schema changes need a generated migration rather than a parallel hand-written test DDL edit.
Rules (MUST / SHOULD):
- Row/insert types MUST be inferred from the schema, never hand-written in parallel.
- Database access MUST go through the
createDatabasefactory so the connection is injected; no top-level client singletons in domain/api code. - Schema changes MUST be captured as migrations via
pnpm db:generate; applied migrations MUST NOT be hand-edited. - Each repository/adapter SHOULD ship ≥1
*.integration.test.tsagainst a real testcontainer. - Integration test schema setup MUST use the committed Drizzle migrations as the source of truth.
Aligning an existing repo:
- Put the schema in
src/infra/<orm>/schema/; export inferred types from a siblingtypes.ts. - Wrap connection creation in a
createDatabase(connectionString)factory injected at the api boundary. - Add
db:*scripts and a config that reads the DB URL from env and throws if it's missing.
TypeScript runs in full strict mode plus noUnusedLocals, noUnusedParameters, noImplicitReturns, and noFallthroughCasesInSwitch. These live in tsconfig.base.json, which every other config (tsconfig.json, the layer configs, and the per-variant test configs) extends, so the strictness can't be quietly dropped in one corner. On top of that, two patterns keep failures explicit:
- Result over exceptions.
src/domain/shared/result.tsdefinesResult<T, E>— a discriminated union of{ ok: true, value }/{ ok: false, error }— plus anAsyncResult<T, E>alias andok()/err()constructors. Domain operations that can fail return aResultinstead of throwing, so callers must handle both branches and the type narrows onresult.ok. - Parse, don't validate. Untrusted input is parsed into constrained types at the boundary with Zod (
*.schema.ts), wired into HTTP routes via@hono/zod-openapi. Past the boundary, code trusts the types rather than re-checking the same data.
Declaration-only modules carry explicit suffixes (*.types.ts, *.schema.ts) and are coverage-exempt (see Module filename conventions).
Rules (MUST / SHOULD):
strictMUST stay on; don't weaken compiler flags to land a change — fix the types.- Fallible domain operations SHOULD return
Result/AsyncResultrather than throwing. - External input MUST be parsed (Zod) at the boundary into a typed value; downstream code MUST NOT re-validate it.
Aligning an existing repo:
- Turn on
strictplus thenoUnused*/noImplicitReturns/noFallthroughCasesInSwitchflags. - Add a
Resulttype to the domain and adopt it for operations that currently throw on expected failures. - Define Zod schemas at each input boundary; infer the parsed type and drop redundant downstream checks.
Four test types. The filename tells you which, because it reads like the test type it is.
| Type | Filename | Purpose | Tool | Coverage expectation |
|---|---|---|---|---|
| Unit | *.unit.test.ts |
Behavioural units of domain logic | Vitest | 100% on domain |
| Browser | *.browser.test.ts |
UI components interacting with real DOM APIs | Vitest browser mode + Playwright | As needed per component |
| Integration | *.integration.test.ts |
Adapter implementations reaching real third-party boundaries — e.g. a Drizzle repository against a Postgres testcontainer, or an HTTP client against an MSW handler | Vitest (+ Testcontainers / MSW) | At least one per adapter |
| E2E | *.e2e.test.ts |
Full user flows across multiple routes — signup, login, the journeys that must always work | Playwright | Critical flows only (top priority) |
Examples from the repo: src/domain/shared/result.unit.test.ts, src/infra/drizzle/user-operations.integration.test.ts, e2e/home.e2e.test.ts, and the oxlint plugin's tools/oxlint-plugins/rules/domain-no-infra-imports.unit.test.ts.
Unit tests are the load-bearing layer: they run on every staged-file commit and every push, and they drive the 100% domain-coverage expectation. Integration tests verify that adapter code actually talks to the thing it claims to. E2E tests keep the most important journeys honest. Browser tests catch regressions in DOM-dependent behaviour that jsdom-style runners miss.
When testing HTTP code, import and drive the real Hono app from src/api/ or src/server.ts; the unit and integration test TypeScript configs include the generated worker-configuration.d.ts so Cloudflare.Env remains available where server code is tested. Browser tests stay on webapp-facing aliases and should not import infra modules. Layer typechecks are source-only: tests are excluded from tsconfig.domain.json, tsconfig.infra.json, tsconfig.api.json, and tsconfig.webapp.json and checked by the per-variant test configs instead.
Each test type maps to a Vitest project or Playwright (vitest.config.ts defines the unit, browser, and integration projects; the integration project boots a Postgres testcontainer via test/integration/global-setup.ts and an MSW server via test/integration/setup.ts). The principle is mock only what you don't own: third-party HTTP is faked with MSW, while your own database is exercised for real against a container.
Test helpers and per-variant configs. Test helpers are imported through @test-utils/* — a variant-local alias that resolves to a different directory per test type: test/unit/*, test/integration/*, test/browser/*, or e2e/test-utils/*. Each level gets its own helper namespace, so a unit test can't reach for integration-only helpers (e.g. testcontainers) and the deep ../../../test/... relative imports disappear. The alias is wired twice: a per-project resolve.alias in vitest.config.ts for the runtime, and a narrowed paths entry in each test config for the type-checker. Each variant also carries its own TypeScript config — tsconfig.test.unit.json, tsconfig.test.integration.json, tsconfig.test.browser.json, tsconfig.test.e2e.json, all extending the shared tsconfig.base.json — that models its runtime's APIs (node + vitest/globals for unit/integration, DOM + @vitest/browser for browser, node + Playwright for e2e), so the type-checker flags use of an API the runtime doesn't have. tsconfig.test.json aggregates the four through project references, and pnpm typecheck:test fans out to typecheck:test:{unit,integration,browser,e2e}. (Playwright needs e2e/tsconfig.json as a shim, because it resolves path aliases from the nearest tsconfig.json under the test dir.)
Rules (MUST / SHOULD):
- Every test file MUST use exactly one of the four suffixes (
.unit,.browser,.integration,.e2e). The suffix selects the runner and the coverage expectation. - Tests MUST mock only what you don't own — third-party HTTP via the shared MSW
server(imported as@test-utils/msw-server), where unhandled requests fail the test. Things you own (the database) MUST be exercised for real against a testcontainer. - New adapter code MUST ship ≥1
*.integration.test.ts. Domain logic MUST keep its 100% unit-coverage expectation (and the ≥80% mutation score below). - Test data SHOULD come from factories (e.g.
src/infra/drizzle/user-factory.ts), not inline literals; tests SHOULD assert behaviour (given/when/then), not implementation detail. - Test helpers MUST be imported via the variant-local
@test-utils/*alias, not deep relative paths. - Each test variant MUST type-check under its own
tsconfig.test.<variant>.jsonmodelling its runtime; don't add DOM libs to node-only variants, or@infrato the browser/e2e configs.
Aligning an existing repo:
- Define Vitest
projectsfor unit/browser/integration keyed on the suffixes; add Playwright for.e2e. - Add a global setup that starts a DB testcontainer and an MSW server with
onUnhandledRequest: 'error'. - Rename existing tests to the four-suffix scheme and add factories for shared fixtures.
- Add a variant-local
@test-utils/*alias (per-projectresolve.aliasinvitest.config.ts+ a narrowedpathsentry per test config), and splittsconfig.test.jsoninto per-variant configs that extend a sharedtsconfig.base.json.
Mutation testing is the test-quality gate. Line coverage can show that tests executed code; Stryker checks whether those tests would fail if the code's behaviour changed.
Run it with:
pnpm test:mutateConfiguration lives in stryker.config.mjs. Stryker uses vitest.mutation.config.ts, not the main vitest.config.ts, so mutation testing runs only fast Node-based unit tests. This is deliberate: Stryker's Vitest runner does not support Vitest Browser Mode, and integration/e2e suites are too slow and environment-heavy for the mutation loop.
Current mutation scope is src/domain/** and src/api/**, excluding all test suffixes, declarations, and declaration-only *.types.ts modules. Schema modules stay in scope when they live under mutated layers because schemas often encode runtime validation or API contracts. Expand the mutate globs only when the new code has fast behavioural unit tests; do not add browser, integration, generated, or adapter-only code to the default mutation target set. Composition or adapter code that is intentionally covered only by integration tests should be excluded from mutation rather than pulled into the unit-only mutation runner.
The mutation score must stay at or above 80%. thresholds.break enforces this in Stryker, so pnpm test:mutate and the mutation CI workflow fail below that floor. HTML reports are written under reports/ for local investigation.
Rules (MUST / SHOULD):
- The mutation score MUST stay ≥ 80% (
thresholds.break); bothpnpm test:mutateand the mutation workflow fail below it. - The
mutateglobs MUST stay scoped tosrc/domain/**+src/api/**(tests/declarations excluded); add globs only for code with fast behavioural unit tests. - Mutation MUST run only the unit project (
vitest.mutation.config.ts) — no browser/integration/e2e in the loop.
Aligning an existing repo:
- Add
@stryker-mutator/core+ the Vitest runner; point Stryker at a unit-only Vitest config. - Set
thresholds.breakto your floor (80% here) and scopemutateto the business-logic layers. - Gate it in CI on changes to those layers (see Continuous integration).
oxlint (type-aware: typeAware + typeCheck in oxlint.config.ts) is the linter; oxfmt is the formatter (semi: false, singleQuote: true). Both are Rust-native and run in milliseconds, which is what makes them viable inside the pre-commit hook. Architectural rules a per-file linter can't normally express are added as a local JS plugin under tools/oxlint-plugins/ — currently starter/domain-no-infra-imports (see tools/oxlint-plugins/README.md for the rule and how to add more). Generated files (src/webapp/routeTree.gen.ts, worker-configuration.d.ts, dist/**) are ignored by both.
Each tool has a write variant and a check variant — lint / lint:check, format / format:check: the write variants fix locally, the :check variants are read-only for CI. Editors format on save through the oxc language servers (.vscode/settings.json, .zed/settings.json, the latter also disabling Prettier). Markdown — including this file — is outside the oxlint/oxfmt globs, so it isn't auto-formatted.
Rules (MUST / SHOULD):
pnpm lint:checkandpnpm format:checkMUST pass with zero findings before merge; CI runs the read-only variants.- Project-specific architectural constraints SHOULD be encoded as oxlint plugin rules (with a co-located
*.unit.test.ts), not left to review. - Generated files MUST be listed in both tools'
ignorePatterns.
Aligning an existing repo:
- Add
oxlint+oxfmt, createoxlint.config.ts/oxfmt.config.ts, and ignore generated files. - Add the four script pairs (
lint/lint:check,format/format:check). - Port custom rules into
tools/oxlint-plugins/and enable them under your own namespace.
Chakra v3 provides low-level primitives; the starter vendors the common CLI snippets in src/webapp/components/ui/ so a first real screen can use Button, Field, and the app-mounted Toaster without recreating the snippet path. Add more snippets with pnpm dlx @chakra-ui/cli snippet add <name> and move generated files under src/webapp/components/ui/ if the CLI writes to its default src/components/ui path.
Route protection should keep authorization enforceable on the server. The default pattern is a pathless TanStack _authed layout using useSession for client-side navigation UX, backed by explicit session checks on every protected API route. If you use an SSR beforeLoad guard instead, forward the incoming request cookie header to better-auth getSession; otherwise server-rendered route loads can see an anonymous request even when the browser has a valid session cookie.
Rules (MUST / SHOULD):
- Shared Chakra snippets SHOULD live under
src/webapp/components/ui/and be mounted through the existingProviderwhen they are app-level services like toasts. - Protected data and mutations MUST be enforced in API routes; client route guards are UX, not authorization.
- SSR auth guards MUST forward request cookies when calling better-auth session APIs.
Filename suffixes encode module intent. They act as machine-readable tags: tooling can treat them uniformly (coverage exemptions, lint overrides, test discovery) and readers can see the shape of a module before opening it.
| Pattern | Purpose |
|---|---|
*.unit.test.ts |
Unit tests — behavioural units of domain logic. |
*.browser.test.ts |
Browser tests — UI components against real DOM APIs. |
*.integration.test.ts |
Integration tests — adapters against real third-party boundaries. |
*.e2e.test.ts |
End-to-end tests — full user flows across routes. |
*.schema.ts |
Validation schemas (Zod or similar). No branching logic. |
*.types.ts |
Type and interface declarations only — including port interfaces under src/domain/**/ports/. No *.interface.ts suffix. |
*.schema.ts and *.types.ts are exempt from unit-test coverage demands — they contain no behaviour to verify; unit-testing them would only restate the declarations. The explicit suffix makes that exemption self-documenting.
See Testing strategy for the tool and coverage expectation behind each test suffix.
Rule of thumb: if a module contains only declarations, use *.schema.ts or *.types.ts. If it contains behaviour, don't — and use the matching *.test.ts suffix for its tests.
Rules (MUST / SHOULD):
- A declaration-only module MUST use
*.schema.ts(validation schemas) or*.types.ts(types/interfaces, including ports); both are coverage-exempt. - A module containing behaviour MUST NOT use those suffixes, and its tests MUST use the matching
*.test.tssuffix. - Interfaces MUST be
*.types.ts; don't introduce a*.interface.tssuffix.
Aligning an existing repo:
- Rename declaration-only files to
*.types.ts/*.schema.tsand point coverage/mutation exemptions at those globs. - Adopt the four test suffixes before writing new tests.
Scripts in package.json follow a few conventions so the command surface stays predictable; the exhaustive list is in AGENTS.md.
- Namespaced groups. Related commands share a prefix —
test:*,typecheck:*,codebase:*,db:*— so they're discoverable and tab-completable. - Stable names over tools. The
codebase:*scripts are tool-agnostic aliases; fallow is the current implementation behind them (codebase:audit→fallow audit). Hooks and CI call the stable name, so the analyzer can be swapped without touching them.fallow:ciis itself an alias forcodebase:audit. - Composite gates, fail-fast.
ci,fix,typecheck:layers, andtypecheck:testchain sub-commands with&&, so the first failure stops the run and there's one memorable entry point —pnpm run ciis the whole static gate,pnpm fixis lint + format,pnpm typecheck:layersis the four layer checks, andpnpm typecheck:testfans out totypecheck:test:{unit,integration,browser,e2e}(one per test variant). - Check vs write pairs.
lint/lint:checkandformat/format:check— write variants for local dev,:checkvariants for CI so CI never mutates the tree. - Lifecycle hook.
preparerunslefthook install, so git hooks self-install onpnpm install.
Rules (MUST / SHOULD):
- New commands MUST join the right namespace (
test:,typecheck:,codebase:,db:). - Tooling invoked by hooks/CI SHOULD be reached through a stable script alias, not the raw binary, when the tool might be swapped.
- CI-facing scripts MUST be read-only (
:checkvariants); composite gates MUST chain with&&.
Aligning an existing repo:
- Group scripts by prefix and add
ci/fixcomposites. - Put third-party analyzers behind
codebase:*-style aliases. - Add a
preparescript that installs your hook manager.
Every stage has a specific job. Understanding the why matters as much as the commands.
- Pre-commit (lefthook) — runs
oxlint --fixandoxfmt --writeon staged files (auto-restaged), thenvitest related --run --project unitover the staged files andpnpm codebase:auditfor codebase intelligence checks. Why: keeps git history clean and readable (no "fix lint" commits), and ensures every commit is independently releasable — no commit silently breaks the behaviour or architecture of code near the change. - Pre-push (lefthook) —
vitest run --changed origin/master --project unit. Catches regressions across the whole change set before they leave the machine. pnpm run ci(local + CI) —pnpm lint:check && pnpm format:check && pnpm typecheck:layers && pnpm typecheck && pnpm typecheck:test && pnpm codebase:audit. Read-only static quality gate; no fixes, no writes. The source of truth for "does this branch pass static analysis?"- GitHub Actions — runs the same static gate as named jobs plus the test matrix (unit, browser, integration, e2e) and build. See Continuous integration.
- Mutation Tests workflow —
pnpm test:mutateon pull requests that touchsrc/domain/**,src/api/**, unit tests, or mutation config, with manual dispatch available. It fails below an 80% mutation score.
To skip hooks for a single command (e.g. an intentional WIP commit), set LEFTHOOK=0.
Rules (MUST / SHOULD):
- Pre-commit MUST lint + format staged files (auto-restaged), run the related unit tests, and run
codebase:audit. Pre-push MUST run the changed unit tests againstorigin/master. pnpm run ciMUST stay read-only and is the source of truth for static-analysis pass/fail.- Hooks MAY be skipped for one command with
LEFTHOOK=0, but the same checks MUST still pass in CI.
Aligning an existing repo:
- Add lefthook (
prepare: lefthook install) with pre-commit + pre-push jobs mirroring the above. - Add the
cicomposite and run it both locally (pre-merge) and in CI.
GitHub Actions enforces the same gates as the local hooks, plus the full test matrix and build. The workflow files are the source of truth — see .github/workflows/.
ci.yml("CI Tests") runs on every PR tomaster, withconcurrencycancelling superseded runs on the same ref. The static gate is split into separate, parallel named jobs —lint,typecheck,codebase-audit— rather than onepnpm run cicall, so a failure points at the exact check and the jobs run concurrently. The test matrix is likewise parallel jobs:test-unit,test-browser(installs the Chromium binary first), andtest-integration(setsTESTCONTAINERS_RYUK_DISABLED=true, since the ephemeral runner cleans itself up).buildruns standalone, andtest-e2edeclaresneeds: [build]and brings up its ownpostgres:18service.codebase-auditchecks out full history (fetch-depth: 0) and scopes analysis to--base origin/<base-ref>. Every job pins Node 22 and installs withpnpm install --frozen-lockfile.mutation.yml("Mutation Tests") is path-filtered: it runspnpm test:mutateonly whensrc/domain/**,src/api/**, unit tests, or the mutation config change (plus manualworkflow_dispatch), because the mutation loop is too slow to run on every PR.deploy.yml("Deploy") is manualworkflow_dispatchonly — thepushtrigger is commented out until the Cloudflare secrets (CLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_API_TOKEN) are set — and usesconcurrency: cancel-in-progress: falseso an in-flight production deploy is never interrupted.
Rules (MUST / SHOULD):
- The static checks and the test matrix MUST run as parallel jobs on every PR to
master. - Installs MUST use
--frozen-lockfile; every job MUST pin the Node version (22). test-e2eMUSTneeds: [build]and MUST provide its own Postgres service.- Slow gates (mutation) MUST be path-filtered; deploy MUST be guarded (manual trigger + non-cancelling concurrency + secrets).
Aligning an existing repo:
- Add a PR workflow whose jobs mirror
pnpm run ciplus per-suffix test jobs; pin Node and use--frozen-lockfile. - Add a path-filtered mutation workflow and a manual, secret-gated deploy workflow.
- Set
concurrencyto cancel superseded CI runs but never deploys.
- Hono as the HTTP server — deliberately runtime-agnostic. If Cloudflare Workers stops fitting (a cold-start regression, a pricing change, a feature Workers can't express), the Hono app moves to Node / Bun / Deno / a container with near-zero rewrite.
- Cloudflare Workers as the starter runtime — chosen because it's exceptionally cheap and fast out of the box, with a global edge by default. Drizzle + Hyperdrive give Postgres access without managing a connection pool.
- The swap path —
src/api/is the composition boundary. Replacesrc/server.ts(the Workers entry) with a different runtime adapter to change runtime; the app factory does not change.
Rules (MUST / SHOULD):
- All HTTP MUST be defined on the Hono app, never bound directly to a Workers handler, so the runtime stays swappable.
- A runtime change MUST happen only at
src/server.ts(the runtime adapter); the app factory insrc/api/MUST NOT change.
Aligning an existing repo:
- Define your HTTP surface on a runtime-agnostic framework (Hono).
- Keep the runtime entry (
server.ts) as the only runtime-specific file; compose the app insrc/api/.
fallow is a Rust-native, sub-second whole-project analyzer for TypeScript/JavaScript. It finds things oxlint's per-file model can't see:
- Dead code — unused files, exports, types, dependencies
- Duplication — cross-file clone groups
- Complexity hotspots — cyclomatic + cognitive complexity, churn-vs-complexity hotspots
- Circular dependencies
- Architecture drift
Use the codebase:* scripts for stable, tool-agnostic commands — they're aliases (see Scripts), and fallow is the current implementation behind them:
pnpm codebase:analyze # full analysis (dead code + dupes + health)
pnpm codebase:audit # changed-file quality gate for hooks and CI
pnpm codebase:dead-code # unused code + circular deps only
pnpm codebase:duplicates # duplication scan
pnpm codebase:health # complexity + maintainability
pnpm codebase:boundaries # resolved architecture boundary config
pnpm codebase:boundary-violations # architecture boundary violations only
pnpm codebase:fix:dry-run # preview auto-fixes for unused exports/depspnpm run ci runs pnpm codebase:audit (fallow audit) as a quality gate — it scopes analysis to files changed against the base branch and returns a pass/warn/fail verdict. Configuration lives in .fallowrc.json, including custom boundary zones and rules for the hexagonal architecture. Generated files that should not be analyzed directly are ignored, while generated reachability roots such as src/webapp/routeTree.gen.ts, the deliberate public inferred-row-type surface src/infra/drizzle/types.ts, and intentionally vendored UI snippets are listed as entries so routes, shared row types, and template scaffolding do not look unused.
The fast audit does not consume coverage output. Because this template intentionally unit-tests domain/api and browser/integration-tests webapp/infra, .fallowrc.json raises health.maxCrap so zero-coverage CRAP false positives on UI/infra code do not become stricter than the cyclomatic and cognitive complexity gates. Duplication scans use a higher duplicates.minLines threshold because short Testcontainers/MSW setup blocks are acceptable boilerplate, and duplicate-exports is a warning because TanStack route files must each export Route.
For the full feature set, see the fallow docs.
Versions live in package.json — copy them from there. The commands below intentionally omit version pins.
# Package manager (pin the version from package.json)
corepack enable
corepack prepare pnpm@<version-from-package.json> --activate
# App, runtime, build
pnpm add react react-dom @tanstack/react-start @tanstack/react-router @chakra-ui/react @emotion/react
pnpm add -D vite @vitejs/plugin-react @cloudflare/vite-plugin wrangler
# Data, auth, HTTP
pnpm add drizzle-orm postgres hono @hono/zod-openapi zod better-auth
pnpm add -D drizzle-kit
# Lint, format, codebase intelligence, hooks, types
pnpm add -D oxlint oxfmt fallow lefthook typescript
# Testing
pnpm add -D vitest @vitest/browser @vitest/browser-playwright @vitest/coverage-v8 \
@playwright/test testcontainers @testcontainers/postgresql msw \
@testing-library/react @testing-library/jest-dom @testing-library/user-event \
@stryker-mutator/core @stryker-mutator/vitest-runner
# Install git hooks
pnpm run prepareA short checklist for applying these conventions to a greenfield project:
pnpm init, set"packageManager": "pnpm@10.x".- Create
src/domain,src/infra,src/utilson day one, even if empty. Directory shape is a commitment. - Copy
tsconfig.json,tsconfig.app.json, and the layer configs (tsconfig.domain.json,tsconfig.infra.json,tsconfig.api.json,tsconfig.webapp.json); adjust the includes/aliases. - Set up
oxlint.config.tsandoxfmt.config.ts. Copytools/oxlint-plugins/and rename thestarternamespace. - Add
lefthook.ymlwith pre-commit (lint + format +vitest related --project unit+pnpm fallow:ci) and pre-push (vitest run --changed origin/master --project unit). - Add the
pnpm ciscript: lint check → format check → layered typecheck → full source typecheck → test typecheck → fallow audit. - Add Stryker (
@stryker-mutator/core,@stryker-mutator/vitest-runner),stryker.config.mjs,vitest.mutation.config.ts, and thepnpm test:mutatescript with an 80%thresholds.breakfloor. - Adopt the four test-type filename suffixes (
.unit,.browser,.integration,.e2e) before writing any tests. - Adopt
*.schema.ts/*.types.tsbefore introducing any declaration-only module. - Put your HTTP server behind Hono so the runtime stays swappable.
- Wire fallow with
.fallowrc.jsonto catch dead code and duplication as the project grows. - Mirror the local gates in CI: parallel static + test-matrix jobs, path-filtered mutation, and a guarded deploy (see Continuous integration).
AGENTS.md— commands, imports, hook commands, and agent workflow notes.package.json— dependency versions (source of truth) and the full script list..github/workflows/—ci.yml,mutation.yml, anddeploy.ymlpipelines.tsconfig*.json— base strict config plus the layered configs.oxlint.config.ts/oxfmt.config.ts— lint and format configuration.tools/oxlint-plugins/README.md— the custom lint plugin, includingdomain-no-infra-imports..fallowrc.json— fallow configuration (boundary zones and rules).stryker.config.mjsandvitest.mutation.config.ts— mutation testing configuration.wrangler.jsonc— Cloudflare Workers config (bindings, vars).drizzle.config.ts— database schema/migration configuration.lefthook.yml— git hook definitions..devcontainer/— dev container definition and post-create setup.