@donkeylabs/server 1.0.0 → 1.1.0

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.
@@ -141,17 +141,201 @@ const order = await api.orders.create({ items: [...] }, {
141
141
 
142
142
  ### Raw Routes
143
143
 
144
- For non-JSON endpoints:
144
+ For full Request/Response control:
145
145
 
146
146
  ```ts
147
147
  // Server-side
148
- router.route("download").raw({
149
- handle: async (req, ctx) => new Response(fileBuffer),
148
+ router.route("proxy").raw({
149
+ handle: async (req, ctx) => fetch("https://api.example.com"),
150
150
  });
151
151
 
152
152
  // Client-side (returns raw Response)
153
- const response = await api.files.download();
153
+ const response = await api.proxy();
154
+ const data = await response.json();
155
+ ```
156
+
157
+ ### Stream Routes
158
+
159
+ Validated input with binary/streaming Response:
160
+
161
+ ```ts
162
+ // Server-side
163
+ router.route("files.download").stream({
164
+ input: z.object({ fileId: z.string(), quality: z.enum(["low", "high"]) }),
165
+ handle: async (input, ctx) => {
166
+ const file = await ctx.plugins.storage.get(input.fileId);
167
+ return new Response(file.stream, {
168
+ headers: { "Content-Type": file.mimeType },
169
+ });
170
+ },
171
+ });
172
+
173
+ // Client-side (typed input, returns Response)
174
+ const response = await api.files.download({ fileId: "abc", quality: "high" });
154
175
  const blob = await response.blob();
176
+
177
+ // For video/audio streaming
178
+ const videoUrl = URL.createObjectURL(await response.blob());
179
+ ```
180
+
181
+ ### SSE Routes
182
+
183
+ Server-Sent Events with automatic channel subscription and **typed event handlers**:
184
+
185
+ ```ts
186
+ // Server-side - define events schema for type-safe client
187
+ router.route("notifications.subscribe").sse({
188
+ input: z.object({ userId: z.string() }),
189
+ events: {
190
+ notification: z.object({ message: z.string(), id: z.string() }),
191
+ announcement: z.object({ title: z.string(), urgent: z.boolean() }),
192
+ },
193
+ handle: (input, ctx) => [`user:${input.userId}`, "global"],
194
+ });
195
+
196
+ // Client-side (returns typed SSEConnection)
197
+ const connection = api.notifications.subscribe({ userId: "123" });
198
+
199
+ // Type-safe event handlers - data is fully typed!
200
+ const unsubNotif = connection.on("notification", (data) => {
201
+ // data: { message: string; id: string }
202
+ console.log("New notification:", data.message);
203
+ });
204
+
205
+ const unsubAnnounce = connection.on("announcement", (data) => {
206
+ // data: { title: string; urgent: boolean }
207
+ if (data.urgent) showAlert(data.title);
208
+ });
209
+
210
+ // Unsubscribe from specific handlers
211
+ unsubNotif();
212
+
213
+ // Handle connection events
214
+ connection.onError((e) => console.error("Connection lost"));
215
+ connection.onOpen(() => console.log("Connected"));
216
+
217
+ // Check connection state
218
+ console.log(connection.connected); // boolean
219
+ console.log(connection.readyState); // EventSource.OPEN, CONNECTING, CLOSED
220
+
221
+ // Close entire connection
222
+ connection.close();
223
+ ```
224
+
225
+ **Svelte 5 example:**
226
+ ```svelte
227
+ <script lang="ts">
228
+ import { createApi } from '$lib/api';
229
+ import type { SSEConnection } from '@donkeylabs/adapter-sveltekit/client';
230
+
231
+ const api = createApi();
232
+ let messages = $state<{ message: string; id: string }[]>([]);
233
+ let connection: SSEConnection | null = null;
234
+
235
+ function connect() {
236
+ connection = api.notifications.subscribe({ userId: "123" });
237
+
238
+ // Typed event handler - no JSON.parse needed!
239
+ connection.on("notification", (data) => {
240
+ messages = [...messages, data]; // data is already typed
241
+ });
242
+ }
243
+
244
+ // Cleanup on unmount
245
+ $effect(() => () => connection?.close());
246
+ </script>
247
+
248
+ <button onclick={connect}>Connect</button>
249
+ {#each messages as msg}<p>{msg.message}</p>{/each}
250
+ ```
251
+
252
+ ### FormData Routes
253
+
254
+ File uploads with validated fields:
255
+
256
+ ```ts
257
+ // Server-side
258
+ router.route("files.upload").formData({
259
+ input: z.object({ folder: z.string(), tags: z.array(z.string()).optional() }),
260
+ output: z.object({ ids: z.array(z.string()), count: z.number() }),
261
+ files: { maxSize: 10 * 1024 * 1024, accept: ["image/*", "application/pdf"] },
262
+ handle: async ({ fields, files }, ctx) => {
263
+ const ids = await Promise.all(files.map(f => saveFile(f, fields.folder)));
264
+ return { ids, count: files.length };
265
+ },
266
+ });
267
+
268
+ // Client-side (fields + files array)
269
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
270
+ const files = Array.from(fileInput.files || []);
271
+
272
+ const result = await api.files.upload(
273
+ { folder: "photos", tags: ["vacation", "2024"] },
274
+ files
275
+ );
276
+ console.log(`Uploaded ${result.count} files:`, result.ids);
277
+ ```
278
+
279
+ **Svelte 5 example:**
280
+ ```svelte
281
+ <script lang="ts">
282
+ import { createApi } from '$lib/api';
283
+
284
+ const api = createApi();
285
+ let uploading = $state(false);
286
+ let result = $state<{ ids: string[]; count: number } | null>(null);
287
+
288
+ async function handleUpload(e: Event) {
289
+ const input = e.target as HTMLInputElement;
290
+ const files = Array.from(input.files || []);
291
+ if (!files.length) return;
292
+
293
+ uploading = true;
294
+ try {
295
+ result = await api.files.upload({ folder: "uploads" }, files);
296
+ } finally {
297
+ uploading = false;
298
+ }
299
+ }
300
+ </script>
301
+
302
+ <input type="file" multiple onchange={handleUpload} disabled={uploading} />
303
+ {#if result}<p>Uploaded {result.count} files</p>{/if}
304
+ ```
305
+
306
+ ### HTML Routes
307
+
308
+ HTML responses for htmx and server components:
309
+
310
+ ```ts
311
+ // Server-side
312
+ router.route("components.userCard").html({
313
+ input: z.object({ userId: z.string() }),
314
+ handle: async (input, ctx) => {
315
+ const user = await ctx.plugins.users.get(input.userId);
316
+ return `<div class="card">${user.name}</div>`;
317
+ },
318
+ });
319
+
320
+ // Client-side (returns HTML string)
321
+ const html = await api.components.userCard({ userId: "123" });
322
+ document.getElementById("container")!.innerHTML = html;
323
+ ```
324
+
325
+ **htmx usage (no client needed):**
326
+ ```html
327
+ <!-- htmx calls the route directly -->
328
+ <div hx-get="/api/components.userCard?userId=123"
329
+ hx-trigger="load"
330
+ hx-swap="innerHTML">
331
+ Loading...
332
+ </div>
333
+
334
+ <!-- With click trigger -->
335
+ <button hx-get="/api/components.userCard?userId=456"
336
+ hx-target="#card-container">
337
+ Load User
338
+ </button>
155
339
  ```
156
340
 
157
341
  ---
package/docs/handlers.md CHANGED
@@ -23,6 +23,15 @@ export const MyHandler = createHandler<MyFn>(async (req, def, handle, ctx) => {
23
23
 
24
24
  ## Built-in Handlers
25
25
 
26
+ | Handler | Input | Output | HTTP Methods | Use Case |
27
+ |---------|-------|--------|--------------|----------|
28
+ | `typed` | Zod-validated JSON | Zod-validated JSON | POST | Standard API endpoints |
29
+ | `raw` | Full Request | Full Response | Any | Proxies, WebSockets, custom protocols |
30
+ | `stream` | Zod-validated (query/JSON) | Response (binary/stream) | GET, POST | File downloads, video/image serving |
31
+ | `sse` | Zod-validated (query/JSON) | SSE connection | GET, POST | Real-time notifications |
32
+ | `formData` | Zod-validated fields + files | Zod-validated JSON | POST | File uploads |
33
+ | `html` | Zod-validated (query/JSON) | HTML string | GET, POST | htmx, server components |
34
+
26
35
  ### TypedHandler (Default)
27
36
 
28
37
  JSON-RPC style handler with automatic validation:
@@ -59,29 +68,347 @@ router.route("greet").typed({
59
68
  Full control over Request and Response:
60
69
 
61
70
  ```ts
62
- router.route("upload").raw({
71
+ router.route("proxy").raw({
63
72
  handle: async (req, ctx) => {
64
- if (req.method !== "POST") {
65
- return new Response("Method Not Allowed", { status: 405 });
66
- }
73
+ // Full access to request
74
+ const response = await fetch("https://api.example.com" + new URL(req.url).pathname, {
75
+ method: req.method,
76
+ headers: req.headers,
77
+ body: req.body,
78
+ });
79
+ return response;
80
+ },
81
+ });
82
+ ```
67
83
 
68
- const formData = await req.formData();
69
- const file = formData.get("file") as File;
84
+ **Use cases:**
85
+ - Proxying requests
86
+ - WebSocket upgrades
87
+ - Custom protocols
88
+ - Non-standard HTTP methods
70
89
 
71
- // Process file...
90
+ ---
72
91
 
73
- return Response.json({ success: true });
92
+ ### StreamHandler
93
+
94
+ Validated input with custom Response output. Best for binary data and streaming:
95
+
96
+ ```ts
97
+ router.route("files.download").stream({
98
+ input: z.object({
99
+ fileId: z.string(),
100
+ format: z.enum(["mp4", "webm"])
101
+ }),
102
+ handle: async (input, ctx) => {
103
+ const file = await ctx.plugins.storage.getFile(input.fileId);
104
+
105
+ return new Response(file.stream, {
106
+ headers: {
107
+ "Content-Type": `video/${input.format}`,
108
+ "Content-Disposition": `attachment; filename="${input.fileId}.${input.format}"`,
109
+ },
110
+ });
74
111
  },
75
112
  });
76
113
  ```
77
114
 
115
+ **Behavior:**
116
+ - Accepts GET (query params) or POST (JSON body)
117
+ - Parses and validates input with Zod
118
+ - Returns Response directly (no output validation)
119
+
78
120
  **Use cases:**
79
- - File uploads/downloads
80
- - Streaming responses
81
- - Server-Sent Events
82
- - Custom content types
83
- - WebSocket upgrades
84
- - Non-JSON APIs
121
+ - File downloads with parameters
122
+ - Video/audio streaming
123
+ - Binary data with metadata
124
+ - Custom content-types
125
+ - Image serving (`<img src="...">`)
126
+ - Video embedding (`<video src="...">`)
127
+
128
+ **Generated Client:**
129
+
130
+ The generated client provides three methods for stream routes:
131
+
132
+ ```ts
133
+ // 1. fetch() - POST request with JSON body (programmatic use)
134
+ const response = await api.files.download.fetch({ fileId: "abc", format: "mp4" });
135
+ const blob = await response.blob();
136
+
137
+ // 2. url() - GET URL for browser src attributes
138
+ const url = api.files.download.url({ fileId: "abc", format: "mp4" });
139
+ // Returns: "/api.files.download?fileId=abc&format=mp4"
140
+
141
+ // 3. get() - GET request with query params
142
+ const response = await api.files.download.get({ fileId: "abc", format: "mp4" });
143
+ ```
144
+
145
+ **HTML/Svelte Usage:**
146
+
147
+ ```svelte
148
+ <script>
149
+ import { createApi } from '$lib/api';
150
+ const api = createApi();
151
+ </script>
152
+
153
+ <!-- Video element with stream URL -->
154
+ <video src={api.videos.stream.url({ id: "video-123" })} controls />
155
+
156
+ <!-- Image with dynamic src -->
157
+ <img src={api.images.thumbnail.url({ id: "img-456", size: "medium" })} />
158
+
159
+ <!-- Download link -->
160
+ <a href={api.files.download.url({ fileId: "doc-789" })} download>
161
+ Download File
162
+ </a>
163
+ ```
164
+
165
+ ---
166
+
167
+ ### SSEHandler
168
+
169
+ Server-Sent Events with validated input, automatic channel subscription, and **typed events**:
170
+
171
+ ```ts
172
+ router.route("notifications.subscribe").sse({
173
+ input: z.object({
174
+ userId: z.string(),
175
+ channels: z.array(z.string()).optional(),
176
+ }),
177
+ // Define event schemas for type-safe generated clients
178
+ events: {
179
+ notification: z.object({ message: z.string(), id: z.string() }),
180
+ announcement: z.object({ title: z.string(), urgent: z.boolean() }),
181
+ },
182
+ handle: (input, ctx) => {
183
+ // Return channel names to subscribe to
184
+ const channels = [`user:${input.userId}`, "global"];
185
+ if (input.channels) {
186
+ channels.push(...input.channels);
187
+ }
188
+ return channels;
189
+ },
190
+ });
191
+ ```
192
+
193
+ **Behavior:**
194
+ - Accepts GET (query params) or POST (JSON body)
195
+ - Validates input with Zod schema
196
+ - Creates SSE connection via `ctx.core.sse`
197
+ - Subscribes client to returned channels
198
+ - Supports `Last-Event-ID` header for reconnection
199
+ - `events` schema enables typed event handlers in generated client
200
+
201
+ **Broadcasting Events (Server-side):**
202
+ ```ts
203
+ // In your service or handler
204
+ ctx.core.sse.broadcast("user:123", "notification", {
205
+ message: "New message received",
206
+ id: "notif-123"
207
+ });
208
+
209
+ // Broadcast to all connected clients
210
+ ctx.core.sse.broadcastAll("announcement", {
211
+ title: "Server maintenance in 5 minutes",
212
+ urgent: true
213
+ });
214
+ ```
215
+
216
+ **Generated Client (Typed):**
217
+ ```ts
218
+ // Connect to SSE endpoint - returns typed SSEConnection
219
+ const connection = api.notifications.subscribe({ userId: "123" });
220
+
221
+ // Type-safe event handlers - data is fully typed, no JSON.parse needed!
222
+ const unsubNotif = connection.on("notification", (data) => {
223
+ // data: { message: string; id: string }
224
+ console.log("Notification:", data.message);
225
+ });
226
+
227
+ const unsubAnnounce = connection.on("announcement", (data) => {
228
+ // data: { title: string; urgent: boolean }
229
+ if (data.urgent) showAlert(data.title);
230
+ });
231
+
232
+ // Unsubscribe from specific handlers
233
+ unsubNotif();
234
+
235
+ // Handle connection events
236
+ connection.onError((e) => console.error("SSE connection error"));
237
+ connection.onOpen(() => console.log("Connected"));
238
+
239
+ // Check connection state
240
+ connection.connected; // boolean
241
+ connection.readyState; // 0=CONNECTING, 1=OPEN, 2=CLOSED
242
+
243
+ // Close entire connection
244
+ connection.close();
245
+ ```
246
+
247
+ **Svelte 5 Example:**
248
+ ```svelte
249
+ <script lang="ts">
250
+ import { createApi } from '$lib/api';
251
+ import type { SSEConnection } from '@donkeylabs/adapter-sveltekit/client';
252
+
253
+ const api = createApi();
254
+ let notifications = $state<{ message: string; id: string }[]>([]);
255
+ let connection: SSEConnection | null = null;
256
+
257
+ function connect(userId: string) {
258
+ connection = api.notifications.subscribe({ userId });
259
+
260
+ // Typed event handler - no JSON.parse needed!
261
+ connection.on("notification", (data) => {
262
+ notifications = [...notifications, data]; // data is already typed
263
+ });
264
+ }
265
+
266
+ function disconnect() {
267
+ connection?.close();
268
+ connection = null;
269
+ }
270
+
271
+ // Cleanup on unmount
272
+ $effect(() => {
273
+ return () => connection?.close();
274
+ });
275
+ </script>
276
+ ```
277
+
278
+ ---
279
+
280
+ ### FormDataHandler
281
+
282
+ File uploads with validated form fields and file constraints:
283
+
284
+ ```ts
285
+ router.route("files.upload").formData({
286
+ input: z.object({
287
+ folder: z.string(),
288
+ description: z.string().optional(),
289
+ }),
290
+ output: z.object({
291
+ ids: z.array(z.string()),
292
+ count: z.number(),
293
+ }),
294
+ files: {
295
+ maxSize: 10 * 1024 * 1024, // 10MB
296
+ accept: ["image/*", "application/pdf"],
297
+ },
298
+ handle: async ({ fields, files }, ctx) => {
299
+ const ids: string[] = [];
300
+
301
+ for (const file of files) {
302
+ const id = await ctx.plugins.storage.save(file, fields.folder);
303
+ ids.push(id);
304
+ }
305
+
306
+ return { ids, count: files.length };
307
+ },
308
+ });
309
+ ```
310
+
311
+ **Behavior:**
312
+ - Accepts POST requests only
313
+ - Requires `multipart/form-data` content type
314
+ - Separates form fields from files
315
+ - Validates fields with Zod schema
316
+ - Validates output with Zod schema (if provided)
317
+ - Enforces file constraints before calling handler
318
+
319
+ **File Constraints:**
320
+ ```ts
321
+ files: {
322
+ maxSize?: number; // Max file size in bytes
323
+ accept?: string[]; // MIME types (supports wildcards like "image/*")
324
+ }
325
+ ```
326
+
327
+ **Error Responses:**
328
+
329
+ | Status | Condition |
330
+ |--------|-----------|
331
+ | 405 | Non-POST request |
332
+ | 400 | Not multipart/form-data |
333
+ | 400 | Field validation failed |
334
+ | 400 | File exceeds maxSize |
335
+ | 400 | File type not in accept list |
336
+
337
+ **Generated Client:**
338
+ ```ts
339
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
340
+ const files = Array.from(fileInput.files || []);
341
+
342
+ const result = await api.files.upload(
343
+ { folder: "uploads", description: "My photos" },
344
+ files
345
+ );
346
+ console.log(`Uploaded ${result.count} files:`, result.ids);
347
+ ```
348
+
349
+ ---
350
+
351
+ ### HTMLHandler
352
+
353
+ Returns HTML responses. Perfect for htmx, partial renders, and server components:
354
+
355
+ ```ts
356
+ router.route("components.userCard").html({
357
+ input: z.object({ userId: z.string() }),
358
+ handle: async (input, ctx) => {
359
+ const user = await ctx.plugins.users.get(input.userId);
360
+
361
+ return `
362
+ <div class="card" id="user-${user.id}">
363
+ <img src="${user.avatar}" alt="${user.name}" />
364
+ <h3>${user.name}</h3>
365
+ <p>${user.bio}</p>
366
+ </div>
367
+ `;
368
+ },
369
+ });
370
+ ```
371
+
372
+ **Behavior:**
373
+ - Accepts GET (query params) or POST (JSON/form-urlencoded)
374
+ - Validates input with Zod schema
375
+ - Returns `text/html` content type
376
+ - Can return string (wrapped in Response) or custom Response
377
+ - Returns HTML-formatted errors
378
+
379
+ **Use Cases:**
380
+ - htmx partials
381
+ - Server-side rendered components
382
+ - Email templates
383
+ - PDF generation (return Response with different content-type)
384
+
385
+ **Returning Custom Response:**
386
+ ```ts
387
+ router.route("pages.redirect").html({
388
+ input: z.object({ to: z.string() }),
389
+ handle: (input) => {
390
+ return new Response(null, {
391
+ status: 302,
392
+ headers: { Location: input.to },
393
+ });
394
+ },
395
+ });
396
+ ```
397
+
398
+ **Generated Client:**
399
+ ```ts
400
+ const html = await api.components.userCard({ userId: "123" });
401
+ document.getElementById("container").innerHTML = html;
402
+ ```
403
+
404
+ **htmx Example:**
405
+ ```html
406
+ <div hx-get="/api/components.userCard?userId=123"
407
+ hx-trigger="load"
408
+ hx-swap="innerHTML">
409
+ Loading...
410
+ </div>
411
+ ```
85
412
 
86
413
  ---
87
414
 
package/docs/router.md CHANGED
@@ -44,8 +44,12 @@ After calling `router.route("name")`, you get a RouteBuilder with handler method
44
44
 
45
45
  | Method | Description |
46
46
  |--------|-------------|
47
- | `.typed(config)` | JSON-RPC style handler (default) |
47
+ | `.typed(config)` | JSON-RPC style handler with Zod validation |
48
48
  | `.raw(config)` | Full Request/Response control |
49
+ | `.stream(config)` | Validated input, Response output (binary/streaming) |
50
+ | `.sse(config)` | Server-Sent Events with channel subscription |
51
+ | `.formData(config)` | File uploads with validated fields |
52
+ | `.html(config)` | HTML responses for htmx/components |
49
53
  | `.<custom>(config)` | Custom handlers from plugins |
50
54
 
51
55
  ### MiddlewareBuilder Methods
@@ -119,22 +123,132 @@ router.route("greet").typed({
119
123
  Full control over Request/Response:
120
124
 
121
125
  ```ts
122
- router.route("download").raw({
126
+ router.route("proxy").raw({
123
127
  handle: async (req, ctx) => {
124
- const file = await Bun.file("data.csv").text();
125
- return new Response(file, {
126
- headers: { "Content-Type": "text/csv" },
128
+ return fetch("https://api.example.com", {
129
+ method: req.method,
130
+ headers: req.headers,
131
+ body: req.body,
127
132
  });
128
133
  },
129
134
  });
130
135
  ```
131
136
 
132
137
  **Use cases:**
133
- - File uploads/downloads
134
- - Streaming responses
135
- - SSE endpoints
136
- - Custom content types
138
+ - Proxying requests
137
139
  - WebSocket upgrades
140
+ - Custom protocols
141
+
142
+ ### Stream Handler
143
+
144
+ Validated input with binary/streaming Response:
145
+
146
+ ```ts
147
+ router.route("files.download").stream({
148
+ input: z.object({ fileId: z.string() }),
149
+ handle: async (input, ctx) => {
150
+ const file = await ctx.plugins.storage.get(input.fileId);
151
+ return new Response(file.stream, {
152
+ headers: { "Content-Type": file.mimeType },
153
+ });
154
+ },
155
+ });
156
+ ```
157
+
158
+ **Use cases:**
159
+ - File downloads with parameters
160
+ - Video/audio streaming
161
+ - Binary data with metadata
162
+
163
+ ### SSE Handler
164
+
165
+ Server-Sent Events with automatic channel subscription and typed events:
166
+
167
+ ```ts
168
+ router.route("events.subscribe").sse({
169
+ input: z.object({ userId: z.string() }),
170
+ // Optional: Define event schemas for type-safe generated clients
171
+ events: {
172
+ notification: z.object({ message: z.string(), id: z.string() }),
173
+ announcement: z.object({ title: z.string(), urgent: z.boolean() }),
174
+ },
175
+ handle: (input, ctx) => {
176
+ // Return channels to subscribe to
177
+ return [`user:${input.userId}`, "global"];
178
+ },
179
+ });
180
+ ```
181
+
182
+ **Server-side broadcasting:**
183
+ ```ts
184
+ ctx.core.sse.broadcast("user:123", "notification", { message: "Hello!", id: "1" });
185
+ ```
186
+
187
+ **Generated client usage:**
188
+ ```ts
189
+ const connection = api.events.subscribe({ userId: "123" });
190
+
191
+ // Typed event handlers - no JSON.parse needed
192
+ connection.on("notification", (data) => {
193
+ // data: { message: string; id: string }
194
+ console.log(data.message);
195
+ });
196
+
197
+ // Returns unsubscribe function
198
+ const unsub = connection.on("announcement", (data) => {
199
+ if (data.urgent) showAlert(data.title);
200
+ });
201
+ unsub(); // Unsubscribe from this handler
202
+
203
+ connection.close(); // Close the entire connection
204
+ ```
205
+
206
+ **Use cases:**
207
+ - Real-time notifications
208
+ - Live updates
209
+ - Event streaming
210
+
211
+ ### FormData Handler
212
+
213
+ File uploads with validated fields:
214
+
215
+ ```ts
216
+ router.route("files.upload").formData({
217
+ input: z.object({ folder: z.string() }),
218
+ output: z.object({ ids: z.array(z.string()) }),
219
+ files: { maxSize: 10 * 1024 * 1024, accept: ["image/*"] },
220
+ handle: async ({ fields, files }, ctx) => {
221
+ const ids = await Promise.all(
222
+ files.map(f => ctx.plugins.storage.save(f, fields.folder))
223
+ );
224
+ return { ids };
225
+ },
226
+ });
227
+ ```
228
+
229
+ **Use cases:**
230
+ - File uploads
231
+ - Form submissions with attachments
232
+ - Multi-file uploads
233
+
234
+ ### HTML Handler
235
+
236
+ HTML responses for htmx and server components:
237
+
238
+ ```ts
239
+ router.route("components.card").html({
240
+ input: z.object({ userId: z.string() }),
241
+ handle: async (input, ctx) => {
242
+ const user = await ctx.plugins.users.get(input.userId);
243
+ return `<div class="card">${user.name}</div>`;
244
+ },
245
+ });
246
+ ```
247
+
248
+ **Use cases:**
249
+ - htmx partials
250
+ - Server-rendered components
251
+ - Email templates
138
252
 
139
253
  ### Custom Handlers
140
254
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",
@@ -10,6 +10,10 @@
10
10
  "types": "./src/index.ts",
11
11
  "import": "./src/index.ts"
12
12
  },
13
+ "./core": {
14
+ "types": "./src/core/index.ts",
15
+ "import": "./src/core/index.ts"
16
+ },
13
17
  "./client": {
14
18
  "types": "./src/client/base.ts",
15
19
  "import": "./src/client/base.ts"
@@ -17,6 +17,8 @@ export interface RouteInfo {
17
17
  handler: "typed" | "raw" | string;
18
18
  inputSource?: string;
19
19
  outputSource?: string;
20
+ /** SSE event schemas (for sse handler) */
21
+ eventsSource?: Record<string, string>;
20
22
  }
21
23
 
22
24
  export interface EventInfo {
package/src/handlers.ts CHANGED
@@ -79,8 +79,331 @@ export const RawHandler: RawHandler = {
79
79
  __signature: undefined as unknown as RawFn
80
80
  }
81
81
 
82
+ // ==========================================
83
+ // 3. Stream Handler (Validated input, custom Response output)
84
+ // ==========================================
85
+ /**
86
+ * Stream handler function signature.
87
+ * Like typed, but returns a Response instead of JSON-serializable data.
88
+ * Use this for streaming, binary data, custom content-types, etc.
89
+ *
90
+ * Accepts both GET (query params) and POST (JSON body) for flexibility:
91
+ * - GET /files.download?fileId=123 (for browser links, video src, etc.)
92
+ * - POST /files.download {"fileId": "123"} (for programmatic requests)
93
+ */
94
+ export type StreamFn<I = any> = (input: I, ctx: ServerContext) => Promise<Response> | Response;
95
+ export type StreamHandler = HandlerRuntime<StreamFn>;
96
+
97
+ export const StreamHandler: StreamHandler = {
98
+ async execute(req, def, handle, ctx) {
99
+ // Stream routes accept GET (for browser, <video src>, etc.) and POST
100
+ if (req.method !== "GET" && req.method !== "POST") {
101
+ return new Response("Method Not Allowed", { status: 405 });
102
+ }
103
+
104
+ try {
105
+ // Parse input from query params (GET) or body (POST)
106
+ let body: any = {};
107
+ if (req.method === "POST") {
108
+ try {
109
+ body = await req.json();
110
+ } catch {
111
+ return Response.json({ error: "Invalid JSON" }, { status: 400 });
112
+ }
113
+ } else {
114
+ // Parse query params for GET requests
115
+ const url = new URL(req.url);
116
+ for (const [key, value] of url.searchParams) {
117
+ // Try to parse JSON values, otherwise use string
118
+ try {
119
+ body[key] = JSON.parse(value);
120
+ } catch {
121
+ body[key] = value;
122
+ }
123
+ }
124
+ }
125
+
126
+ // Validate input with Zod (like typed)
127
+ const input = def.input ? def.input.parse(body) : body;
128
+ // But return Response directly (like raw) - no output validation
129
+ return await handle(input, ctx);
130
+ } catch (e: any) {
131
+ console.error(e);
132
+ if (e instanceof z.ZodError) {
133
+ return Response.json({ error: "Validation Failed", details: e.issues }, { status: 400 });
134
+ }
135
+ return Response.json({ error: e.message || "Internal Error" }, { status: e.status || 500 });
136
+ }
137
+ },
138
+ __signature: undefined as unknown as StreamFn
139
+ }
140
+
141
+ // ==========================================
142
+ // 4. SSE Handler (Server-Sent Events)
143
+ // ==========================================
144
+ /**
145
+ * SSE handler function signature.
146
+ * Returns channels to subscribe to based on validated input.
147
+ * The handler sets up the SSE connection and subscribes to channels.
148
+ */
149
+ export type SSEFn<I = any> = (input: I, ctx: ServerContext) => string[] | Promise<string[]>;
150
+ export type SSEHandler = HandlerRuntime<SSEFn>;
151
+
152
+ export const SSEHandler: SSEHandler = {
153
+ async execute(req, def, handle, ctx) {
154
+ // SSE typically uses GET, but we also support POST for input
155
+ if (req.method !== "GET" && req.method !== "POST") {
156
+ return new Response("Method Not Allowed", { status: 405 });
157
+ }
158
+
159
+ try {
160
+ // Parse input from query params (GET) or body (POST)
161
+ let body: any = {};
162
+ if (req.method === "POST") {
163
+ try {
164
+ body = await req.json();
165
+ } catch {
166
+ return Response.json({ error: "Invalid JSON" }, { status: 400 });
167
+ }
168
+ } else {
169
+ // Parse query params for GET requests
170
+ const url = new URL(req.url);
171
+ for (const [key, value] of url.searchParams) {
172
+ // Try to parse JSON values, otherwise use string
173
+ try {
174
+ body[key] = JSON.parse(value);
175
+ } catch {
176
+ body[key] = value;
177
+ }
178
+ }
179
+ }
180
+
181
+ // Validate input
182
+ const input = def.input ? def.input.parse(body) : body;
183
+
184
+ // Get channels from handler
185
+ const channels = await handle(input, ctx);
186
+
187
+ // Create SSE client and response
188
+ const { client, response } = ctx.core.sse.addClient({
189
+ lastEventId: req.headers.get("Last-Event-ID") || undefined,
190
+ });
191
+
192
+ // Subscribe to channels
193
+ for (const channel of channels) {
194
+ ctx.core.sse.subscribe(client.id, channel);
195
+ }
196
+
197
+ return response;
198
+ } catch (e: any) {
199
+ console.error(e);
200
+ if (e instanceof z.ZodError) {
201
+ return Response.json({ error: "Validation Failed", details: e.issues }, { status: 400 });
202
+ }
203
+ return Response.json({ error: e.message || "Internal Error" }, { status: e.status || 500 });
204
+ }
205
+ },
206
+ __signature: undefined as unknown as SSEFn
207
+ };
208
+
209
+ // ==========================================
210
+ // 5. FormData Handler (File Uploads / Multipart)
211
+ // ==========================================
212
+ /**
213
+ * Parsed form data passed to handler.
214
+ */
215
+ export interface ParsedFormData<F = any> {
216
+ fields: F;
217
+ files: File[];
218
+ }
219
+
220
+ /**
221
+ * FormData handler function signature.
222
+ * Receives validated fields and files array.
223
+ */
224
+ export type FormDataFn<F = any, O = any> = (
225
+ data: ParsedFormData<F>,
226
+ ctx: ServerContext
227
+ ) => Promise<O> | O;
228
+ export type FormDataHandler = HandlerRuntime<FormDataFn>;
229
+
230
+ export const FormDataHandler: FormDataHandler = {
231
+ async execute(req, def, handle, ctx) {
232
+ if (req.method !== "POST") {
233
+ return new Response("Method Not Allowed", { status: 405 });
234
+ }
235
+
236
+ const contentType = req.headers.get("Content-Type") || "";
237
+ if (!contentType.includes("multipart/form-data")) {
238
+ return Response.json(
239
+ { error: "Content-Type must be multipart/form-data" },
240
+ { status: 400 }
241
+ );
242
+ }
243
+
244
+ try {
245
+ const formData = await req.formData();
246
+
247
+ // Separate fields and files
248
+ const fields: Record<string, any> = {};
249
+ const files: File[] = [];
250
+
251
+ for (const [key, value] of formData.entries()) {
252
+ // Check if value is a File (not a string)
253
+ if (typeof value !== "string") {
254
+ const file = value as File;
255
+ // Check file constraints if defined
256
+ if (def.fileConstraints) {
257
+ const { maxSize, accept } = def.fileConstraints;
258
+
259
+ if (maxSize && file.size > maxSize) {
260
+ return Response.json(
261
+ { error: `File "${file.name}" exceeds max size of ${maxSize} bytes` },
262
+ { status: 400 }
263
+ );
264
+ }
265
+
266
+ if (accept && accept.length > 0) {
267
+ const isAccepted = accept.some((pattern: string) => {
268
+ if (pattern.endsWith("/*")) {
269
+ const prefix = pattern.slice(0, -1);
270
+ return file.type.startsWith(prefix);
271
+ }
272
+ return file.type === pattern;
273
+ });
274
+
275
+ if (!isAccepted) {
276
+ return Response.json(
277
+ { error: `File "${file.name}" has invalid type "${file.type}"` },
278
+ { status: 400 }
279
+ );
280
+ }
281
+ }
282
+ }
283
+ files.push(file);
284
+ } else {
285
+ // Try to parse JSON values
286
+ try {
287
+ fields[key] = JSON.parse(value);
288
+ } catch {
289
+ fields[key] = value;
290
+ }
291
+ }
292
+ }
293
+
294
+ // Validate fields with Zod schema
295
+ const validatedFields = def.input ? def.input.parse(fields) : fields;
296
+
297
+ // Call handler
298
+ const result = await handle({ fields: validatedFields, files }, ctx);
299
+
300
+ // Validate and return output
301
+ const output = def.output ? def.output.parse(result) : result;
302
+ return Response.json(output);
303
+ } catch (e: any) {
304
+ console.error(e);
305
+ if (e instanceof z.ZodError) {
306
+ return Response.json({ error: "Validation Failed", details: e.issues }, { status: 400 });
307
+ }
308
+ return Response.json({ error: e.message || "Internal Error" }, { status: e.status || 500 });
309
+ }
310
+ },
311
+ __signature: undefined as unknown as FormDataFn
312
+ };
313
+
314
+ // ==========================================
315
+ // 6. HTML Handler (HTML Responses)
316
+ // ==========================================
317
+ /**
318
+ * HTML handler function signature.
319
+ * Returns HTML string or Response.
320
+ */
321
+ export type HTMLFn<I = any> = (input: I, ctx: ServerContext) => string | Response | Promise<string | Response>;
322
+ export type HTMLHandler = HandlerRuntime<HTMLFn>;
323
+
324
+ export const HTMLHandler: HTMLHandler = {
325
+ async execute(req, def, handle, ctx) {
326
+ // HTML routes typically use GET, but support POST for form submissions
327
+ if (req.method !== "GET" && req.method !== "POST") {
328
+ return new Response("Method Not Allowed", { status: 405 });
329
+ }
330
+
331
+ try {
332
+ // Parse input from query params (GET) or body (POST)
333
+ let body: any = {};
334
+ if (req.method === "POST") {
335
+ const contentType = req.headers.get("Content-Type") || "";
336
+ if (contentType.includes("application/json")) {
337
+ try {
338
+ body = await req.json();
339
+ } catch {
340
+ return Response.json({ error: "Invalid JSON" }, { status: 400 });
341
+ }
342
+ } else if (contentType.includes("application/x-www-form-urlencoded")) {
343
+ const formData = await req.formData();
344
+ for (const [key, value] of formData.entries()) {
345
+ if (typeof value === "string") {
346
+ try {
347
+ body[key] = JSON.parse(value);
348
+ } catch {
349
+ body[key] = value;
350
+ }
351
+ }
352
+ }
353
+ }
354
+ } else {
355
+ // Parse query params for GET requests
356
+ const url = new URL(req.url);
357
+ for (const [key, value] of url.searchParams) {
358
+ try {
359
+ body[key] = JSON.parse(value);
360
+ } catch {
361
+ body[key] = value;
362
+ }
363
+ }
364
+ }
365
+
366
+ // Validate input
367
+ const input = def.input ? def.input.parse(body) : body;
368
+
369
+ // Call handler
370
+ const result = await handle(input, ctx);
371
+
372
+ // If handler returns Response, return it directly
373
+ if (result instanceof Response) {
374
+ return result;
375
+ }
376
+
377
+ // Return HTML string with proper content-type
378
+ return new Response(result, {
379
+ headers: {
380
+ "Content-Type": "text/html; charset=utf-8",
381
+ },
382
+ });
383
+ } catch (e: any) {
384
+ console.error(e);
385
+ if (e instanceof z.ZodError) {
386
+ // Return HTML error for HTML handler
387
+ return new Response(
388
+ `<html><body><h1>Validation Error</h1><pre>${JSON.stringify(e.issues, null, 2)}</pre></body></html>`,
389
+ { status: 400, headers: { "Content-Type": "text/html; charset=utf-8" } }
390
+ );
391
+ }
392
+ return new Response(
393
+ `<html><body><h1>Error</h1><p>${e.message || "Internal Error"}</p></body></html>`,
394
+ { status: e.status || 500, headers: { "Content-Type": "text/html; charset=utf-8" } }
395
+ );
396
+ }
397
+ },
398
+ __signature: undefined as unknown as HTMLFn
399
+ };
400
+
82
401
  export const Handlers = {
83
402
  typed: TypedHandler,
84
- raw: RawHandler
403
+ raw: RawHandler,
404
+ stream: StreamHandler,
405
+ sse: SSEHandler,
406
+ formData: FormDataHandler,
407
+ html: HTMLHandler
85
408
  };
86
409
 
package/src/router.ts CHANGED
@@ -8,6 +8,15 @@ export type ServerContext = GlobalContext;
8
8
  /** Base interface for middleware builder - extended by generated types */
9
9
  export interface IMiddlewareBuilder<TRouter> {}
10
10
 
11
+ /** Parsed form data passed to formData handler */
12
+ export interface ParsedFormData<F = any> {
13
+ fields: F;
14
+ files: File[];
15
+ }
16
+
17
+ /** Schema definitions for SSE events */
18
+ export type SSEEventSchemas = Record<string, z.ZodType<any>>;
19
+
11
20
  export interface HandlerRegistry extends PluginHandlerRegistry {
12
21
  typed: {
13
22
  execute(req: Request, def: any, userHandle: Function, ctx: ServerContext): Promise<Response>;
@@ -17,17 +26,38 @@ export interface HandlerRegistry extends PluginHandlerRegistry {
17
26
  execute(req: Request, def: any, userHandle: Function, ctx: ServerContext): Promise<Response>;
18
27
  readonly __signature: (req: Request, ctx: ServerContext) => Promise<Response> | Response;
19
28
  };
29
+ stream: {
30
+ execute(req: Request, def: any, userHandle: Function, ctx: ServerContext): Promise<Response>;
31
+ readonly __signature: (input: any, ctx: ServerContext) => Promise<Response> | Response;
32
+ };
33
+ sse: {
34
+ execute(req: Request, def: any, userHandle: Function, ctx: ServerContext): Promise<Response>;
35
+ readonly __signature: (input: any, ctx: ServerContext) => string[] | Promise<string[]>;
36
+ };
37
+ formData: {
38
+ execute(req: Request, def: any, userHandle: Function, ctx: ServerContext): Promise<Response>;
39
+ readonly __signature: (data: ParsedFormData, ctx: ServerContext) => Promise<any> | any;
40
+ };
41
+ html: {
42
+ execute(req: Request, def: any, userHandle: Function, ctx: ServerContext): Promise<Response>;
43
+ readonly __signature: (input: any, ctx: ServerContext) => string | Response | Promise<string | Response>;
44
+ };
20
45
  }
21
46
 
22
47
  export type RouteDefinition<
23
48
  T extends keyof HandlerRegistry = "typed",
24
49
  I = any,
25
- O = any
50
+ O = any,
51
+ E extends SSEEventSchemas = SSEEventSchemas
26
52
  > = {
27
53
  name: string;
28
54
  handler: T;
29
55
  input?: z.ZodType<I>;
30
56
  output?: z.ZodType<O>;
57
+ /** SSE event schemas (for sse handler only) */
58
+ events?: E;
59
+ /** File constraints for formData handler */
60
+ fileConstraints?: FileConstraints;
31
61
  middleware?: MiddlewareDefinition[];
32
62
  handle: T extends "typed"
33
63
  ? (input: I, ctx: ServerContext) => Promise<O> | O
@@ -61,9 +91,113 @@ export interface RawRouteConfig {
61
91
  handle: (req: Request, ctx: ServerContext) => Promise<Response> | Response;
62
92
  }
63
93
 
94
+ export interface StreamRouteConfig<I = any> {
95
+ input?: z.ZodType<I>;
96
+ handle: (input: I, ctx: ServerContext) => Promise<Response> | Response;
97
+ }
98
+
99
+ /** File upload constraints for formData handler */
100
+ export interface FileConstraints {
101
+ /** Max file size in bytes */
102
+ maxSize?: number;
103
+ /** Accepted MIME types (e.g., "image/*", "application/pdf") */
104
+ accept?: string[];
105
+ }
106
+
107
+ export interface SSERouteConfig<I = any, E extends SSEEventSchemas = SSEEventSchemas> {
108
+ input?: z.ZodType<I>;
109
+ /** Event schemas for type-safe client generation */
110
+ events?: E;
111
+ /** Return channel names to subscribe to */
112
+ handle: (input: I, ctx: ServerContext) => string[] | Promise<string[]>;
113
+ }
114
+
115
+ export interface FormDataRouteConfig<F = any, O = any> {
116
+ /** Schema for form fields (non-file data) */
117
+ input?: z.ZodType<F>;
118
+ /** Schema for output validation */
119
+ output?: z.ZodType<O>;
120
+ /** File upload constraints */
121
+ files?: FileConstraints;
122
+ handle: (data: ParsedFormData<F>, ctx: ServerContext) => Promise<O> | O;
123
+ }
124
+
125
+ export interface HTMLRouteConfig<I = any> {
126
+ input?: z.ZodType<I>;
127
+ handle: (input: I, ctx: ServerContext) => string | Response | Promise<string | Response>;
128
+ }
129
+
64
130
  export interface IRouteBuilderBase<TRouter> {
65
131
  typed<I, O>(config: TypedRouteConfig<I, O>): TRouter;
66
132
  raw(config: RawRouteConfig): TRouter;
133
+ /**
134
+ * Stream handler - validated input, custom Response output.
135
+ * Use for streaming, binary data, files, custom content-types, etc.
136
+ *
137
+ * @example
138
+ * ```ts
139
+ * api.route("files.download").stream({
140
+ * input: z.object({ fileId: z.string() }),
141
+ * handle: async (input, ctx) => {
142
+ * const file = await getFile(input.fileId);
143
+ * return new Response(file.stream, {
144
+ * headers: { "Content-Type": file.mimeType }
145
+ * });
146
+ * }
147
+ * });
148
+ * ```
149
+ */
150
+ stream<I>(config: StreamRouteConfig<I>): TRouter;
151
+ /**
152
+ * SSE handler - Server-Sent Events with validated input and typed events.
153
+ * Returns channel names to subscribe the client to.
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * api.route("notifications.subscribe").sse({
158
+ * input: z.object({ userId: z.string() }),
159
+ * events: {
160
+ * notification: z.object({ message: z.string(), id: z.string() }),
161
+ * announcement: z.object({ title: z.string(), urgent: z.boolean() }),
162
+ * },
163
+ * handle: (input, ctx) => [`user:${input.userId}`, "global"]
164
+ * });
165
+ * ```
166
+ */
167
+ sse<I, E extends SSEEventSchemas>(config: SSERouteConfig<I, E>): TRouter;
168
+ /**
169
+ * FormData handler - file uploads with validated fields.
170
+ * Receives parsed form fields and files array.
171
+ *
172
+ * @example
173
+ * ```ts
174
+ * api.route("files.upload").formData({
175
+ * input: z.object({ folder: z.string() }),
176
+ * files: { maxSize: 10 * 1024 * 1024, accept: ["image/*"] },
177
+ * handle: async ({ fields, files }, ctx) => {
178
+ * const uploaded = await saveFiles(files, fields.folder);
179
+ * return { count: uploaded.length };
180
+ * }
181
+ * });
182
+ * ```
183
+ */
184
+ formData<F, O>(config: FormDataRouteConfig<F, O>): TRouter;
185
+ /**
186
+ * HTML handler - returns HTML responses.
187
+ * Perfect for htmx, partial renders, or server components.
188
+ *
189
+ * @example
190
+ * ```ts
191
+ * api.route("components.userCard").html({
192
+ * input: z.object({ userId: z.string() }),
193
+ * handle: async (input, ctx) => {
194
+ * const user = await ctx.plugins.users.get(input.userId);
195
+ * return `<div class="card">${user.name}</div>`;
196
+ * }
197
+ * });
198
+ * ```
199
+ */
200
+ html<I>(config: HTMLRouteConfig<I>): TRouter;
67
201
  }
68
202
 
69
203
  export interface IRouteBuilder<TRouter> extends IRouteBuilderBase<TRouter> {}
@@ -88,6 +222,27 @@ export class RouteBuilder<TRouter extends Router> implements IRouteBuilderBase<T
88
222
  return this.router.addRoute(this.name, "raw", config, this._middleware);
89
223
  }
90
224
 
225
+ stream<I>(config: StreamRouteConfig<I>): TRouter {
226
+ return this.router.addRoute(this.name, "stream", config, this._middleware);
227
+ }
228
+
229
+ sse<I, E extends SSEEventSchemas>(config: SSERouteConfig<I, E>): TRouter {
230
+ return this.router.addRoute(this.name, "sse", config, this._middleware);
231
+ }
232
+
233
+ formData<F, O>(config: FormDataRouteConfig<F, O>): TRouter {
234
+ // Map files constraints to fileConstraints for the handler
235
+ const routeConfig = {
236
+ ...config,
237
+ fileConstraints: config.files,
238
+ };
239
+ return this.router.addRoute(this.name, "formData", routeConfig, this._middleware);
240
+ }
241
+
242
+ html<I>(config: HTMLRouteConfig<I>): TRouter {
243
+ return this.router.addRoute(this.name, "html", config, this._middleware);
244
+ }
245
+
91
246
  addHandler(handler: string, config: any): TRouter {
92
247
  return this.router.addRoute(this.name, handler, config, this._middleware);
93
248
  }
@@ -174,15 +329,27 @@ export class Router implements IRouter {
174
329
  handler: string;
175
330
  inputType?: string;
176
331
  outputType?: string;
332
+ eventsType?: Record<string, string>;
177
333
  }> {
178
334
  // Dynamic import to avoid circular deps
179
335
  const { zodSchemaToTs } = require("./generator/zod-to-ts");
180
- return this.getRoutes().map(route => ({
181
- name: route.name,
182
- handler: route.handler,
183
- inputType: route.input ? zodSchemaToTs(route.input) : undefined,
184
- outputType: route.output ? zodSchemaToTs(route.output) : undefined,
185
- }));
336
+ return this.getRoutes().map(route => {
337
+ // Extract events schemas for SSE routes
338
+ let eventsType: Record<string, string> | undefined;
339
+ if (route.handler === "sse" && route.events) {
340
+ eventsType = {};
341
+ for (const [eventName, eventSchema] of Object.entries(route.events)) {
342
+ eventsType[eventName] = zodSchemaToTs(eventSchema);
343
+ }
344
+ }
345
+ return {
346
+ name: route.name,
347
+ handler: route.handler,
348
+ inputType: route.input ? zodSchemaToTs(route.input) : undefined,
349
+ outputType: route.output ? zodSchemaToTs(route.output) : undefined,
350
+ eventsType,
351
+ };
352
+ });
186
353
  }
187
354
 
188
355
  getPrefix(): string {
package/src/server.ts CHANGED
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
3
  import { dirname } from "node:path";
4
4
  import { PluginManager, type CoreServices, type ConfiguredPlugin } from "./core";
5
- import { type IRouter, type RouteDefinition, type ServerContext } from "./router";
5
+ import { type IRouter, type RouteDefinition, type ServerContext, type HandlerRegistry } from "./router";
6
6
  import { Handlers } from "./handlers";
7
7
  import type { MiddlewareRuntime, MiddlewareDefinition } from "./middleware";
8
8
  import {
@@ -85,7 +85,7 @@ export class AppServer {
85
85
  private port: number;
86
86
  private manager: PluginManager;
87
87
  private routers: IRouter[] = [];
88
- private routeMap: Map<string, RouteDefinition> = new Map();
88
+ private routeMap: Map<string, RouteDefinition<keyof HandlerRegistry>> = new Map();
89
89
  private coreServices: CoreServices;
90
90
  private typeGenConfig?: TypeGenerationConfig;
91
91
 
@@ -246,7 +246,7 @@ export class AppServer {
246
246
  /**
247
247
  * Get the internal route map for adapter introspection.
248
248
  */
249
- getRouteMap(): Map<string, RouteDefinition> {
249
+ getRouteMap(): Map<string, RouteDefinition<keyof HandlerRegistry>> {
250
250
  return this.routeMap;
251
251
  }
252
252
 
@@ -604,7 +604,7 @@ ${factoryFunction}
604
604
 
605
605
  // Final handler
606
606
  const finalHandler = async () => {
607
- const response = await handler.execute(req, route, route.handle, ctx);
607
+ const response = await handler.execute(req, route, route.handle as any, ctx);
608
608
  // Add CORS headers if provided
609
609
  if (Object.keys(corsHeaders).length > 0 && response instanceof Response) {
610
610
  const newHeaders = new Headers(response.headers);
@@ -820,7 +820,7 @@ ${factoryFunction}
820
820
 
821
821
  // Final handler execution
822
822
  const finalHandler = async () => {
823
- return await handler.execute(req, route, route.handle, ctx);
823
+ return await handler.execute(req, route, route.handle as any, ctx);
824
824
  };
825
825
 
826
826
  // Execute middleware chain, then handler - with HttpError handling