Sub-skills

HTTP in depth

For your agentCopy this and give it to the AI you're building with.
Run `cubek skill`, then `cubek skill http`, read all of both and follow them exactly (no CLI: download https://cubek.dev/SKILL.md and https://cubek.dev/skills/http.md verbatim with curl; don't summarize them).
Run `cubek check` after every edit until it is clean.

Depth for routes across files, middleware, CORS, webhooks, outbound fetch, responses and errors. The core Skill's "Routes and the context c" comes first.

Routes in several files

src/app.tsx is the entry. Other files register routes at top level and the entry imports them for that effect; import order is registration order.

// src/app.tsx
import { app } from "cubek"
import "./routes/api"
import "./routes/hooks"

app.get("/", async (c) => c.html("<h1>Home</h1>"))

// Catch-alls last: an unknown /api path answers JSON, the rest a page.
app.get("/api/*", async (c) => c.json({ error: "not found" }, 404))
app.get("/*", async (c) => c.html("<h1>Not found</h1>", 404))

First registered wins: put /posts/new before /posts/:id, and /:slug, /* last. Paths: :name params, a trailing * matches the rest.

Middleware

// src/routes/api.tsx
import { app, cors, db, log, rateLimit, secrets, validate, type Context, type Response } from "cubek"

// Runs before every matching route, also for paths no route matches.
app.use("/api/*", cors({ origin: ["https://app.example.com"], credentials: true, maxAge: 600 }))
app.use("/api/*", rateLimit({ limit: 120, window: "1m" }))

async function timing(c: Context, next: () => Promise<void>): Promise<void> {
  const t0 = Date.now()
  await next()
  log.info("api", { path: c.path(), ms: Date.now() - t0 })
}
app.use("/api/*", timing)

// Middleware on one route: the second argument.
async function needsKey(c: Context, next: () => Promise<void>): Promise<Response | void> {
  if (c.header("x-api-key") !== secrets.get("API_KEY")) return c.json({ error: "unauthorized" }, 401)
  await next()
}

const itemInput = validate.object({ name: validate.string({ min: 1, max: 100 }), qty: validate.number({ int: true, min: 0 }) })

app.post("/api/items", needsKey, async (c) => {
  const parsed = itemInput.safeParse(await c.body())
  if (!parsed.success) return c.json({ error: parsed.error }, 422)
  return c.json(await db.items.insert(parsed.data), 201)
})

app.get("/api/items", async (c) => {
  const limit = Math.min(Number(c.query("limit") ?? "50"), 100)
  return c.json({ items: await db.items.find({ orderBy: { name: "asc" }, limit }) })
})
// schema.ts
import { table, t } from "cubek"

export const items = table("items", { id: t.id(), name: t.string(), qty: t.number() }).index("name")
export const deliveries = table("deliveries", { id: t.id(), eventId: t.string().unique(), kind: t.string() })
  • app.use("/admin/*", ...) also runs for /admin/login: to leave a page open, keep it outside the prefix or guard route by route.
  • A middleware returns a response to stop, or calls next(). There is no c.set: compute what a handler needs again (await currentUserId(c)).
  • cors() defaults: origin *, methods GET HEAD PUT POST DELETE PATCH, the requested headers, no credentials; it answers the OPTIONS preflight (204). With credentials: true list the origins (a list echoes the matching one).
  • rateLimit: X-RateLimit-* headers, 429 with retry-after, JSON under /api/*; fixed window from the first hit, counted per node.

Webhooks (signed bodies)

Verify the signature over the exact bytes, then parse; store the event id to ignore redeliveries.

// src/routes/hooks.tsx
import { app, crypto, db, encoding, secrets, validate } from "cubek"

const event = validate.object({ id: validate.string(), type: validate.string() })

app.post("/hooks/payments", async (c) => {
  const secret = secrets.get("PAYMENTS_WEBHOOK_SECRET")
  if (secret === null) return c.text("not configured", 500)
  const raw = await c.rawBody()
  const expected = crypto.hmac("sha256", secret, raw)
  if (!crypto.timingSafeEqual(expected, c.header("x-signature") ?? "")) return c.text("bad signature", 401)
  const parsed = event.safeParse(JSON.parse(encoding.utf8.decode(raw)))
  if (!parsed.success) return c.text(parsed.error, 400)
  if ((await db.deliveries.findOne({ where: { eventId: parsed.data.id } })) !== null) return c.text("duplicate", 200)
  await db.deliveries.insert({ eventId: parsed.data.id, kind: parsed.data.type })
  return c.text("ok")
})

Slow work after a webhook: task.run(...) and answer 200 at once (tasks.md).

Outbound fetch

// src/lib/weather.ts
import { fetch, secrets, validate } from "cubek"

const forecast = validate.object({ temp: validate.number(), summary: validate.string() })

export async function weather(city: string): Promise<{ temp: number; summary: string } | null> {
  try {
    const res = await fetch(`https://api.example.com/v1/weather?city=${encodeURIComponent(city)}`, {
      headers: { authorization: `Bearer ${secrets.get("WEATHER_KEY") ?? ""}` },
      timeout: "5s",
    })
    if (!res.ok) return null
    const parsed = forecast.safeParse(await res.json())
    return parsed.success ? parsed.data : null
  } catch (e) {
    return null  // timeout, DNS, refused
  }
}
  • timeout is a duration string ("5s", at most 10 s); 10 MB responses, 20 fetches per request. Public addresses only: private, loopback and metadata IPs are refused (other cubek apps: their public URL; in cubek dev --workspace, their local URL).
  • Slow or retried calls (LLMs, mail APIs) belong in a task.
  • Build query strings with encodeURIComponent (there is no URL).

Forms

  • Form fields arrive as strings: validate.number({ coerce: true }) ("5" → 5, "" = absent), validate.boolean({ coerce: true }) (checkbox "on" or absent).
  • tags[] fields → string[]; other repeated names keep the last value.
  • Rows of inputs: name them productId[]/qty[] per row, then const b = await c.body<{ productId: string[]; qty: string[] }>() and b.productId.map((id, i) => ({ id, qty: Number(b.qty[i]) })).

Responses and errors

  • c.json(data, 201), c.text(s, 200, { "cache-control": "max-age=60" }), c.body(bytes, 200, { "content-type": "image/png" }), c.redirect(url, 303).
  • Headers replace the content-type: a CSV download is c.text(csv.stringify(rows, { header: true }), 200, { "content-type": "text/csv" }).
  • A rendered page as a string (a cached copy, mail): await (await c.render(<Page />)).text().
  • A thrown error is a bare 500 (see cubek logs); catch expected failures and answer 4xx with a message.
  • Limits: 10 MB request body, 32 MB response, 10 s per request.