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 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.
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
}
}timeoutis 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; incubek dev --workspace, their local URL).- Slow or retried calls (LLMs, mail APIs) belong in a task.
- Build query strings with
encodeURIComponent(there is noURL).
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, thenconst b = await c.body<{ productId: string[]; qty: string[] }>()andb.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.