kitcn 0.0.1 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/intent.js +3 -0
- package/dist/aggregate/index.d.ts +388 -0
- package/dist/aggregate/index.js +37 -0
- package/dist/api-entry-BckXqaLb.js +66 -0
- package/dist/auth/client/index.d.ts +37 -0
- package/dist/auth/client/index.js +217 -0
- package/dist/auth/config/index.d.ts +45 -0
- package/dist/auth/config/index.js +24 -0
- package/dist/auth/generated/index.d.ts +2 -0
- package/dist/auth/generated/index.js +3 -0
- package/dist/auth/http/index.d.ts +64 -0
- package/dist/auth/http/index.js +461 -0
- package/dist/auth/index.d.ts +221 -0
- package/dist/auth/index.js +1398 -0
- package/dist/auth/nextjs/index.d.ts +50 -0
- package/dist/auth/nextjs/index.js +81 -0
- package/dist/auth-store-Cljlmdmi.js +197 -0
- package/dist/builder-CBdG5W6A.js +1974 -0
- package/dist/caller-factory-cTXNvYdz.js +216 -0
- package/dist/cli.mjs +13264 -0
- package/dist/codegen-lF80HSWu.mjs +3416 -0
- package/dist/context-utils-HPC5nXzx.d.ts +17 -0
- package/dist/create-schema-odyF4kCy.js +156 -0
- package/dist/create-schema-orm-DOyiNDCx.js +246 -0
- package/dist/crpc/index.d.ts +105 -0
- package/dist/crpc/index.js +169 -0
- package/dist/customFunctions-C0voKmtx.js +144 -0
- package/dist/error-BZEnI7Sq.js +41 -0
- package/dist/generated-contract-disabled-Cih4eITO.js +50 -0
- package/dist/generated-contract-disabled-D-sOFy92.d.ts +354 -0
- package/dist/http-types-DqJubRPJ.d.ts +292 -0
- package/dist/meta-utils-0Pu0Nrap.js +117 -0
- package/dist/middleware-BUybuv9n.d.ts +34 -0
- package/dist/middleware-C2qTZ3V7.js +84 -0
- package/dist/orm/index.d.ts +17 -0
- package/dist/orm/index.js +10713 -0
- package/dist/plugins/index.d.ts +2 -0
- package/dist/plugins/index.js +3 -0
- package/dist/procedure-caller-DtxLmGwA.d.ts +1467 -0
- package/dist/procedure-caller-MWcxhQDv.js +349 -0
- package/dist/query-context-B8o6-8kC.js +1518 -0
- package/dist/query-context-CFZqIvD7.d.ts +42 -0
- package/dist/query-options-Dw7cOyXl.js +121 -0
- package/dist/ratelimit/index.d.ts +269 -0
- package/dist/ratelimit/index.js +856 -0
- package/dist/ratelimit/react/index.d.ts +76 -0
- package/dist/ratelimit/react/index.js +183 -0
- package/dist/react/index.d.ts +1284 -0
- package/dist/react/index.js +2526 -0
- package/dist/rsc/index.d.ts +276 -0
- package/dist/rsc/index.js +233 -0
- package/dist/runtime-CtvJPkur.js +2453 -0
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.js +6 -0
- package/dist/solid/index.d.ts +1221 -0
- package/dist/solid/index.js +2940 -0
- package/dist/transformer-DtDhR3Lc.js +194 -0
- package/dist/types-BTb_4BaU.d.ts +42 -0
- package/dist/types-BiJE7qxR.d.ts +4 -0
- package/dist/types-DEJpkIhw.d.ts +88 -0
- package/dist/types-HhO_R6pd.d.ts +213 -0
- package/dist/validators-B7oIJCAp.js +279 -0
- package/dist/validators-vzRKjBJC.d.ts +88 -0
- package/dist/watcher.mjs +96 -0
- package/dist/where-clause-compiler-DdjN63Io.d.ts +4756 -0
- package/package.json +107 -34
- package/skills/convex/SKILL.md +486 -0
- package/skills/convex/references/features/aggregates.md +353 -0
- package/skills/convex/references/features/auth-admin.md +446 -0
- package/skills/convex/references/features/auth-organizations.md +1141 -0
- package/skills/convex/references/features/auth-polar.md +579 -0
- package/skills/convex/references/features/auth.md +470 -0
- package/skills/convex/references/features/create-plugins.md +153 -0
- package/skills/convex/references/features/http.md +676 -0
- package/skills/convex/references/features/migrations.md +162 -0
- package/skills/convex/references/features/orm.md +1166 -0
- package/skills/convex/references/features/react.md +657 -0
- package/skills/convex/references/features/scheduling.md +267 -0
- package/skills/convex/references/features/testing.md +209 -0
- package/skills/convex/references/setup/auth.md +501 -0
- package/skills/convex/references/setup/biome.md +190 -0
- package/skills/convex/references/setup/doc-guidelines.md +145 -0
- package/skills/convex/references/setup/index.md +761 -0
- package/skills/convex/references/setup/next.md +116 -0
- package/skills/convex/references/setup/react.md +175 -0
- package/skills/convex/references/setup/server.md +473 -0
- package/skills/convex/references/setup/start.md +67 -0
- package/LICENSE +0 -21
- package/README.md +0 -0
- package/dist/index.d.mts +0 -5
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -6
- package/dist/index.mjs.map +0 -1
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
# HTTP Router
|
|
2
|
+
|
|
3
|
+
Typed REST APIs with cRPC HTTP router, Hono integration, webhooks, streaming, and client integration. Route builder basics → SKILL.md Section 9.
|
|
4
|
+
|
|
5
|
+
Prerequisites: `setup/server.md`.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
### Route Builders
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// convex/lib/crpc.ts
|
|
13
|
+
import { CRPCError, initCRPC } from "kitcn/server";
|
|
14
|
+
|
|
15
|
+
const c = initCRPC.dataModel<DataModel>().context({}).create({});
|
|
16
|
+
|
|
17
|
+
export const publicRoute = c.httpAction;
|
|
18
|
+
|
|
19
|
+
export const authRoute = c.httpAction.use(async ({ ctx, next }) => {
|
|
20
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
21
|
+
if (!identity) throw new CRPCError({ code: "UNAUTHORIZED" });
|
|
22
|
+
return next({ ctx: { ...ctx, userId: identity.subject } });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const router = c.router;
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### HTTP Registration with Hono
|
|
29
|
+
|
|
30
|
+
Use `kitcn/auth/http` for auth route helpers; it auto-installs the Convex-safe `MessageChannel` polyfill.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// convex/functions/http.ts
|
|
34
|
+
import { authMiddleware } from "kitcn/auth/http";
|
|
35
|
+
import { createHttpRouter } from "kitcn/server";
|
|
36
|
+
import { Hono } from "hono";
|
|
37
|
+
import { cors } from "hono/cors";
|
|
38
|
+
import { router } from "../lib/crpc";
|
|
39
|
+
import { getAuth } from "./generated/auth";
|
|
40
|
+
import { todosRouter } from "../routers/todos";
|
|
41
|
+
import { health } from "../routers/health";
|
|
42
|
+
|
|
43
|
+
const app = new Hono();
|
|
44
|
+
|
|
45
|
+
app.use(
|
|
46
|
+
"/api/*",
|
|
47
|
+
cors({
|
|
48
|
+
origin: process.env.SITE_URL!,
|
|
49
|
+
allowHeaders: ["Content-Type", "Authorization", "Better-Auth-Cookie"],
|
|
50
|
+
exposeHeaders: ["Set-Better-Auth-Cookie"],
|
|
51
|
+
credentials: true,
|
|
52
|
+
})
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
app.use(authMiddleware(getAuth));
|
|
56
|
+
|
|
57
|
+
export const httpRouter = router({
|
|
58
|
+
health,
|
|
59
|
+
todos: todosRouter,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export default createHttpRouter(app, httpRouter);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| Component | Purpose |
|
|
66
|
+
| ----------------------------------- | ------------------------------------------ |
|
|
67
|
+
| `Hono` | Route handling, middleware, CORS |
|
|
68
|
+
| `authMiddleware(getAuth)` | Better Auth routes middleware |
|
|
69
|
+
| `createHttpRouter(app, httpRouter)` | Creates Convex HttpRouter with Hono + cRPC |
|
|
70
|
+
|
|
71
|
+
## Defining Routes
|
|
72
|
+
|
|
73
|
+
### GET with Search Params
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { createTodosCaller } from "../functions/generated/todos.runtime";
|
|
77
|
+
|
|
78
|
+
export const list = publicRoute
|
|
79
|
+
.get("/api/todos")
|
|
80
|
+
.searchParams(
|
|
81
|
+
z.object({
|
|
82
|
+
limit: z.coerce.number().optional().default(10),
|
|
83
|
+
offset: z.coerce.number().optional().default(0),
|
|
84
|
+
})
|
|
85
|
+
)
|
|
86
|
+
.output(z.array(todoSchema))
|
|
87
|
+
.query(async ({ ctx, searchParams }) => {
|
|
88
|
+
const caller = createTodosCaller(ctx);
|
|
89
|
+
return caller.list({
|
|
90
|
+
limit: searchParams.limit,
|
|
91
|
+
offset: searchParams.offset,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Use `z.coerce.number()` for search params since URL query strings are always strings.
|
|
97
|
+
|
|
98
|
+
### GET with Path Params
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
export const get = publicRoute
|
|
102
|
+
.get("/api/todos/:id")
|
|
103
|
+
.params(z.object({ id: z.string() }))
|
|
104
|
+
.output(todoSchema.nullable())
|
|
105
|
+
.query(async ({ ctx, params }) => {
|
|
106
|
+
const caller = createTodosCaller(ctx);
|
|
107
|
+
return caller.get({ id: params.id });
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### POST / PATCH / DELETE
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
export const create = authRoute
|
|
115
|
+
.post("/api/todos")
|
|
116
|
+
.input(
|
|
117
|
+
z.object({ title: z.string().min(1), description: z.string().optional() })
|
|
118
|
+
)
|
|
119
|
+
.output(z.object({ id: z.string() }))
|
|
120
|
+
.mutation(async ({ ctx, input }) => {
|
|
121
|
+
const caller = createTodoInternalCaller(ctx);
|
|
122
|
+
const id = await caller.create({ userId: ctx.userId, ...input });
|
|
123
|
+
return { id };
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export const update = authRoute
|
|
127
|
+
.patch("/api/todos/:id")
|
|
128
|
+
.params(z.object({ id: z.string() }))
|
|
129
|
+
.input(
|
|
130
|
+
z.object({
|
|
131
|
+
title: z.string().optional(),
|
|
132
|
+
completed: z.boolean().optional(),
|
|
133
|
+
})
|
|
134
|
+
)
|
|
135
|
+
.output(z.object({ success: z.boolean() }))
|
|
136
|
+
.mutation(async ({ ctx, params, input }) => {
|
|
137
|
+
const caller = createTodoInternalCaller(ctx);
|
|
138
|
+
await caller.update({ id: params.id, ...input });
|
|
139
|
+
return { success: true };
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
export const deleteTodo = authRoute
|
|
143
|
+
.delete("/api/todos/:id")
|
|
144
|
+
.params(z.object({ id: z.string() }))
|
|
145
|
+
.output(z.object({ success: z.boolean() }))
|
|
146
|
+
.mutation(async ({ ctx, params }) => {
|
|
147
|
+
const caller = createTodoInternalCaller(ctx);
|
|
148
|
+
await caller.deleteTodo({ id: params.id });
|
|
149
|
+
return { success: true };
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Routers
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
// convex/routers/todos.ts
|
|
157
|
+
export const todosRouter = router({
|
|
158
|
+
list,
|
|
159
|
+
get,
|
|
160
|
+
create,
|
|
161
|
+
update,
|
|
162
|
+
delete: deleteTodo,
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Combined Schemas
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
export const createTask = authRoute
|
|
170
|
+
.post("/api/projects/:projectId/tasks")
|
|
171
|
+
.params(z.object({ projectId: z.string() }))
|
|
172
|
+
.searchParams(z.object({ notify: z.coerce.boolean().optional() }))
|
|
173
|
+
.input(z.object({ title: z.string(), description: z.string().optional() }))
|
|
174
|
+
.output(z.object({ taskId: z.string(), projectId: z.string() }))
|
|
175
|
+
.mutation(async ({ ctx, params, searchParams, input }) => {
|
|
176
|
+
const caller = createTasksCaller(ctx);
|
|
177
|
+
const taskId = await caller.create({
|
|
178
|
+
projectId: params.projectId,
|
|
179
|
+
...input,
|
|
180
|
+
});
|
|
181
|
+
if (searchParams.notify) {
|
|
182
|
+
await caller.schedule.now.sendNotification({ taskId });
|
|
183
|
+
}
|
|
184
|
+
return { taskId, projectId: params.projectId };
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## FormData Uploads
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
// Server
|
|
192
|
+
export const upload = authRoute
|
|
193
|
+
.post("/api/files/upload")
|
|
194
|
+
.form(
|
|
195
|
+
z.object({
|
|
196
|
+
file: z.instanceof(File),
|
|
197
|
+
title: z.string().optional(),
|
|
198
|
+
tags: z.array(z.string()).optional(),
|
|
199
|
+
})
|
|
200
|
+
)
|
|
201
|
+
.mutation(async ({ ctx, c, form }) => {
|
|
202
|
+
const storageId = await ctx.storage.store(form.file);
|
|
203
|
+
return c.json({ storageId, filename: form.file.name });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Client
|
|
207
|
+
uploadFile.mutate({
|
|
208
|
+
form: { file: selectedFile, title: "My Document", tags: ["work"] },
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Metadata & Middleware
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
export const heavyEndpoint = publicRoute
|
|
216
|
+
.meta({ ratelimit: "api/heavy" })
|
|
217
|
+
.get("/api/reports")
|
|
218
|
+
.query(async ({ ctx }) => {
|
|
219
|
+
const caller = createReportsCaller(ctx);
|
|
220
|
+
return caller.generate({});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Chained meta (shallow merge)
|
|
224
|
+
export const adminEndpoint = authRoute
|
|
225
|
+
.meta({ role: "admin" })
|
|
226
|
+
.meta({ ratelimit: "api/admin" })
|
|
227
|
+
.delete("/api/users/:id")
|
|
228
|
+
.params(z.object({ id: z.string() }))
|
|
229
|
+
.mutation(async ({ ctx, params }) => {
|
|
230
|
+
const caller = createAdminCaller(ctx);
|
|
231
|
+
await caller.deleteUser({ id: params.id });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Custom middleware extending context
|
|
235
|
+
export const withPermissions = authRoute
|
|
236
|
+
.use(async ({ ctx, next }) => {
|
|
237
|
+
const caller = createPermissionsCaller(ctx);
|
|
238
|
+
const permissions = await caller.get({ userId: ctx.userId });
|
|
239
|
+
return next({ ctx: { ...ctx, permissions } });
|
|
240
|
+
})
|
|
241
|
+
.get("/api/protected")
|
|
242
|
+
.query(async ({ ctx }) => {
|
|
243
|
+
if (!ctx.permissions.includes("admin")) {
|
|
244
|
+
throw new CRPCError({ code: "FORBIDDEN", message: "Admin required" });
|
|
245
|
+
}
|
|
246
|
+
return { data: "secret" };
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Optional Auth
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
export const publicOrAuth = optionalAuthRoute
|
|
254
|
+
.get("/api/content")
|
|
255
|
+
.query(async ({ ctx }) => {
|
|
256
|
+
const caller = createContentCaller(ctx);
|
|
257
|
+
const userId: Id<"user"> | null = ctx.userId;
|
|
258
|
+
if (userId) return caller.personalized({ userId });
|
|
259
|
+
return caller.public({});
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Error Handling
|
|
264
|
+
|
|
265
|
+
See [Error Codes](#error-codes) in API Reference. Zod validation failures auto-return `400 Bad Request` with error details.
|
|
266
|
+
|
|
267
|
+
## Custom Responses
|
|
268
|
+
|
|
269
|
+
cRPC handlers receive `c` (Hono Context) for custom responses:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
// File download
|
|
273
|
+
export const download = authRoute
|
|
274
|
+
.get("/api/todos/export/:format")
|
|
275
|
+
.params(z.object({ format: z.enum(["json", "csv"]) }))
|
|
276
|
+
.query(async ({ ctx, params, c }) => {
|
|
277
|
+
const caller = createTodosCaller(ctx);
|
|
278
|
+
const todos = await caller.list({ limit: 100 });
|
|
279
|
+
c.header(
|
|
280
|
+
"Content-Disposition",
|
|
281
|
+
`attachment; filename="todos.${params.format}"`
|
|
282
|
+
);
|
|
283
|
+
c.header("Cache-Control", "no-cache");
|
|
284
|
+
if (params.format === "csv") {
|
|
285
|
+
const csv = [
|
|
286
|
+
"id,title,completed",
|
|
287
|
+
...todos.map((t) => `${t.id},${t.title},${t.completed}`),
|
|
288
|
+
].join("\n");
|
|
289
|
+
return c.text(csv);
|
|
290
|
+
}
|
|
291
|
+
return c.json({ todos });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Redirect
|
|
295
|
+
export const redirect = publicRoute
|
|
296
|
+
.get("/api/old-path")
|
|
297
|
+
.query(async ({ c }) => c.redirect("/api/new-path", 301));
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
| Method | Description |
|
|
301
|
+
| -------------------------- | -------------------- |
|
|
302
|
+
| `c.json(data)` | Return JSON response |
|
|
303
|
+
| `c.text(str)` | Return text response |
|
|
304
|
+
| `c.redirect(url, status?)` | Return redirect |
|
|
305
|
+
| `c.header(name, value)` | Set response header |
|
|
306
|
+
| `c.req.header(name)` | Get request header |
|
|
307
|
+
| `c.req.text()` | Get raw body as text |
|
|
308
|
+
|
|
309
|
+
## Streaming
|
|
310
|
+
|
|
311
|
+
### Server-Sent Events
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
import { streamText } from "hono/streaming";
|
|
315
|
+
|
|
316
|
+
export const events = publicRoute
|
|
317
|
+
.get("/api/stream")
|
|
318
|
+
.query(async ({ ctx, c }) => {
|
|
319
|
+
c.header("Content-Type", "text/event-stream");
|
|
320
|
+
c.header("Cache-Control", "no-cache");
|
|
321
|
+
return streamText(c, async (stream) => {
|
|
322
|
+
for (let i = 0; i < 10; i++) {
|
|
323
|
+
const caller = createDataCaller(ctx);
|
|
324
|
+
const data = await caller.getChunk({ index: i });
|
|
325
|
+
await stream.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
326
|
+
await stream.sleep(1000);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### AI Streaming
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
import { stream } from "hono/streaming";
|
|
336
|
+
|
|
337
|
+
export const chat = publicRoute
|
|
338
|
+
.post("/api/ai/stream")
|
|
339
|
+
.input(z.object({ prompt: z.string() }))
|
|
340
|
+
.mutation(async ({ ctx, input, c }) => {
|
|
341
|
+
const aiCaller = createAiCaller(ctx);
|
|
342
|
+
c.header("Content-Type", "text/event-stream");
|
|
343
|
+
c.header("Cache-Control", "no-cache");
|
|
344
|
+
const aiStream = await aiCaller.actions.streamResponse({
|
|
345
|
+
prompt: input.prompt,
|
|
346
|
+
});
|
|
347
|
+
return stream(c, async (stream) => {
|
|
348
|
+
await stream.pipe(aiStream);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## Rate Limiting
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
export const ratelimited = publicRoute
|
|
357
|
+
.post("/api/public")
|
|
358
|
+
.input(z.object({ data: z.string() }))
|
|
359
|
+
.mutation(async ({ ctx, input, c }) => {
|
|
360
|
+
const ip =
|
|
361
|
+
c.req.header("X-Forwarded-For")?.split(",")[0]?.trim() ??
|
|
362
|
+
c.req.header("CF-Connecting-IP") ??
|
|
363
|
+
"unknown";
|
|
364
|
+
const ratelimitCaller = createRatelimitCaller(ctx);
|
|
365
|
+
const allowed = await ratelimitCaller.check({
|
|
366
|
+
key: `http:${ip}`,
|
|
367
|
+
limit: 100,
|
|
368
|
+
window: 3600000,
|
|
369
|
+
});
|
|
370
|
+
if (!allowed)
|
|
371
|
+
return c.text("Rate limit exceeded", 429, { "Retry-After": "3600" });
|
|
372
|
+
const apiCaller = createApiCaller(ctx);
|
|
373
|
+
const result = await apiCaller.process({ data: input.data });
|
|
374
|
+
return c.json(result);
|
|
375
|
+
});
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
## Webhooks
|
|
379
|
+
|
|
380
|
+
### Stripe
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
export const stripeWebhook = publicRoute
|
|
384
|
+
.post("/webhooks/stripe")
|
|
385
|
+
.mutation(async ({ ctx, c }) => {
|
|
386
|
+
const stripeCaller = createStripeCaller(ctx);
|
|
387
|
+
const signature = c.req.header("stripe-signature");
|
|
388
|
+
if (!signature)
|
|
389
|
+
throw new CRPCError({ code: "BAD_REQUEST", message: "No signature" });
|
|
390
|
+
|
|
391
|
+
const body = await c.req.text();
|
|
392
|
+
const isValid = await stripeCaller.actions.verify({ body, signature });
|
|
393
|
+
if (!isValid)
|
|
394
|
+
throw new CRPCError({
|
|
395
|
+
code: "BAD_REQUEST",
|
|
396
|
+
message: "Invalid signature",
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const event = JSON.parse(body);
|
|
400
|
+
switch (event.type) {
|
|
401
|
+
case "payment_intent.succeeded":
|
|
402
|
+
const paymentsCaller = createPaymentsCaller(ctx);
|
|
403
|
+
await paymentsCaller.markPaid({
|
|
404
|
+
paymentIntentId: event.data.object.id,
|
|
405
|
+
});
|
|
406
|
+
break;
|
|
407
|
+
case "customer.subscription.deleted":
|
|
408
|
+
const subscriptionsCaller = createSubscriptionsCaller(ctx);
|
|
409
|
+
await subscriptionsCaller.cancel({
|
|
410
|
+
subscriptionId: event.data.object.id,
|
|
411
|
+
});
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
return c.text("OK", 200);
|
|
415
|
+
});
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### Discord Bot
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
import { verifyKey } from "discord-interactions";
|
|
422
|
+
|
|
423
|
+
export const discordWebhook = publicRoute
|
|
424
|
+
.post("/webhooks/discord")
|
|
425
|
+
.mutation(async ({ ctx, c }) => {
|
|
426
|
+
const signature = c.req.header("X-Signature-Ed25519");
|
|
427
|
+
const timestamp = c.req.header("X-Signature-Timestamp");
|
|
428
|
+
if (!signature || !timestamp)
|
|
429
|
+
throw new CRPCError({
|
|
430
|
+
code: "UNAUTHORIZED",
|
|
431
|
+
message: "Missing signature",
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const body = await c.req.text();
|
|
435
|
+
if (
|
|
436
|
+
!verifyKey(body, signature, timestamp, process.env.DISCORD_PUBLIC_KEY!)
|
|
437
|
+
) {
|
|
438
|
+
throw new CRPCError({
|
|
439
|
+
code: "UNAUTHORIZED",
|
|
440
|
+
message: "Invalid signature",
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const interaction = JSON.parse(body);
|
|
445
|
+
if (interaction.type === 1) return c.json({ type: 1 }); // PING
|
|
446
|
+
if (interaction.type === 2) {
|
|
447
|
+
const statsCaller = createStatsCaller(ctx);
|
|
448
|
+
const discordCaller = createDiscordCaller(ctx);
|
|
449
|
+
switch (interaction.data.name) {
|
|
450
|
+
case "stats":
|
|
451
|
+
const stats = await statsCaller.get({});
|
|
452
|
+
return c.json({
|
|
453
|
+
type: 4,
|
|
454
|
+
data: { content: `Users: ${stats.users}, Posts: ${stats.posts}` },
|
|
455
|
+
});
|
|
456
|
+
case "create":
|
|
457
|
+
await discordCaller.schedule.now.processCreate({
|
|
458
|
+
token: interaction.token,
|
|
459
|
+
});
|
|
460
|
+
return c.json({ type: 5 }); // DEFERRED
|
|
461
|
+
default:
|
|
462
|
+
return c.json({ type: 4, data: { content: "Unknown command" } });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (interaction.type === 3) {
|
|
466
|
+
const discordCaller = createDiscordCaller(ctx);
|
|
467
|
+
await discordCaller.handleButton({
|
|
468
|
+
customId: interaction.data.custom_id,
|
|
469
|
+
userId: interaction.user.id,
|
|
470
|
+
});
|
|
471
|
+
return c.json({ type: 7, data: { content: "Done!" } });
|
|
472
|
+
}
|
|
473
|
+
throw new CRPCError({
|
|
474
|
+
code: "BAD_REQUEST",
|
|
475
|
+
message: "Unknown interaction",
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
## React Client
|
|
481
|
+
|
|
482
|
+
See [Input Args](#input-args) in API Reference.
|
|
483
|
+
|
|
484
|
+
### Query Patterns
|
|
485
|
+
|
|
486
|
+
```ts
|
|
487
|
+
// GET with searchParams
|
|
488
|
+
crpc.http.todos.list.queryOptions({ searchParams: { limit: "10" } });
|
|
489
|
+
|
|
490
|
+
// GET with path params
|
|
491
|
+
crpc.http.todos.get.queryOptions({ params: { id: todoId } });
|
|
492
|
+
|
|
493
|
+
// GET with custom headers
|
|
494
|
+
crpc.http.todos.list.queryOptions({
|
|
495
|
+
searchParams: { limit: "10" },
|
|
496
|
+
headers: { "X-Custom": "value" },
|
|
497
|
+
});
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
### One-Time Fetch
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
// For exports/downloads (no caching, mutation semantics)
|
|
504
|
+
const exportTodos = useMutation(crpc.http.todos.export.mutationOptions());
|
|
505
|
+
exportTodos.mutate({ params: { format: "csv" } });
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### Vanilla Client
|
|
509
|
+
|
|
510
|
+
```ts
|
|
511
|
+
const client = useCRPCClient();
|
|
512
|
+
const todos = await client.http.todos.list.query();
|
|
513
|
+
await client.http.todos.create.mutate({ title: "New todo" });
|
|
514
|
+
|
|
515
|
+
// For cache-aware fetches in render context
|
|
516
|
+
const queryClient = useQueryClient();
|
|
517
|
+
const todos = await queryClient.fetchQuery(crpc.http.todos.list.queryOptions());
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
### staticQueryOptions
|
|
521
|
+
|
|
522
|
+
For prefetching in event handlers (doesn't use hooks internally):
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
const queryClient = useQueryClient();
|
|
526
|
+
const handleMouseEnter = () => {
|
|
527
|
+
queryClient.prefetchQuery(crpc.http.todos.list.staticQueryOptions());
|
|
528
|
+
};
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
`staticQueryOptions` doesn't include reactive auth state. Auth handled at execution time.
|
|
532
|
+
|
|
533
|
+
### Mutation Patterns
|
|
534
|
+
|
|
535
|
+
```ts
|
|
536
|
+
const createTodo = useMutation(
|
|
537
|
+
crpc.http.todos.create.mutationOptions({ onSuccess: () => queryClient.invalidateQueries(...) })
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
createTodo.mutate({ title: 'New Todo' }); // JSON body at root
|
|
541
|
+
updateTodo.mutate({ params: { id: '123' }, completed: true }); // PATCH with params + body
|
|
542
|
+
deleteTodo.mutate({ params: { id: '123' } }); // DELETE with params
|
|
543
|
+
uploadFile.mutate({ form: { file: selectedFile, description: 'My file' } }); // FormData
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
### Cache Invalidation
|
|
547
|
+
|
|
548
|
+
```ts
|
|
549
|
+
const updateTodo = useMutation(
|
|
550
|
+
crpc.http.todos.update.mutationOptions({
|
|
551
|
+
onSuccess: (_, vars) => {
|
|
552
|
+
queryClient.invalidateQueries(crpc.http.todos.list.queryFilter());
|
|
553
|
+
queryClient.invalidateQueries(
|
|
554
|
+
crpc.http.todos.get.queryFilter({ params: { id: vars.params?.id } })
|
|
555
|
+
);
|
|
556
|
+
},
|
|
557
|
+
})
|
|
558
|
+
);
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
See [Client Methods](#client-methods) in API Reference.
|
|
562
|
+
|
|
563
|
+
## RSC Prefetching
|
|
564
|
+
|
|
565
|
+
```tsx
|
|
566
|
+
// app/todos/page.tsx
|
|
567
|
+
import { crpc, HydrateClient, prefetch } from "@/lib/convex/rsc";
|
|
568
|
+
|
|
569
|
+
export default async function TodosPage() {
|
|
570
|
+
prefetch(
|
|
571
|
+
crpc.http.todos.list.queryOptions({ searchParams: { limit: "10" } })
|
|
572
|
+
);
|
|
573
|
+
return (
|
|
574
|
+
<HydrateClient>
|
|
575
|
+
<TodoList />
|
|
576
|
+
</HydrateClient>
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
### Awaited Prefetch
|
|
582
|
+
|
|
583
|
+
```tsx
|
|
584
|
+
const todo = await preloadQuery(
|
|
585
|
+
crpc.http.todos.get.queryOptions({ params: { id } })
|
|
586
|
+
);
|
|
587
|
+
if (!todo) notFound();
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
### Auth-Aware Prefetch
|
|
591
|
+
|
|
592
|
+
```tsx
|
|
593
|
+
prefetch(
|
|
594
|
+
crpc.http.todos.list.queryOptions(
|
|
595
|
+
{ searchParams: { limit: "10" } },
|
|
596
|
+
{ skipUnauth: true }
|
|
597
|
+
)
|
|
598
|
+
);
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
| Pattern | Blocking | Server Access | Client Hydration |
|
|
602
|
+
| ---------------- | -------- | ------------- | ---------------- |
|
|
603
|
+
| `prefetch()` | No | No | Yes |
|
|
604
|
+
| `preloadQuery()` | Yes | Yes | Yes |
|
|
605
|
+
|
|
606
|
+
## Server-Side Calls
|
|
607
|
+
|
|
608
|
+
```ts
|
|
609
|
+
import { createContext } from "@/lib/convex/server";
|
|
610
|
+
|
|
611
|
+
const ctx = await createContext({ headers: request.headers });
|
|
612
|
+
const todos = await ctx.caller.todos.list({ limit: 10 });
|
|
613
|
+
if (ctx.isAuthenticated) await ctx.caller.todos.create({ title: "New task" });
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
## API Reference
|
|
617
|
+
|
|
618
|
+
### Route Builder Patterns
|
|
619
|
+
|
|
620
|
+
| Pattern | Use Case |
|
|
621
|
+
| ---------------------------------------- | ------------------------ |
|
|
622
|
+
| `publicRoute.get('/path').query()` | Public GET endpoint |
|
|
623
|
+
| `authRoute.post('/path').mutation()` | Auth-required POST |
|
|
624
|
+
| `optionalAuthRoute.get('/path').query()` | Optional auth endpoint |
|
|
625
|
+
| `.params(z.object({id}))` | Path params `/todos/:id` |
|
|
626
|
+
| `.searchParams(z.object({limit}))` | Query params `?limit=10` |
|
|
627
|
+
| `.input(z.object({...}))` | JSON body (POST/PATCH) |
|
|
628
|
+
| `.form(z.object({file, description}))` | FormData uploads |
|
|
629
|
+
| `.output(z.object({...}))` | Response validation |
|
|
630
|
+
| `.meta({ ratelimit: 'api/heavy' })` | Procedure metadata |
|
|
631
|
+
| `.use(middleware)` | Custom middleware |
|
|
632
|
+
| `router({ endpoint1, endpoint2 })` | Group endpoints |
|
|
633
|
+
|
|
634
|
+
### HTTP Methods
|
|
635
|
+
|
|
636
|
+
| Method | Builder | Use Case | Has Body |
|
|
637
|
+
| ------ | ---------------------- | ----------------- | -------- |
|
|
638
|
+
| GET | `.get().query()` | Read operations | No |
|
|
639
|
+
| POST | `.post().mutation()` | Create operations | Yes |
|
|
640
|
+
| PATCH | `.patch().mutation()` | Partial updates | Yes |
|
|
641
|
+
| DELETE | `.delete().mutation()` | Delete operations | No |
|
|
642
|
+
|
|
643
|
+
### Error Codes
|
|
644
|
+
|
|
645
|
+
| Code | HTTP Status | Use Case |
|
|
646
|
+
| ----------------------- | ----------- | --------------------------------- |
|
|
647
|
+
| `BAD_REQUEST` | 400 | Invalid request format |
|
|
648
|
+
| `UNAUTHORIZED` | 401 | Missing or invalid authentication |
|
|
649
|
+
| `FORBIDDEN` | 403 | Authenticated but not authorized |
|
|
650
|
+
| `NOT_FOUND` | 404 | Resource doesn't exist |
|
|
651
|
+
| `CONFLICT` | 409 | Resource conflict (duplicate) |
|
|
652
|
+
| `UNPROCESSABLE_CONTENT` | 422 | Validation failed |
|
|
653
|
+
| `TOO_MANY_REQUESTS` | 429 | Rate limit exceeded |
|
|
654
|
+
| `INTERNAL_SERVER_ERROR` | 500 | Unexpected server error |
|
|
655
|
+
|
|
656
|
+
### Input Args
|
|
657
|
+
|
|
658
|
+
| Property | Type | Description |
|
|
659
|
+
| -------------- | --------------------------------------- | ------------------------------------- |
|
|
660
|
+
| `params` | `Record<string, string>` | Path parameters (`:id`) |
|
|
661
|
+
| `searchParams` | `Record<string, string \| string[]>` | Query string params |
|
|
662
|
+
| `form` | `z.infer<TForm>` | Typed FormData (if `.form()` defined) |
|
|
663
|
+
| `fetch` | `typeof fetch` | Custom fetch function |
|
|
664
|
+
| `init` | `RequestInit` | Request options |
|
|
665
|
+
| `headers` | `Record<string, string> \| (() => ...)` | Headers (incl. cookies) |
|
|
666
|
+
| `[key]` | `unknown` | JSON body fields at root |
|
|
667
|
+
|
|
668
|
+
### Client Methods
|
|
669
|
+
|
|
670
|
+
| Method | Signature | Description |
|
|
671
|
+
| -------------------- | --------------------- | ----------------------------------------- |
|
|
672
|
+
| `queryOptions` | `(args?, queryOpts?)` | Options for `useQuery`/`useSuspenseQuery` |
|
|
673
|
+
| `staticQueryOptions` | `(args?, queryOpts?)` | For event handlers/prefetching (no hooks) |
|
|
674
|
+
| `mutationOptions` | `(mutationOpts?)` | Options for `useMutation` |
|
|
675
|
+
| `queryKey` | `(args?)` | Get query key for cache operations |
|
|
676
|
+
| `queryFilter` | `(args?, filters?)` | Filter for `invalidateQueries` |
|