UNPKG

kitcn

Version:

kitcn - React Query integration and CLI tools for Convex

677 lines (565 loc) 20.5 kB
# 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` |