CLAUDE.md was 1,324 lines and all of it was about the TypeScript half, written before there was another half. Every line of it loaded on every turn regardless of what was being worked on, which is a real cost paid constantly for context that is usually irrelevant. Split by where it applies, so each guide loads when you are in the directory it describes: packages/core/CLAUDE.md 694 domain modules, fn(), actor, errors, auth apps/api/CLAUDE.md 284 routes, registration, error flow docs/alchemy.md 345 stages, bindings, secrets, the CLI CLAUDE.md 72 the repo, both toolchains, two hard rules Nothing was rewritten or dropped — the three files are the original text, verified identical after the split. What the root file now carries is only what is true repo-wide: the layout, the commands, where the detail lives, and the two rules that are not style preferences. One of those is that nothing closed may enter this repo, which is here because it has already been caught once. The README described a streaming platform in four bullets and did not mention that half the repository is Rust that runs inside a virtual machine. It now says what each component does, why a micro-VM rather than a container, what is deliberately absent, and what decides whether a thing is open — data is, capacity is not. It also says plainly that this is mid-rewrite and the docs are behind. Someone arriving at a repo whose documentation does not match its tree should be told that by the README rather than discover it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9.9 KiB
apps/api — routes, errors and the entry point
API Route Pattern (apps/api/app/routes/)
Every API domain is a TypeScript namespace with a .route property — a plain new Hono() instance with chained route definitions.
Route → Domain function flow
HTTP request ──► Route handler (thin) ──► Core domain fn() ──► DB
│ │
│ validates input │ pulls Actor.userID
│ calls domain fn │ handles business logic
│ returns c.json({data}) │ inside Database.transaction()
Route handlers are thin wrappers — they validate input, call a core function, and return the result. All business logic lives in packages/core/src/<module>/.
Creating a route module
// app/routes/<thing>.ts
import { z } from 'zod';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { Thing } from '@nestri/core/thing/index';
import { Examples } from '@nestri/core/examples';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { ErrorResponses, notPublic, Result, validator } from '../utils';
export namespace ThingApi {
export const route = new Hono()
.use(notPublic)
.get(
'/',
describeRoute({
tags: ['Thing'],
summary: 'List things',
description: 'List all things',
responses: {
200: {
content: {
'application/json': {
schema: Result(
Thing.Info.array().meta({
description: 'All things',
example: [Examples.Thing]
})
)
}
},
description: 'All things'
},
400: ErrorResponses[400],
404: ErrorResponses[404],
429: ErrorResponses[429]
}
}),
async (c) => c.json({ data: await Thing.list() })
)
.get(
'/:id',
describeRoute({/* … */}),
validator(
'param',
z.object({
id: z.string().meta({
description: 'ID of the thing',
example: Examples.Thing.id
})
})
),
async (c) => {
const thing = await Thing.fromID(c.req.valid('param').id);
if (!thing) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`Thing ${id} not found`
);
}
return c.json({ data: thing });
}
);
}
Grouped routes: /(group)/(sub-route)
For domains with multiple sub-routes (e.g. Steam with /link, /sync, /unlink), group them under one route file. The namespace name is XxxApi (e.g. SteamApi), the route path is /(group):
// app/routes/steam.ts
import { z } from "zod";
import { Hono } from "hono";
import { describeRoute } from "hono-openapi";
import { Steam } from "@nestri/core/steam/index";
import { ErrorResponses, notPublic, Result, validator } from "../utils";
export namespace SteamApi {
export const route = new Hono()
.use(notPublic)
.post("/link", // → POST /steam/link
describeRoute({ tags: ["Steam"], summary: "Link a Steam account", ... }),
validator("json", z.object({ steamId: z.string() })),
async (c) => {
const { steamId } = c.req.valid("json");
const result = await Steam.link({ steamId }); // ← calls core fn
return c.json({ data: { linkedAccountId: result, steamId } });
},
)
.post("/sync", // → POST /steam/sync
// ...
);
}
Registered in the app entry as /steam:
// app/index.ts
import { SteamApi } from "./routes/steam.js";
const routes = app
.route("/", IndexApi.route)
.route("/users", UserApi.route)
.route("/steam", SteamApi.route) // mounts all /steam/* routes
.onError(…);
This keeps the route path and the namespace name aligned — the Hono instance at SteamApi.route is mounted at /steam.
Key conventions
| Element | Pattern |
|---|---|
| Structure | export namespace XxxApi { export const route = new Hono() … } |
| Group route | POST "/link" at XxxApi → mounted at /xxx → POST /xxx/link |
| Auth guard | .use(notPublic) at the namespace level (or per-route) |
| Route is thin | validates input → calls core fn → returns c.json({ data: … }) |
| Core fn | reusable fn() in packages/core/src/<module>/ owns all logic |
| OpenAPI | describeRoute({ tags, summary, description, responses }) wraps each handler |
| Response schema | Result(Schema) → resolver(z.object({ data: schema })) |
| Error responses | ErrorResponses[statusCode] for 400, 401, 403, 404, 409, 429, 500 |
| Param validation | validator("param", z.object({…})) — uses custom wrapper that formats Zod errors |
| Body validation | validator("json", z.object({…})) — same wrapper for request body |
| Not found | throw new VisibleError("not_found", ErrorCodes.NotFound.RESOURCE_NOT_FOUND, …) |
| Metadata | Use .meta() (NOT .openapi()) — Zod v4 native + zod-openapi v6 |
Registering a route in the app
// app/index.ts
import { SteamApi } from "./routes/steam.js";
import { ThingApi } from "./routes/thing.js";
const routes = app
.route("/", IndexApi.route)
.route("/users", UserApi.route)
.route("/steam", SteamApi.route) // mount group at /steam
.route("/things", ThingApi.route) // mount group at /things
.onError(…);
The first argument to .route() is the URL prefix. All sub-routes defined on that Hono instance are relative to this prefix.
API Utils (apps/api/app/utils/)
| File | Export | Purpose |
|---|---|---|
index.ts |
— | Barrel re-export of all utils |
auth.ts |
auth, notPublic |
Re-exports from middleware/auth |
error.ts |
ErrorResponses |
{ 400, 401, 403, 404, 409, 429, 500 } → OpenAPI response objects |
result.ts |
Result<T> |
resolver(z.object({ data: T })) — standard { data: … } response shape |
validator.ts |
validator |
Wraps hono-openapi/zod's validator with standardized Zod error formatting (400 + error code) |
hook.ts |
Hook, zValidator |
Type declarations (re-exported from @hono/zod-validator) |
Main API Entry (apps/api/app/index.ts)
The entry point wires everything together. Key structure:
import 'zod-openapi'; // augment Zod v4 with OpenAPI metadata types
import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { cors } from 'hono/cors';
import { showRoutes } from 'hono/dev';
import { openAPISpecs } from 'hono-openapi';
import { HTTPException } from 'hono/http-exception';
export const app = new Hono();
// Global middleware (order matters)
app
.use(logger())
.use(async (c, next) => {
c.header('Cache-Control', 'no-store');
return next();
})
.use(cors({ origin: Env.env.FRONTEND_URL || 'http://localhost:5173', credentials: true }))
.use(auth);
// Routes + error handler
const routes = app
.route('/', IndexApi.route)
.route('/things', ThingApi.route)
.onError((error, c) => {
if (error instanceof VisibleError) {
return c.json(error.toResponse(), error.statusCode());
}
if (error instanceof HTTPException) {
return c.json(
{
type: 'validation',
code: ErrorCodes.Validation.INVALID_PARAMETER,
message: 'Invalid request'
},
error.status
);
}
return c.json(
{
type: 'internal',
code: ErrorCodes.Server.INTERNAL_ERROR,
message: 'Internal server error'
},
500
);
});
// OpenAPI spec at /doc
app.get(
'/doc',
openAPISpecs(routes, { documentation: { info: { title: 'API', version: '0.0.1' } } })
);
showRoutes(app);
export default { port: process.env.PORT ?? 3000, fetch: app.fetch };
Dev / production
bun --watch app/index.ts # dev with hot reload
bun app/index.ts # production
No Vite needed — Bun runs TypeScript natively.
Important: .meta() vs .openapi()
| Library | Method | Notes |
|---|---|---|
zod-openapi v4 (old) |
.openapi() |
Required import "zod-openapi/extend" |
zod-openapi v6 (current) |
.meta() |
Native Zod v4 method; no import needed — auto-augments via declare module 'zod/v4' |
- Domain schemas (
@nestri/core/*/index.ts) use.meta()for descriptions/examples. - API route schemas (
apps/api/app/routes/*.ts) use.meta()for OpenAPI response/docs metadata. - Never use
.openapi()— it doesn't exist inzod-openapiv6.
Error flow summary
Route handler
│
├─ throws VisibleError ──► onError → c.json(error.toResponse(), error.statusCode())
│
├─ throws HTTPException ─► onError → c.json({ type: "validation", … }, error.status)
│
├─ throws raw Error ─────► onError → c.json({ type: "internal", … }, 500)
│ (includes VisibleError for Actor model / context issues)
│
└─ returns normally ─────► c.json({ data: … })