kitcn
Version:
kitcn - React Query integration and CLI tools for Convex
677 lines (565 loc) • 20.5 kB
Markdown
# HTTP Router
Typed REST APIs with cRPC HTTP router, Hono integration, webhooks, streaming, and client integration. Route builder basics → SKILL.md Section 9.
Prerequisites: `setup/server.md`.
## Setup
### Route Builders
```ts
// convex/lib/crpc.ts
import { CRPCError, initCRPC } from "kitcn/server";
const c = initCRPC.dataModel<DataModel>().context({}).create({});
export const publicRoute = c.httpAction;
export const authRoute = c.httpAction.use(async ({ ctx, next }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new CRPCError({ code: "UNAUTHORIZED" });
return next({ ctx: { ...ctx, userId: identity.subject } });
});
export const router = c.router;
```
### HTTP Registration with Hono
Use `kitcn/auth/http` for auth route helpers; it auto-installs the Convex-safe `MessageChannel` polyfill.
```ts
// convex/functions/http.ts
import { authMiddleware } from "kitcn/auth/http";
import { createHttpRouter } from "kitcn/server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { router } from "../lib/crpc";
import { getAuth } from "./generated/auth";
import { todosRouter } from "../routers/todos";
import { health } from "../routers/health";
const app = new Hono();
app.use(
"/api/*",
cors({
origin: process.env.SITE_URL!,
allowHeaders: ["Content-Type", "Authorization", "Better-Auth-Cookie"],
exposeHeaders: ["Set-Better-Auth-Cookie"],
credentials: true,
})
);
app.use(authMiddleware(getAuth));
export const httpRouter = router({
health,
todos: todosRouter,
});
export default createHttpRouter(app, httpRouter);
```
| Component | Purpose |
| ----------------------------------- | ------------------------------------------ |
| `Hono` | Route handling, middleware, CORS |
| `authMiddleware(getAuth)` | Better Auth routes middleware |
| `createHttpRouter(app, httpRouter)` | Creates Convex HttpRouter with Hono + cRPC |
## Defining Routes
### GET with Search Params
```ts
import { createTodosCaller } from "../functions/generated/todos.runtime";
export const list = publicRoute
.get("/api/todos")
.searchParams(
z.object({
limit: z.coerce.number().optional().default(10),
offset: z.coerce.number().optional().default(0),
})
)
.output(z.array(todoSchema))
.query(async ({ ctx, searchParams }) => {
const caller = createTodosCaller(ctx);
return caller.list({
limit: searchParams.limit,
offset: searchParams.offset,
});
});
```
Use `z.coerce.number()` for search params since URL query strings are always strings.
### GET with Path Params
```ts
export const get = publicRoute
.get("/api/todos/:id")
.params(z.object({ id: z.string() }))
.output(todoSchema.nullable())
.query(async ({ ctx, params }) => {
const caller = createTodosCaller(ctx);
return caller.get({ id: params.id });
});
```
### POST / PATCH / DELETE
```ts
export const create = authRoute
.post("/api/todos")
.input(
z.object({ title: z.string().min(1), description: z.string().optional() })
)
.output(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const caller = createTodoInternalCaller(ctx);
const id = await caller.create({ userId: ctx.userId, ...input });
return { id };
});
export const update = authRoute
.patch("/api/todos/:id")
.params(z.object({ id: z.string() }))
.input(
z.object({
title: z.string().optional(),
completed: z.boolean().optional(),
})
)
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, params, input }) => {
const caller = createTodoInternalCaller(ctx);
await caller.update({ id: params.id, ...input });
return { success: true };
});
export const deleteTodo = authRoute
.delete("/api/todos/:id")
.params(z.object({ id: z.string() }))
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, params }) => {
const caller = createTodoInternalCaller(ctx);
await caller.deleteTodo({ id: params.id });
return { success: true };
});
```
### Routers
```ts
// convex/routers/todos.ts
export const todosRouter = router({
list,
get,
create,
update,
delete: deleteTodo,
});
```
### Combined Schemas
```ts
export const createTask = authRoute
.post("/api/projects/:projectId/tasks")
.params(z.object({ projectId: z.string() }))
.searchParams(z.object({ notify: z.coerce.boolean().optional() }))
.input(z.object({ title: z.string(), description: z.string().optional() }))
.output(z.object({ taskId: z.string(), projectId: z.string() }))
.mutation(async ({ ctx, params, searchParams, input }) => {
const caller = createTasksCaller(ctx);
const taskId = await caller.create({
projectId: params.projectId,
...input,
});
if (searchParams.notify) {
await caller.schedule.now.sendNotification({ taskId });
}
return { taskId, projectId: params.projectId };
});
```
## FormData Uploads
```ts
// Server
export const upload = authRoute
.post("/api/files/upload")
.form(
z.object({
file: z.instanceof(File),
title: z.string().optional(),
tags: z.array(z.string()).optional(),
})
)
.mutation(async ({ ctx, c, form }) => {
const storageId = await ctx.storage.store(form.file);
return c.json({ storageId, filename: form.file.name });
});
// Client
uploadFile.mutate({
form: { file: selectedFile, title: "My Document", tags: ["work"] },
});
```
## Metadata & Middleware
```ts
export const heavyEndpoint = publicRoute
.meta({ ratelimit: "api/heavy" })
.get("/api/reports")
.query(async ({ ctx }) => {
const caller = createReportsCaller(ctx);
return caller.generate({});
});
// Chained meta (shallow merge)
export const adminEndpoint = authRoute
.meta({ role: "admin" })
.meta({ ratelimit: "api/admin" })
.delete("/api/users/:id")
.params(z.object({ id: z.string() }))
.mutation(async ({ ctx, params }) => {
const caller = createAdminCaller(ctx);
await caller.deleteUser({ id: params.id });
});
// Custom middleware extending context
export const withPermissions = authRoute
.use(async ({ ctx, next }) => {
const caller = createPermissionsCaller(ctx);
const permissions = await caller.get({ userId: ctx.userId });
return next({ ctx: { ...ctx, permissions } });
})
.get("/api/protected")
.query(async ({ ctx }) => {
if (!ctx.permissions.includes("admin")) {
throw new CRPCError({ code: "FORBIDDEN", message: "Admin required" });
}
return { data: "secret" };
});
```
## Optional Auth
```ts
export const publicOrAuth = optionalAuthRoute
.get("/api/content")
.query(async ({ ctx }) => {
const caller = createContentCaller(ctx);
const userId: Id<"user"> | null = ctx.userId;
if (userId) return caller.personalized({ userId });
return caller.public({});
});
```
## Error Handling
See [Error Codes](#error-codes) in API Reference. Zod validation failures auto-return `400 Bad Request` with error details.
## Custom Responses
cRPC handlers receive `c` (Hono Context) for custom responses:
```ts
// File download
export const download = authRoute
.get("/api/todos/export/:format")
.params(z.object({ format: z.enum(["json", "csv"]) }))
.query(async ({ ctx, params, c }) => {
const caller = createTodosCaller(ctx);
const todos = await caller.list({ limit: 100 });
c.header(
"Content-Disposition",
`attachment; filename="todos.${params.format}"`
);
c.header("Cache-Control", "no-cache");
if (params.format === "csv") {
const csv = [
"id,title,completed",
...todos.map((t) => `${t.id},${t.title},${t.completed}`),
].join("\n");
return c.text(csv);
}
return c.json({ todos });
});
// Redirect
export const redirect = publicRoute
.get("/api/old-path")
.query(async ({ c }) => c.redirect("/api/new-path", 301));
```
| Method | Description |
| -------------------------- | -------------------- |
| `c.json(data)` | Return JSON response |
| `c.text(str)` | Return text response |
| `c.redirect(url, status?)` | Return redirect |
| `c.header(name, value)` | Set response header |
| `c.req.header(name)` | Get request header |
| `c.req.text()` | Get raw body as text |
## Streaming
### Server-Sent Events
```ts
import { streamText } from "hono/streaming";
export const events = publicRoute
.get("/api/stream")
.query(async ({ ctx, c }) => {
c.header("Content-Type", "text/event-stream");
c.header("Cache-Control", "no-cache");
return streamText(c, async (stream) => {
for (let i = 0; i < 10; i++) {
const caller = createDataCaller(ctx);
const data = await caller.getChunk({ index: i });
await stream.write(`data: ${JSON.stringify(data)}\n\n`);
await stream.sleep(1000);
}
});
});
```
### AI Streaming
```ts
import { stream } from "hono/streaming";
export const chat = publicRoute
.post("/api/ai/stream")
.input(z.object({ prompt: z.string() }))
.mutation(async ({ ctx, input, c }) => {
const aiCaller = createAiCaller(ctx);
c.header("Content-Type", "text/event-stream");
c.header("Cache-Control", "no-cache");
const aiStream = await aiCaller.actions.streamResponse({
prompt: input.prompt,
});
return stream(c, async (stream) => {
await stream.pipe(aiStream);
});
});
```
## Rate Limiting
```ts
export const ratelimited = publicRoute
.post("/api/public")
.input(z.object({ data: z.string() }))
.mutation(async ({ ctx, input, c }) => {
const ip =
c.req.header("X-Forwarded-For")?.split(",")[0]?.trim() ??
c.req.header("CF-Connecting-IP") ??
"unknown";
const ratelimitCaller = createRatelimitCaller(ctx);
const allowed = await ratelimitCaller.check({
key: `http:${ip}`,
limit: 100,
window: 3600000,
});
if (!allowed)
return c.text("Rate limit exceeded", 429, { "Retry-After": "3600" });
const apiCaller = createApiCaller(ctx);
const result = await apiCaller.process({ data: input.data });
return c.json(result);
});
```
## Webhooks
### Stripe
```ts
export const stripeWebhook = publicRoute
.post("/webhooks/stripe")
.mutation(async ({ ctx, c }) => {
const stripeCaller = createStripeCaller(ctx);
const signature = c.req.header("stripe-signature");
if (!signature)
throw new CRPCError({ code: "BAD_REQUEST", message: "No signature" });
const body = await c.req.text();
const isValid = await stripeCaller.actions.verify({ body, signature });
if (!isValid)
throw new CRPCError({
code: "BAD_REQUEST",
message: "Invalid signature",
});
const event = JSON.parse(body);
switch (event.type) {
case "payment_intent.succeeded":
const paymentsCaller = createPaymentsCaller(ctx);
await paymentsCaller.markPaid({
paymentIntentId: event.data.object.id,
});
break;
case "customer.subscription.deleted":
const subscriptionsCaller = createSubscriptionsCaller(ctx);
await subscriptionsCaller.cancel({
subscriptionId: event.data.object.id,
});
break;
}
return c.text("OK", 200);
});
```
### Discord Bot
```ts
import { verifyKey } from "discord-interactions";
export const discordWebhook = publicRoute
.post("/webhooks/discord")
.mutation(async ({ ctx, c }) => {
const signature = c.req.header("X-Signature-Ed25519");
const timestamp = c.req.header("X-Signature-Timestamp");
if (!signature || !timestamp)
throw new CRPCError({
code: "UNAUTHORIZED",
message: "Missing signature",
});
const body = await c.req.text();
if (
!verifyKey(body, signature, timestamp, process.env.DISCORD_PUBLIC_KEY!)
) {
throw new CRPCError({
code: "UNAUTHORIZED",
message: "Invalid signature",
});
}
const interaction = JSON.parse(body);
if (interaction.type === 1) return c.json({ type: 1 }); // PING
if (interaction.type === 2) {
const statsCaller = createStatsCaller(ctx);
const discordCaller = createDiscordCaller(ctx);
switch (interaction.data.name) {
case "stats":
const stats = await statsCaller.get({});
return c.json({
type: 4,
data: { content: `Users: ${stats.users}, Posts: ${stats.posts}` },
});
case "create":
await discordCaller.schedule.now.processCreate({
token: interaction.token,
});
return c.json({ type: 5 }); // DEFERRED
default:
return c.json({ type: 4, data: { content: "Unknown command" } });
}
}
if (interaction.type === 3) {
const discordCaller = createDiscordCaller(ctx);
await discordCaller.handleButton({
customId: interaction.data.custom_id,
userId: interaction.user.id,
});
return c.json({ type: 7, data: { content: "Done!" } });
}
throw new CRPCError({
code: "BAD_REQUEST",
message: "Unknown interaction",
});
});
```
## React Client
See [Input Args](#input-args) in API Reference.
### Query Patterns
```ts
// GET with searchParams
crpc.http.todos.list.queryOptions({ searchParams: { limit: "10" } });
// GET with path params
crpc.http.todos.get.queryOptions({ params: { id: todoId } });
// GET with custom headers
crpc.http.todos.list.queryOptions({
searchParams: { limit: "10" },
headers: { "X-Custom": "value" },
});
```
### One-Time Fetch
```ts
// For exports/downloads (no caching, mutation semantics)
const exportTodos = useMutation(crpc.http.todos.export.mutationOptions());
exportTodos.mutate({ params: { format: "csv" } });
```
### Vanilla Client
```ts
const client = useCRPCClient();
const todos = await client.http.todos.list.query();
await client.http.todos.create.mutate({ title: "New todo" });
// For cache-aware fetches in render context
const queryClient = useQueryClient();
const todos = await queryClient.fetchQuery(crpc.http.todos.list.queryOptions());
```
### staticQueryOptions
For prefetching in event handlers (doesn't use hooks internally):
```ts
const queryClient = useQueryClient();
const handleMouseEnter = () => {
queryClient.prefetchQuery(crpc.http.todos.list.staticQueryOptions());
};
```
`staticQueryOptions` doesn't include reactive auth state. Auth handled at execution time.
### Mutation Patterns
```ts
const createTodo = useMutation(
crpc.http.todos.create.mutationOptions({ onSuccess: () => queryClient.invalidateQueries(...) })
);
createTodo.mutate({ title: 'New Todo' }); // JSON body at root
updateTodo.mutate({ params: { id: '123' }, completed: true }); // PATCH with params + body
deleteTodo.mutate({ params: { id: '123' } }); // DELETE with params
uploadFile.mutate({ form: { file: selectedFile, description: 'My file' } }); // FormData
```
### Cache Invalidation
```ts
const updateTodo = useMutation(
crpc.http.todos.update.mutationOptions({
onSuccess: (_, vars) => {
queryClient.invalidateQueries(crpc.http.todos.list.queryFilter());
queryClient.invalidateQueries(
crpc.http.todos.get.queryFilter({ params: { id: vars.params?.id } })
);
},
})
);
```
See [Client Methods](#client-methods) in API Reference.
## RSC Prefetching
```tsx
// app/todos/page.tsx
import { crpc, HydrateClient, prefetch } from "@/lib/convex/rsc";
export default async function TodosPage() {
prefetch(
crpc.http.todos.list.queryOptions({ searchParams: { limit: "10" } })
);
return (
<HydrateClient>
<TodoList />
</HydrateClient>
);
}
```
### Awaited Prefetch
```tsx
const todo = await preloadQuery(
crpc.http.todos.get.queryOptions({ params: { id } })
);
if (!todo) notFound();
```
### Auth-Aware Prefetch
```tsx
prefetch(
crpc.http.todos.list.queryOptions(
{ searchParams: { limit: "10" } },
{ skipUnauth: true }
)
);
```
| Pattern | Blocking | Server Access | Client Hydration |
| ---------------- | -------- | ------------- | ---------------- |
| `prefetch()` | No | No | Yes |
| `preloadQuery()` | Yes | Yes | Yes |
## Server-Side Calls
```ts
import { createContext } from "@/lib/convex/server";
const ctx = await createContext({ headers: request.headers });
const todos = await ctx.caller.todos.list({ limit: 10 });
if (ctx.isAuthenticated) await ctx.caller.todos.create({ title: "New task" });
```
## API Reference
### Route Builder Patterns
| Pattern | Use Case |
| ---------------------------------------- | ------------------------ |
| `publicRoute.get('/path').query()` | Public GET endpoint |
| `authRoute.post('/path').mutation()` | Auth-required POST |
| `optionalAuthRoute.get('/path').query()` | Optional auth endpoint |
| `.params(z.object({id}))` | Path params `/todos/:id` |
| `.searchParams(z.object({limit}))` | Query params `?limit=10` |
| `.input(z.object({...}))` | JSON body (POST/PATCH) |
| `.form(z.object({file, description}))` | FormData uploads |
| `.output(z.object({...}))` | Response validation |
| `.meta({ ratelimit: 'api/heavy' })` | Procedure metadata |
| `.use(middleware)` | Custom middleware |
| `router({ endpoint1, endpoint2 })` | Group endpoints |
### HTTP Methods
| Method | Builder | Use Case | Has Body |
| ------ | ---------------------- | ----------------- | -------- |
| GET | `.get().query()` | Read operations | No |
| POST | `.post().mutation()` | Create operations | Yes |
| PATCH | `.patch().mutation()` | Partial updates | Yes |
| DELETE | `.delete().mutation()` | Delete operations | No |
### Error Codes
| Code | HTTP Status | Use Case |
| ----------------------- | ----------- | --------------------------------- |
| `BAD_REQUEST` | 400 | Invalid request format |
| `UNAUTHORIZED` | 401 | Missing or invalid authentication |
| `FORBIDDEN` | 403 | Authenticated but not authorized |
| `NOT_FOUND` | 404 | Resource doesn't exist |
| `CONFLICT` | 409 | Resource conflict (duplicate) |
| `UNPROCESSABLE_CONTENT` | 422 | Validation failed |
| `TOO_MANY_REQUESTS` | 429 | Rate limit exceeded |
| `INTERNAL_SERVER_ERROR` | 500 | Unexpected server error |
### Input Args
| Property | Type | Description |
| -------------- | --------------------------------------- | ------------------------------------- |
| `params` | `Record<string, string>` | Path parameters (`:id`) |
| `searchParams` | `Record<string, string \| string[]>` | Query string params |
| `form` | `z.infer<TForm>` | Typed FormData (if `.form()` defined) |
| `fetch` | `typeof fetch` | Custom fetch function |
| `init` | `RequestInit` | Request options |
| `headers` | `Record<string, string> \| (() => ...)` | Headers (incl. cookies) |
| `[key]` | `unknown` | JSON body fields at root |
### Client Methods
| Method | Signature | Description |
| -------------------- | --------------------- | ----------------------------------------- |
| `queryOptions` | `(args?, queryOpts?)` | Options for `useQuery`/`useSuspenseQuery` |
| `staticQueryOptions` | `(args?, queryOpts?)` | For event handlers/prefetching (no hooks) |
| `mutationOptions` | `(mutationOpts?)` | Options for `useMutation` |
| `queryKey` | `(args?)` | Get query key for cache operations |
| `queryFilter` | `(args?, filters?)` | Filter for `invalidateQueries` |