@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.
@@ -0,0 +1,682 @@
1
+ # Middleware
2
+
3
+ Request/response middleware for cross-cutting concerns like authentication, rate limiting, CORS, and logging.
4
+
5
+ ## Quick Start
6
+
7
+ ```ts
8
+ import { createMiddleware } from "./middleware";
9
+
10
+ // Create middleware with config
11
+ const authMiddleware = createMiddleware<{ required: boolean }>(
12
+ async (req, ctx, next, config) => {
13
+ const token = req.headers.get("Authorization");
14
+
15
+ if (config.required && !token) {
16
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
17
+ }
18
+
19
+ // Modify context
20
+ ctx.user = await validateToken(token);
21
+
22
+ // Continue to next middleware/handler
23
+ return next();
24
+ }
25
+ );
26
+ ```
27
+
28
+ ---
29
+
30
+ ## API Reference
31
+
32
+ ### Types
33
+
34
+ ```ts
35
+ // The next function - calls the next middleware or handler
36
+ type NextFn = () => Promise<Response>;
37
+
38
+ // Middleware function signature
39
+ type MiddlewareFn<TConfig = void> = (
40
+ req: Request,
41
+ ctx: ServerContext,
42
+ next: NextFn,
43
+ config: TConfig
44
+ ) => Promise<Response>;
45
+
46
+ // Runtime middleware structure
47
+ interface MiddlewareRuntime<TConfig = void> {
48
+ execute: MiddlewareFn<TConfig>;
49
+ readonly __config: TConfig; // Phantom type for config inference
50
+ }
51
+ ```
52
+
53
+ ### createMiddleware Factory
54
+
55
+ ```ts
56
+ function createMiddleware<TConfig = void>(
57
+ execute: MiddlewareFn<TConfig>
58
+ ): MiddlewareRuntime<TConfig>;
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Creating Middleware
64
+
65
+ ### Step 1: Define Middleware
66
+
67
+ ```ts
68
+ // middleware/auth.ts
69
+ import { createMiddleware } from "../middleware";
70
+
71
+ interface AuthConfig {
72
+ required?: boolean;
73
+ role?: string;
74
+ }
75
+
76
+ export const authMiddleware = createMiddleware<AuthConfig>(
77
+ async (req, ctx, next, config) => {
78
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
79
+
80
+ if (!token) {
81
+ if (config.required) {
82
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
83
+ }
84
+ return next();
85
+ }
86
+
87
+ try {
88
+ const user = await verifyToken(token);
89
+
90
+ if (config.role && user.role !== config.role) {
91
+ return Response.json({ error: "Forbidden" }, { status: 403 });
92
+ }
93
+
94
+ ctx.user = user;
95
+ } catch {
96
+ if (config.required) {
97
+ return Response.json({ error: "Invalid token" }, { status: 401 });
98
+ }
99
+ }
100
+
101
+ return next();
102
+ }
103
+ );
104
+ ```
105
+
106
+ ### Step 2: Register in Plugin
107
+
108
+ ```ts
109
+ // plugins/auth/index.ts
110
+ import { createPlugin } from "../../core";
111
+ import { authMiddleware } from "./middleware";
112
+
113
+ export const authPlugin = createPlugin.define({
114
+ name: "auth",
115
+ middleware: {
116
+ auth: authMiddleware, // Key becomes method name
117
+ },
118
+ service: async (ctx) => ({
119
+ // Auth service methods...
120
+ }),
121
+ });
122
+ ```
123
+
124
+ ### Step 3: Regenerate Registry
125
+
126
+ ```sh
127
+ bun run gen:registry
128
+ ```
129
+
130
+ ### Step 4: Use in Routes
131
+
132
+ ```ts
133
+ // Now available as .auth() method
134
+ router.middleware
135
+ .auth({ required: true, role: "admin" })
136
+ .route("admin").typed({ ... });
137
+ ```
138
+
139
+ ---
140
+
141
+ ## Middleware Examples
142
+
143
+ ### Rate Limiting Middleware
144
+
145
+ ```ts
146
+ interface RateLimitConfig {
147
+ limit: number;
148
+ window: string; // "1m", "1h", etc.
149
+ keyPrefix?: string;
150
+ }
151
+
152
+ export const rateLimitMiddleware = createMiddleware<RateLimitConfig>(
153
+ async (req, ctx, next, config) => {
154
+ const windowMs = parseDuration(config.window);
155
+ const key = config.keyPrefix
156
+ ? `${config.keyPrefix}:${ctx.ip}`
157
+ : `ratelimit:${ctx.ip}`;
158
+
159
+ const result = await ctx.core.rateLimiter.check(key, config.limit, windowMs);
160
+
161
+ if (!result.allowed) {
162
+ return Response.json(
163
+ { error: "Rate limit exceeded" },
164
+ {
165
+ status: 429,
166
+ headers: { "Retry-After": String(result.retryAfter) },
167
+ }
168
+ );
169
+ }
170
+
171
+ const response = await next();
172
+
173
+ // Add rate limit headers
174
+ response.headers.set("X-RateLimit-Limit", String(config.limit));
175
+ response.headers.set("X-RateLimit-Remaining", String(result.remaining));
176
+
177
+ return response;
178
+ }
179
+ );
180
+ ```
181
+
182
+ ### CORS Middleware
183
+
184
+ ```ts
185
+ interface CORSConfig {
186
+ origin?: string | string[];
187
+ methods?: string[];
188
+ headers?: string[];
189
+ credentials?: boolean;
190
+ }
191
+
192
+ export const corsMiddleware = createMiddleware<CORSConfig>(
193
+ async (req, ctx, next, config) => {
194
+ const origin = req.headers.get("Origin");
195
+ const allowedOrigins = Array.isArray(config.origin)
196
+ ? config.origin
197
+ : [config.origin || "*"];
198
+
199
+ // Handle preflight
200
+ if (req.method === "OPTIONS") {
201
+ return new Response(null, {
202
+ status: 204,
203
+ headers: {
204
+ "Access-Control-Allow-Origin": allowedOrigins.includes(origin!)
205
+ ? origin!
206
+ : allowedOrigins[0],
207
+ "Access-Control-Allow-Methods": (config.methods || ["GET", "POST"]).join(", "),
208
+ "Access-Control-Allow-Headers": (config.headers || ["Content-Type"]).join(", "),
209
+ "Access-Control-Allow-Credentials": String(config.credentials ?? false),
210
+ },
211
+ });
212
+ }
213
+
214
+ const response = await next();
215
+
216
+ // Add CORS headers to response
217
+ if (origin && (allowedOrigins.includes("*") || allowedOrigins.includes(origin))) {
218
+ response.headers.set("Access-Control-Allow-Origin", origin);
219
+ if (config.credentials) {
220
+ response.headers.set("Access-Control-Allow-Credentials", "true");
221
+ }
222
+ }
223
+
224
+ return response;
225
+ }
226
+ );
227
+ ```
228
+
229
+ ### Logging Middleware
230
+
231
+ ```ts
232
+ interface LogConfig {
233
+ level?: "debug" | "info";
234
+ includeBody?: boolean;
235
+ }
236
+
237
+ export const loggingMiddleware = createMiddleware<LogConfig>(
238
+ async (req, ctx, next, config) => {
239
+ const start = Date.now();
240
+
241
+ const logData: any = {
242
+ method: req.method,
243
+ url: req.url,
244
+ ip: ctx.ip,
245
+ requestId: ctx.requestId,
246
+ };
247
+
248
+ if (config.includeBody && req.method === "POST") {
249
+ try {
250
+ logData.body = await req.clone().json();
251
+ } catch {}
252
+ }
253
+
254
+ const log = config.level === "debug"
255
+ ? ctx.core.logger.debug
256
+ : ctx.core.logger.info;
257
+
258
+ log("Request started", logData);
259
+
260
+ const response = await next();
261
+
262
+ log("Request completed", {
263
+ ...logData,
264
+ status: response.status,
265
+ duration: Date.now() - start,
266
+ });
267
+
268
+ return response;
269
+ }
270
+ );
271
+ ```
272
+
273
+ ### Caching Middleware
274
+
275
+ ```ts
276
+ interface CacheConfig {
277
+ ttl: number;
278
+ keyFn?: (req: Request, ctx: ServerContext) => string;
279
+ }
280
+
281
+ export const cacheMiddleware = createMiddleware<CacheConfig>(
282
+ async (req, ctx, next, config) => {
283
+ // Only cache GET requests
284
+ if (req.method !== "GET" && req.method !== "POST") {
285
+ return next();
286
+ }
287
+
288
+ const key = config.keyFn
289
+ ? config.keyFn(req, ctx)
290
+ : `cache:${new URL(req.url).pathname}`;
291
+
292
+ // Check cache
293
+ const cached = await ctx.core.cache.get<{ body: string; headers: Record<string, string> }>(key);
294
+
295
+ if (cached) {
296
+ return new Response(cached.body, {
297
+ headers: {
298
+ ...cached.headers,
299
+ "X-Cache": "HIT",
300
+ },
301
+ });
302
+ }
303
+
304
+ // Get fresh response
305
+ const response = await next();
306
+
307
+ // Cache the response
308
+ if (response.ok) {
309
+ const body = await response.clone().text();
310
+ const headers: Record<string, string> = {};
311
+ response.headers.forEach((v, k) => (headers[k] = v));
312
+
313
+ await ctx.core.cache.set(key, { body, headers }, config.ttl);
314
+ response.headers.set("X-Cache", "MISS");
315
+ }
316
+
317
+ return response;
318
+ }
319
+ );
320
+ ```
321
+
322
+ ### Validation Middleware
323
+
324
+ ```ts
325
+ import { z } from "zod";
326
+
327
+ interface ValidationConfig {
328
+ headers?: z.ZodType;
329
+ query?: z.ZodType;
330
+ }
331
+
332
+ export const validateMiddleware = createMiddleware<ValidationConfig>(
333
+ async (req, ctx, next, config) => {
334
+ // Validate headers
335
+ if (config.headers) {
336
+ const headers: Record<string, string> = {};
337
+ req.headers.forEach((v, k) => (headers[k] = v));
338
+
339
+ const result = config.headers.safeParse(headers);
340
+ if (!result.success) {
341
+ return Response.json(
342
+ { error: "Invalid headers", details: result.error.issues },
343
+ { status: 400 }
344
+ );
345
+ }
346
+ }
347
+
348
+ // Validate query params
349
+ if (config.query) {
350
+ const url = new URL(req.url);
351
+ const query: Record<string, string> = {};
352
+ url.searchParams.forEach((v, k) => (query[k] = v));
353
+
354
+ const result = config.query.safeParse(query);
355
+ if (!result.success) {
356
+ return Response.json(
357
+ { error: "Invalid query parameters", details: result.error.issues },
358
+ { status: 400 }
359
+ );
360
+ }
361
+ }
362
+
363
+ return next();
364
+ }
365
+ );
366
+ ```
367
+
368
+ ---
369
+
370
+ ## Using Middleware
371
+
372
+ ### Single Middleware
373
+
374
+ ```ts
375
+ router.middleware
376
+ .auth({ required: true })
377
+ .route("protected").typed({
378
+ handle: async (input, ctx) => {
379
+ return { userId: ctx.user.id };
380
+ },
381
+ });
382
+ ```
383
+
384
+ ### Chained Middleware
385
+
386
+ Middleware executes in order (left to right):
387
+
388
+ ```ts
389
+ router.middleware
390
+ .cors({ origin: "*" }) // 1st: CORS handling
391
+ .logging({ level: "info" }) // 2nd: Log request start
392
+ .auth({ required: true }) // 3rd: Check authentication
393
+ .rateLimit({ limit: 100, window: "1m" }) // 4th: Rate limiting
394
+ .route("api").typed({
395
+ handle: async (input, ctx) => {
396
+ // All middleware passed
397
+ },
398
+ });
399
+ ```
400
+
401
+ ### Reusable Middleware Chain
402
+
403
+ ```ts
404
+ // Create reusable middleware chain
405
+ const protectedApi = router.middleware
406
+ .cors({ origin: "https://myapp.com" })
407
+ .auth({ required: true })
408
+ .rateLimit({ limit: 1000, window: "1h" });
409
+
410
+ // Apply to multiple routes
411
+ protectedApi.route("users").typed({ ... });
412
+ protectedApi.route("orders").typed({ ... });
413
+ protectedApi.route("products").typed({ ... });
414
+ ```
415
+
416
+ ### Conditional Middleware
417
+
418
+ ```ts
419
+ const baseMiddleware = router.middleware.cors({ origin: "*" });
420
+
421
+ // Add auth only in production
422
+ const middleware = process.env.NODE_ENV === "production"
423
+ ? baseMiddleware.auth({ required: true })
424
+ : baseMiddleware;
425
+
426
+ middleware.route("api").typed({ ... });
427
+ ```
428
+
429
+ ---
430
+
431
+ ## Middleware Execution Flow
432
+
433
+ ```
434
+ Request
435
+
436
+
437
+ ┌─────────────────────────────────┐
438
+ │ Middleware 1 (CORS) │
439
+ │ │ │
440
+ │ ▼ │
441
+ │ Middleware 2 (Auth) │
442
+ │ │ │
443
+ │ ▼ │
444
+ │ Middleware 3 (RateLimit) │
445
+ │ │ │
446
+ │ ▼ │
447
+ │ ┌───────────────────────────┐ │
448
+ │ │ Handler │ │
449
+ │ │ (your route handler) │ │
450
+ │ └───────────────────────────┘ │
451
+ │ │ │
452
+ │ ▼ │
453
+ │ Middleware 3 (post-handler) │
454
+ │ │ │
455
+ │ ▼ │
456
+ │ Middleware 2 (post-handler) │
457
+ │ │ │
458
+ │ ▼ │
459
+ │ Middleware 1 (post-handler) │
460
+ └─────────────────────────────────┘
461
+
462
+
463
+ Response
464
+ ```
465
+
466
+ ---
467
+
468
+ ## Modifying Context
469
+
470
+ Middleware can add properties to `ctx`:
471
+
472
+ ```ts
473
+ // Auth middleware adds ctx.user
474
+ const authMiddleware = createMiddleware<AuthConfig>(async (req, ctx, next, config) => {
475
+ ctx.user = await validateToken(req.headers.get("Authorization"));
476
+ return next();
477
+ });
478
+
479
+ // Handler can access ctx.user
480
+ router.middleware.auth({ required: true }).route("profile").typed({
481
+ handle: async (input, ctx) => {
482
+ return { name: ctx.user.name }; // ctx.user is set
483
+ },
484
+ });
485
+ ```
486
+
487
+ ---
488
+
489
+ ## Response Modification
490
+
491
+ Middleware can modify the response:
492
+
493
+ ```ts
494
+ const timingMiddleware = createMiddleware(async (req, ctx, next) => {
495
+ const start = Date.now();
496
+
497
+ // Get response from handler
498
+ const response = await next();
499
+
500
+ // Add timing header
501
+ response.headers.set("X-Response-Time", `${Date.now() - start}ms`);
502
+
503
+ return response;
504
+ });
505
+ ```
506
+
507
+ ---
508
+
509
+ ## Early Returns
510
+
511
+ Middleware can return early without calling `next()`:
512
+
513
+ ```ts
514
+ const maintenanceMiddleware = createMiddleware(async (req, ctx, next) => {
515
+ if (process.env.MAINTENANCE_MODE === "true") {
516
+ return Response.json(
517
+ { error: "Service under maintenance" },
518
+ { status: 503 }
519
+ );
520
+ }
521
+
522
+ return next(); // Continue if not in maintenance
523
+ });
524
+ ```
525
+
526
+ ---
527
+
528
+ ## Error Handling
529
+
530
+ ```ts
531
+ const errorMiddleware = createMiddleware(async (req, ctx, next) => {
532
+ try {
533
+ return await next();
534
+ } catch (error: any) {
535
+ ctx.core.logger.error("Unhandled error", {
536
+ error: error.message,
537
+ stack: error.stack,
538
+ requestId: ctx.requestId,
539
+ });
540
+
541
+ return Response.json(
542
+ { error: "Internal server error", requestId: ctx.requestId },
543
+ { status: 500 }
544
+ );
545
+ }
546
+ });
547
+ ```
548
+
549
+ ---
550
+
551
+ ## Best Practices
552
+
553
+ ### 1. Keep Middleware Focused
554
+
555
+ ```ts
556
+ // Good - single responsibility
557
+ const authMiddleware = createMiddleware(...); // Just auth
558
+ const rateLimitMiddleware = createMiddleware(...); // Just rate limiting
559
+ const loggingMiddleware = createMiddleware(...); // Just logging
560
+
561
+ // Bad - too many responsibilities
562
+ const everythingMiddleware = createMiddleware(async (req, ctx, next) => {
563
+ // Check auth
564
+ // Rate limit
565
+ // Log
566
+ // Validate
567
+ // Cache
568
+ // ...
569
+ });
570
+ ```
571
+
572
+ ### 2. Order Matters
573
+
574
+ ```ts
575
+ // Good order
576
+ router.middleware
577
+ .cors() // Handle CORS first (for preflight)
578
+ .logging() // Log all requests
579
+ .auth() // Then authenticate
580
+ .rateLimit() // Then rate limit
581
+ .route("api")
582
+
583
+ // Bad order
584
+ router.middleware
585
+ .rateLimit() // Rate limit before auth = limit by IP only
586
+ .auth() // Auth after rate limit = may waste rate limit on bad tokens
587
+ .cors() // CORS late = preflight requests fail
588
+ ```
589
+
590
+ ### 3. Make Config Optional When Possible
591
+
592
+ ```ts
593
+ interface AuthConfig {
594
+ required?: boolean; // Default: false
595
+ role?: string; // Default: any role
596
+ }
597
+
598
+ // Allows simple usage
599
+ router.middleware.auth().route("optional-auth")
600
+ router.middleware.auth({ required: true }).route("required-auth")
601
+ ```
602
+
603
+ ### 4. Document Configuration
604
+
605
+ ```ts
606
+ /**
607
+ * Rate limiting middleware
608
+ *
609
+ * @param config.limit - Max requests in window (default: 100)
610
+ * @param config.window - Time window ("1m", "1h", "1d")
611
+ * @param config.keyPrefix - Cache key prefix (default: "ratelimit")
612
+ *
613
+ * @example
614
+ * router.middleware.rateLimit({ limit: 100, window: "1m" })
615
+ */
616
+ export const rateLimitMiddleware = createMiddleware<RateLimitConfig>(...);
617
+ ```
618
+
619
+ ### 5. Test Middleware in Isolation
620
+
621
+ ```ts
622
+ import { authMiddleware } from "./middleware/auth";
623
+
624
+ test("auth middleware rejects invalid token", async () => {
625
+ const req = new Request("http://test", {
626
+ headers: { Authorization: "Bearer invalid" },
627
+ });
628
+
629
+ const ctx = createMockContext();
630
+ const next = vi.fn();
631
+
632
+ const response = await authMiddleware.execute(req, ctx, next, { required: true });
633
+
634
+ expect(response.status).toBe(401);
635
+ expect(next).not.toHaveBeenCalled();
636
+ });
637
+ ```
638
+
639
+ ---
640
+
641
+ ## Common Patterns
642
+
643
+ ### Authentication + Authorization
644
+
645
+ ```ts
646
+ // First check if authenticated, then check role
647
+ router.middleware
648
+ .auth({ required: true })
649
+ .role({ allowed: ["admin", "moderator"] })
650
+ .route("admin").typed({ ... });
651
+ ```
652
+
653
+ ### Public Routes with Optional Auth
654
+
655
+ ```ts
656
+ // Auth runs but doesn't require login
657
+ router.middleware
658
+ .auth({ required: false })
659
+ .route("public").typed({
660
+ handle: async (input, ctx) => {
661
+ // ctx.user may or may not exist
662
+ if (ctx.user) {
663
+ return { message: `Hello, ${ctx.user.name}!` };
664
+ }
665
+ return { message: "Hello, guest!" };
666
+ },
667
+ });
668
+ ```
669
+
670
+ ### Environment-Specific Middleware
671
+
672
+ ```ts
673
+ const middleware = router.middleware.cors({ origin: "*" });
674
+
675
+ if (process.env.NODE_ENV === "production") {
676
+ middleware
677
+ .auth({ required: true })
678
+ .rateLimit({ limit: 100, window: "1m" });
679
+ }
680
+
681
+ middleware.route("api").typed({ ... });
682
+ ```