@donkeylabs/server 0.1.1 → 0.1.2
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/cli/donkeylabs +6 -0
- package/context.d.ts +17 -0
- package/docs/api-client.md +520 -0
- package/docs/cache.md +437 -0
- package/docs/cli.md +353 -0
- package/docs/core-services.md +338 -0
- package/docs/cron.md +465 -0
- package/docs/errors.md +303 -0
- package/docs/events.md +460 -0
- package/docs/handlers.md +549 -0
- package/docs/jobs.md +556 -0
- package/docs/logger.md +316 -0
- package/docs/middleware.md +682 -0
- package/docs/plugins.md +524 -0
- package/docs/project-structure.md +493 -0
- package/docs/rate-limiter.md +525 -0
- package/docs/router.md +566 -0
- package/docs/sse.md +542 -0
- package/docs/svelte-frontend.md +324 -0
- package/package.json +12 -9
- package/registry.d.ts +11 -0
- package/src/index.ts +1 -1
- package/src/server.ts +1 -0
package/docs/router.md
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
# Router & Routes
|
|
2
|
+
|
|
3
|
+
Fluent API for defining type-safe routes with handler selection and middleware chaining.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createRouter } from "./router";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
const router = createRouter("api")
|
|
12
|
+
.route("hello").typed({
|
|
13
|
+
input: z.object({ name: z.string() }),
|
|
14
|
+
handle: async (input, ctx) => {
|
|
15
|
+
return { message: `Hello, ${input.name}!` };
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## API Reference
|
|
23
|
+
|
|
24
|
+
### createRouter
|
|
25
|
+
|
|
26
|
+
Create a new router with optional prefix:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
const router = createRouter("api"); // Routes prefixed with "api."
|
|
30
|
+
const router = createRouter(); // No prefix
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Router Methods
|
|
34
|
+
|
|
35
|
+
| Method | Description |
|
|
36
|
+
|--------|-------------|
|
|
37
|
+
| `route(name)` | Start defining a route, returns RouteBuilder |
|
|
38
|
+
| `middleware` | Start middleware chain, returns MiddlewareBuilder |
|
|
39
|
+
| `getRoutes()` | Get all registered route definitions |
|
|
40
|
+
|
|
41
|
+
### RouteBuilder Methods
|
|
42
|
+
|
|
43
|
+
After calling `router.route("name")`, you get a RouteBuilder with handler methods:
|
|
44
|
+
|
|
45
|
+
| Method | Description |
|
|
46
|
+
|--------|-------------|
|
|
47
|
+
| `.typed(config)` | JSON-RPC style handler (default) |
|
|
48
|
+
| `.raw(config)` | Full Request/Response control |
|
|
49
|
+
| `.<custom>(config)` | Custom handlers from plugins |
|
|
50
|
+
|
|
51
|
+
### MiddlewareBuilder Methods
|
|
52
|
+
|
|
53
|
+
After calling `router.middleware`, chain middleware then define routes:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
router.middleware
|
|
57
|
+
.auth({ required: true })
|
|
58
|
+
.rateLimit({ limit: 100, window: "1m" })
|
|
59
|
+
.route("protected").typed({ ... });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Route Naming
|
|
65
|
+
|
|
66
|
+
Routes are named as `prefix.name`:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
const router = createRouter("users");
|
|
70
|
+
|
|
71
|
+
router.route("list"); // Route name: "users.list"
|
|
72
|
+
router.route("get"); // Route name: "users.get"
|
|
73
|
+
router.route("create"); // Route name: "users.create"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**HTTP Requests:**
|
|
77
|
+
```sh
|
|
78
|
+
POST /users.list # Calls users.list handler
|
|
79
|
+
POST /users.get # Calls users.get handler
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Handler Types
|
|
85
|
+
|
|
86
|
+
### Typed Handler (Default)
|
|
87
|
+
|
|
88
|
+
JSON-RPC style with automatic validation:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
router.route("greet").typed({
|
|
92
|
+
// Optional: Zod schema for input validation
|
|
93
|
+
input: z.object({
|
|
94
|
+
name: z.string(),
|
|
95
|
+
age: z.number().optional(),
|
|
96
|
+
}),
|
|
97
|
+
|
|
98
|
+
// Optional: Zod schema for output validation
|
|
99
|
+
output: z.object({
|
|
100
|
+
message: z.string(),
|
|
101
|
+
}),
|
|
102
|
+
|
|
103
|
+
// Required: Handler function
|
|
104
|
+
handle: async (input, ctx) => {
|
|
105
|
+
return { message: `Hello, ${input.name}!` };
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Behavior:**
|
|
111
|
+
- POST only (returns 405 for other methods)
|
|
112
|
+
- Parses JSON body automatically
|
|
113
|
+
- Validates input against schema (returns 400 on failure)
|
|
114
|
+
- Validates output against schema
|
|
115
|
+
- Returns JSON response
|
|
116
|
+
|
|
117
|
+
### Raw Handler
|
|
118
|
+
|
|
119
|
+
Full control over Request/Response:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
router.route("download").raw({
|
|
123
|
+
handle: async (req, ctx) => {
|
|
124
|
+
const file = await Bun.file("data.csv").text();
|
|
125
|
+
return new Response(file, {
|
|
126
|
+
headers: { "Content-Type": "text/csv" },
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Use cases:**
|
|
133
|
+
- File uploads/downloads
|
|
134
|
+
- Streaming responses
|
|
135
|
+
- SSE endpoints
|
|
136
|
+
- Custom content types
|
|
137
|
+
- WebSocket upgrades
|
|
138
|
+
|
|
139
|
+
### Custom Handlers
|
|
140
|
+
|
|
141
|
+
Plugins can register custom handlers:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
// Plugin registers "echo" handler
|
|
145
|
+
router.route("test").echo({
|
|
146
|
+
handle: async (body, ctx) => {
|
|
147
|
+
return { echo: body };
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
See [Handlers Documentation](handlers.md) for creating custom handlers.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Server Context
|
|
157
|
+
|
|
158
|
+
Every handler receives `ServerContext`:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
router.route("example").typed({
|
|
162
|
+
handle: async (input, ctx) => {
|
|
163
|
+
// Database (Kysely)
|
|
164
|
+
const users = await ctx.db.selectFrom("users").selectAll().execute();
|
|
165
|
+
|
|
166
|
+
// Plugin services
|
|
167
|
+
const data = await ctx.plugins.myPlugin.getData();
|
|
168
|
+
|
|
169
|
+
// Core services
|
|
170
|
+
ctx.core.logger.info("Processing request", { input });
|
|
171
|
+
const cached = await ctx.core.cache.get("key");
|
|
172
|
+
|
|
173
|
+
// Request info
|
|
174
|
+
console.log(ctx.ip); // Client IP
|
|
175
|
+
console.log(ctx.requestId); // Unique request ID
|
|
176
|
+
console.log(ctx.user); // Set by auth middleware
|
|
177
|
+
|
|
178
|
+
return { users };
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Middleware
|
|
186
|
+
|
|
187
|
+
Apply middleware before routes:
|
|
188
|
+
|
|
189
|
+
### Single Middleware
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
router.middleware
|
|
193
|
+
.auth({ required: true })
|
|
194
|
+
.route("protected").typed({
|
|
195
|
+
handle: async (input, ctx) => {
|
|
196
|
+
// ctx.user is guaranteed by auth middleware
|
|
197
|
+
return { userId: ctx.user.id };
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Chained Middleware
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
router.middleware
|
|
206
|
+
.cors({ origin: "*" })
|
|
207
|
+
.auth({ required: true })
|
|
208
|
+
.rateLimit({ limit: 100, window: "1m" })
|
|
209
|
+
.route("api").typed({
|
|
210
|
+
handle: async (input, ctx) => {
|
|
211
|
+
// All middleware applied
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Middleware for Multiple Routes
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const protectedRoutes = router.middleware
|
|
220
|
+
.auth({ required: true })
|
|
221
|
+
.rateLimit({ limit: 1000, window: "1h" });
|
|
222
|
+
|
|
223
|
+
protectedRoutes.route("profile").typed({ ... });
|
|
224
|
+
protectedRoutes.route("settings").typed({ ... });
|
|
225
|
+
protectedRoutes.route("orders").typed({ ... });
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Input Validation
|
|
231
|
+
|
|
232
|
+
Use Zod schemas for automatic validation:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
import { z } from "zod";
|
|
236
|
+
|
|
237
|
+
const CreateUserInput = z.object({
|
|
238
|
+
email: z.string().email(),
|
|
239
|
+
name: z.string().min(1).max(100),
|
|
240
|
+
age: z.number().int().positive().optional(),
|
|
241
|
+
role: z.enum(["user", "admin"]).default("user"),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
router.route("createUser").typed({
|
|
245
|
+
input: CreateUserInput,
|
|
246
|
+
handle: async (input, ctx) => {
|
|
247
|
+
// input is typed and validated
|
|
248
|
+
// input.email: string
|
|
249
|
+
// input.name: string
|
|
250
|
+
// input.age: number | undefined
|
|
251
|
+
// input.role: "user" | "admin"
|
|
252
|
+
|
|
253
|
+
const user = await ctx.db.insertInto("users")
|
|
254
|
+
.values(input)
|
|
255
|
+
.returningAll()
|
|
256
|
+
.executeTakeFirstOrThrow();
|
|
257
|
+
|
|
258
|
+
return user;
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**Validation Errors:**
|
|
264
|
+
|
|
265
|
+
```json
|
|
266
|
+
{
|
|
267
|
+
"error": "Validation Failed",
|
|
268
|
+
"details": [
|
|
269
|
+
{
|
|
270
|
+
"path": ["email"],
|
|
271
|
+
"message": "Invalid email"
|
|
272
|
+
}
|
|
273
|
+
]
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## Output Validation
|
|
280
|
+
|
|
281
|
+
Validate and type your responses:
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
const UserResponse = z.object({
|
|
285
|
+
id: z.number(),
|
|
286
|
+
email: z.string(),
|
|
287
|
+
createdAt: z.string(),
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
router.route("getUser").typed({
|
|
291
|
+
input: z.object({ id: z.number() }),
|
|
292
|
+
output: UserResponse,
|
|
293
|
+
handle: async (input, ctx) => {
|
|
294
|
+
const user = await ctx.db.selectFrom("users")
|
|
295
|
+
.selectAll()
|
|
296
|
+
.where("id", "=", input.id)
|
|
297
|
+
.executeTakeFirstOrThrow();
|
|
298
|
+
|
|
299
|
+
// Return type is validated against UserResponse
|
|
300
|
+
return user;
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Real-World Examples
|
|
308
|
+
|
|
309
|
+
### CRUD Operations
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
const router = createRouter("users");
|
|
313
|
+
|
|
314
|
+
// List users
|
|
315
|
+
router.route("list").typed({
|
|
316
|
+
input: z.object({
|
|
317
|
+
page: z.number().default(1),
|
|
318
|
+
limit: z.number().default(20),
|
|
319
|
+
}),
|
|
320
|
+
handle: async (input, ctx) => {
|
|
321
|
+
const offset = (input.page - 1) * input.limit;
|
|
322
|
+
|
|
323
|
+
const users = await ctx.db.selectFrom("users")
|
|
324
|
+
.selectAll()
|
|
325
|
+
.limit(input.limit)
|
|
326
|
+
.offset(offset)
|
|
327
|
+
.execute();
|
|
328
|
+
|
|
329
|
+
return { users, page: input.page };
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// Get single user
|
|
334
|
+
router.route("get").typed({
|
|
335
|
+
input: z.object({ id: z.number() }),
|
|
336
|
+
handle: async (input, ctx) => {
|
|
337
|
+
const user = await ctx.db.selectFrom("users")
|
|
338
|
+
.selectAll()
|
|
339
|
+
.where("id", "=", input.id)
|
|
340
|
+
.executeTakeFirstOrThrow();
|
|
341
|
+
|
|
342
|
+
return user;
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Create user
|
|
347
|
+
router.middleware
|
|
348
|
+
.auth({ required: true, role: "admin" })
|
|
349
|
+
.route("create").typed({
|
|
350
|
+
input: z.object({
|
|
351
|
+
email: z.string().email(),
|
|
352
|
+
name: z.string(),
|
|
353
|
+
}),
|
|
354
|
+
handle: async (input, ctx) => {
|
|
355
|
+
const user = await ctx.db.insertInto("users")
|
|
356
|
+
.values(input)
|
|
357
|
+
.returningAll()
|
|
358
|
+
.executeTakeFirstOrThrow();
|
|
359
|
+
|
|
360
|
+
await ctx.core.events.emit("user.created", user);
|
|
361
|
+
|
|
362
|
+
return user;
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// Update user
|
|
367
|
+
router.middleware
|
|
368
|
+
.auth({ required: true })
|
|
369
|
+
.route("update").typed({
|
|
370
|
+
input: z.object({
|
|
371
|
+
id: z.number(),
|
|
372
|
+
name: z.string().optional(),
|
|
373
|
+
email: z.string().email().optional(),
|
|
374
|
+
}),
|
|
375
|
+
handle: async (input, ctx) => {
|
|
376
|
+
const { id, ...updates } = input;
|
|
377
|
+
|
|
378
|
+
const user = await ctx.db.updateTable("users")
|
|
379
|
+
.set(updates)
|
|
380
|
+
.where("id", "=", id)
|
|
381
|
+
.returningAll()
|
|
382
|
+
.executeTakeFirstOrThrow();
|
|
383
|
+
|
|
384
|
+
return user;
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Delete user
|
|
389
|
+
router.middleware
|
|
390
|
+
.auth({ required: true, role: "admin" })
|
|
391
|
+
.route("delete").typed({
|
|
392
|
+
input: z.object({ id: z.number() }),
|
|
393
|
+
handle: async (input, ctx) => {
|
|
394
|
+
await ctx.db.deleteFrom("users")
|
|
395
|
+
.where("id", "=", input.id)
|
|
396
|
+
.execute();
|
|
397
|
+
|
|
398
|
+
return { success: true };
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### File Upload (Raw Handler)
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
router.route("upload").raw({
|
|
407
|
+
handle: async (req, ctx) => {
|
|
408
|
+
const formData = await req.formData();
|
|
409
|
+
const file = formData.get("file") as File;
|
|
410
|
+
|
|
411
|
+
if (!file) {
|
|
412
|
+
return Response.json({ error: "No file provided" }, { status: 400 });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const buffer = await file.arrayBuffer();
|
|
416
|
+
const path = `uploads/${Date.now()}-${file.name}`;
|
|
417
|
+
|
|
418
|
+
await Bun.write(path, buffer);
|
|
419
|
+
|
|
420
|
+
return Response.json({
|
|
421
|
+
path,
|
|
422
|
+
size: file.size,
|
|
423
|
+
type: file.type,
|
|
424
|
+
});
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### SSE Endpoint (Raw Handler)
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
router.route("events").raw({
|
|
433
|
+
handle: async (req, ctx) => {
|
|
434
|
+
const { client, response } = ctx.core.sse.addClient();
|
|
435
|
+
ctx.core.sse.subscribe(client.id, `user:${ctx.user.id}`);
|
|
436
|
+
return response;
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### Streaming Response
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
router.route("stream").raw({
|
|
445
|
+
handle: async (req, ctx) => {
|
|
446
|
+
const stream = new ReadableStream({
|
|
447
|
+
async start(controller) {
|
|
448
|
+
for (let i = 0; i < 10; i++) {
|
|
449
|
+
controller.enqueue(`data: ${i}\n\n`);
|
|
450
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
451
|
+
}
|
|
452
|
+
controller.close();
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
return new Response(stream, {
|
|
457
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
458
|
+
});
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## Route Registration
|
|
466
|
+
|
|
467
|
+
Register routes with the server:
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
import { AppServer } from "./server";
|
|
471
|
+
import { userRouter } from "./routes/users";
|
|
472
|
+
import { orderRouter } from "./routes/orders";
|
|
473
|
+
|
|
474
|
+
const server = new AppServer({ db, port: 3000 });
|
|
475
|
+
|
|
476
|
+
// Register single router
|
|
477
|
+
server.use(userRouter);
|
|
478
|
+
|
|
479
|
+
// Register multiple routers
|
|
480
|
+
server.use(userRouter);
|
|
481
|
+
server.use(orderRouter);
|
|
482
|
+
|
|
483
|
+
await server.start();
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
---
|
|
487
|
+
|
|
488
|
+
## Best Practices
|
|
489
|
+
|
|
490
|
+
### 1. Organize by Domain
|
|
491
|
+
|
|
492
|
+
```
|
|
493
|
+
routes/
|
|
494
|
+
├── users.ts # createRouter("users")
|
|
495
|
+
├── orders.ts # createRouter("orders")
|
|
496
|
+
├── products.ts # createRouter("products")
|
|
497
|
+
└── index.ts # Export all routers
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
### 2. Use Descriptive Route Names
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
// Good - clear action
|
|
504
|
+
router.route("list");
|
|
505
|
+
router.route("get");
|
|
506
|
+
router.route("create");
|
|
507
|
+
router.route("update");
|
|
508
|
+
router.route("delete");
|
|
509
|
+
|
|
510
|
+
// Bad - ambiguous
|
|
511
|
+
router.route("data");
|
|
512
|
+
router.route("do");
|
|
513
|
+
router.route("handle");
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
### 3. Validate All Input
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
// Good - always validate
|
|
520
|
+
router.route("create").typed({
|
|
521
|
+
input: z.object({ email: z.string().email() }),
|
|
522
|
+
handle: async (input, ctx) => { ... },
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Bad - trusting client input
|
|
526
|
+
router.route("create").typed({
|
|
527
|
+
handle: async (input, ctx) => {
|
|
528
|
+
// input is untyped `any`
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
### 4. Use Middleware for Cross-Cutting Concerns
|
|
534
|
+
|
|
535
|
+
```ts
|
|
536
|
+
// Good - middleware for auth
|
|
537
|
+
router.middleware
|
|
538
|
+
.auth({ required: true })
|
|
539
|
+
.route("protected").typed({ ... });
|
|
540
|
+
|
|
541
|
+
// Bad - auth check in every handler
|
|
542
|
+
router.route("protected").typed({
|
|
543
|
+
handle: async (input, ctx) => {
|
|
544
|
+
if (!ctx.user) throw new Error("Unauthorized");
|
|
545
|
+
// ...
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
### 5. Keep Handlers Focused
|
|
551
|
+
|
|
552
|
+
```ts
|
|
553
|
+
// Good - focused handler, delegates to service
|
|
554
|
+
router.route("create").typed({
|
|
555
|
+
handle: async (input, ctx) => {
|
|
556
|
+
return ctx.plugins.users.create(input);
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// Bad - business logic in handler
|
|
561
|
+
router.route("create").typed({
|
|
562
|
+
handle: async (input, ctx) => {
|
|
563
|
+
// 100 lines of business logic...
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
```
|