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

conventionts
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/posts

A route

yatta/backend/user/index.tsts
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.

yatta/backend/posts/[id].tsts
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

ctxts
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 cookies

Responses

API.*ts
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.

Validationts
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.

middlewarets
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

error handlingts
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

tsts
api.cors({  origin: ["https://app.dev", "http://localhost:3000"],  credentials: true,  methods: ["GET", "POST", "PUT", "DELETE"],  maxAge: 86_400,});

Method not allowed

tsts
api.get(handler);api.post(handler);api.put(handler);api.patch(handler);api.delete(handler);api.all(handler);   // every method

Streaming

yatta/backend/stream.tsts
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" },  });});