@donkeylabs/server 1.0.1 → 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.
- package/docs/api-client.md +188 -4
- package/docs/handlers.md +341 -14
- package/docs/router.md +123 -9
- package/package.json +1 -1
- package/src/generator/index.ts +2 -0
- package/src/handlers.ts +324 -1
- package/src/router.ts +174 -7
- package/src/server.ts +2 -2
package/docs/api-client.md
CHANGED
|
@@ -141,17 +141,201 @@ const order = await api.orders.create({ items: [...] }, {
|
|
|
141
141
|
|
|
142
142
|
### Raw Routes
|
|
143
143
|
|
|
144
|
-
For
|
|
144
|
+
For full Request/Response control:
|
|
145
145
|
|
|
146
146
|
```ts
|
|
147
147
|
// Server-side
|
|
148
|
-
router.route("
|
|
149
|
-
handle: async (req, ctx) =>
|
|
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.
|
|
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("
|
|
71
|
+
router.route("proxy").raw({
|
|
63
72
|
handle: async (req, ctx) => {
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
84
|
+
**Use cases:**
|
|
85
|
+
- Proxying requests
|
|
86
|
+
- WebSocket upgrades
|
|
87
|
+
- Custom protocols
|
|
88
|
+
- Non-standard HTTP methods
|
|
70
89
|
|
|
71
|
-
|
|
90
|
+
---
|
|
72
91
|
|
|
73
|
-
|
|
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
|
|
80
|
-
-
|
|
81
|
-
-
|
|
82
|
-
- Custom content
|
|
83
|
-
-
|
|
84
|
-
-
|
|
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
|
|
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("
|
|
126
|
+
router.route("proxy").raw({
|
|
123
127
|
handle: async (req, ctx) => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
headers:
|
|
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
|
-
-
|
|
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
package/src/generator/index.ts
CHANGED
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
@@ -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
|