Sub-skills
HTTP in depth
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, Claude and other AI APIs, 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.
// src/app.tsx
import { app } from "cubek"
import "./routes/api"
import "./routes/hooks"
import "./lib/ai"
app.get("/", async (c) => c.html("<h1>Home</h1>"))
// Catch-alls: 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))The most specific route wins, in any order (/posts/new > /posts/:id >
/*); a trailing * matches the rest.
Middleware
// src/routes/api.tsx
import { app, cors, db, log, rateLimit, secrets, validate, type Context, type Response } from "cubek"
// Every matching route (in any order) and unmatched paths.
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.- Files of
public/skip global middleware (app.use(mw),"*","/*"); a narrower path gates them:app.use("/docs/*", auth)coverspublic/docs/. - A middleware returns a response to stop, or calls
next(). There is noc.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). Withcredentials: truelist the origins (a list echoes the matching one).rateLimit:X-RateLimit-*headers, 429 withretry-after, JSON under/api/*; fixed window from the first hit, counted per node (an app on several nodes allowslimiton each). One global count:kv.incr(key, 1, { ttl: "1m" })in a handler that also readsdb(it then runs on the app's primary node, where kv is exact), and answer 429 over the limit.
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")
})Public-key signatures (Ed25519, ES256): crypto.verify(alg, publicKey, raw, signature) (push.md).
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:"5s"(requests: 10 s at most; tasks: 60 s, up to"5m"); 10 MB responses, 20 fetches per request (200 per task). Public addresses only: private, loopback, metadata IPs are refused (other apps: their public URL; incubek dev --workspace, their local URL).- An app may fetch its own public URL (a task posting to its own webhook): the app keeps serving while a fetch waits.
- Slow or retried calls (LLMs, email APIs) belong in a task.
Claude (AI APIs)
No server npm (no Anthropic SDK): POST to the HTTP API from a task, which retries when it throws (429 rate limit, 529 overloaded, 5xx).
// src/lib/ai.ts
import { fetch, kv, log, secrets, task, validate } from "cubek"
const reply = validate.object({ content: validate.array(validate.object({ type: validate.string(), text: validate.string().optional() })) })
task.on("summarize", async (p: { id: string; text: string }) => {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: { "x-api-key": secrets.get("ANTHROPIC_API_KEY") ?? "", "anthropic-version": "2023-06-01", "content-type": "application/json" },
body: JSON.stringify({ model: "claude-opus-5", max_tokens: 2048, messages: [{ role: "user", content: `Summarize:\n\n${p.text}` }] }),
})
if (res.status === 429 || res.status >= 500) throw new Error(`claude: ${res.status}`) // retried
if (!res.ok) return log.error("claude", { status: res.status, body: await res.text() }) // 400/401: fix, no retry
const text = reply.parse(await res.json()).content.map((b) => b.type === "text" ? b.text ?? "" : "").join("")
await kv.set(`summary:${p.id}`, text)
})task.run("summarize", { id, text })from the route; the page reads the result later. No streaming needed in a task.- Bulk or fast work:
"claude-haiku-4-5". Long generations:fetch(url, { timeout })in the task. - Build URLs with
URL/URLSearchParamsorencodeURIComponent.
Forms
- Form fields arrive as strings:
validate.number({ coerce: true })("5"→ 5,""= absent),validate.boolean({ coerce: true })(checkbox"on"or absent). tags[]fields →string[]; another repeated name (<select multiple>, checkboxes):c.body()keeps the last; all:await c.formAll("ids").- Rows of inputs: name them
productId[]/qty[]per row, thenconst b = await c.body<{ productId: string[]; qty: string[] }>()andb.productId.map((id, i) => ({ id, qty: Number(b.qty[i]) })). - Dynamic names (
score_<id>):const b = await c.body<Record<string, string>>(), thenObject.entries(b), or check it withvalidate.record(validate.string()). - Formats:
validate.string({ pattern: /^[a-z0-9-]+$/ })(searched: anchor it),{ email: true },{ url: true }.
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, an email body):
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.