Engines
HTTP API
Routes are files. Anything in yatta/backend is matched with a Next.js-style convention, and the parameter types come from the filename.
File routing
yatta/backend/index.ts → GET /yatta/backend/user/index.ts → /useryatta/backend/posts/[id].ts → /posts/:idyatta/backend/posts/[id]/index.ts → /posts/:idyatta/backend/users/[id]/posts.ts → /users/:id/postsA route
import { API, createAPI } from "yatta/api"; const api = createAPI(); api.get(async (ctx) => { return API.json({ user: "ok" });}); api.post(async (ctx) => { const body = await ctx.json(); return API.json({ created: body }, { status: 201 });}); export default api;Dynamic routes
The filename types ctx.params. No casts, no runtime schema.
import { API, createAPI } from "yatta/api"; const api = createAPI(); api.get(async (ctx) => { const id = ctx.params.id; // typed string return API.json({ id });}); export default api;Context
ctx.req // the Requestctx.params // typed from the filenamectx.query() // parsed query stringctx.state // bag for middleware to share valuesctx.headers // request headers await ctx.json(); // body as JSONawait ctx.formData(); // multipart or urlencodedctx.cookies(); // parsed cookiesResponses
API.json({ ok: true });API.json({ error: "Not found" }, { status: 404 });API.text("plain");API.redirect("/login");API.stream(readableStream); API.cookie("session", value, { httpOnly: true, secure: true, sameSite: "lax", maxAge: "7d",});Validation
Pass a schema to ctx.json or ctx.formData. Zod, Valibot and ArkType are all supported.
import { z } from "zod"; api.post(async (ctx) => { const body = await ctx.json( z.object({ name: z.string().min(1), email: z.string().email(), }), ); return API.json({ ok: true, name: body.name });});Warning
A validation failure throws
ValidationError, which the router turns into a 400. The underlying parser error is preserved on .cause.Middleware
Middleware wraps the handler. Call next() to continue; return a response to short-circuit.
api.use(async (ctx, next) => { const started = Date.now(); const res = await next(); console.log(`${ctx.req.method} ${ctx.params.id} ${Date.now() - started}ms`); return res;}); // Auth guard for everything belowapi.use(async (ctx, next) => { const token = ctx.req.headers.get("Authorization")?.replace("Bearer ", ""); const session = await auth.getSession(token); if (!session) return API.json({ error: "Unauthorized" }, { status: 401 }); ctx.state.user = session.user; // available downstream return next();});Note
Calling
next() twice in one middleware throws. This is caught deliberately rather than silently running the handler twice.Errors
import { HttpError, ValidationError } from "yatta/api"; // Throw with a statusthrow new HttpError("Not found", 404); // Catch everythingapi.onError(async (err, ctx) => { console.error(err); if (err instanceof HttpError) { return API.json({ error: err.message }, { status: err.status }); } return API.json({ error: "Internal Server Error" }, { status: 500 });});CORS
api.cors({ origin: ["https://app.dev", "http://localhost:3000"], credentials: true, methods: ["GET", "POST", "PUT", "DELETE"], maxAge: 86_400,});Method not allowed
api.get(handler);api.post(handler);api.put(handler);api.patch(handler);api.delete(handler);api.all(handler); // every methodStreaming
api.get(async () => { const stream = new ReadableStream({ async start(controller) { for (const chunk of chunks) { controller.enqueue(new TextEncoder().encode(chunk)); } controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/plain; charset=utf-8" }, });});