@donkeylabs/server 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/sse.md ADDED
@@ -0,0 +1,542 @@
1
+ # SSE Service
2
+
3
+ Server-Sent Events for real-time server-to-client push notifications. Supports channels, broadcasting, and automatic heartbeats.
4
+
5
+ ## Quick Start
6
+
7
+ ```ts
8
+ // Create SSE endpoint
9
+ router.route("events").raw({
10
+ handle: async (req, ctx) => {
11
+ const { client, response } = ctx.core.sse.addClient();
12
+ ctx.core.sse.subscribe(client.id, `user:${ctx.user.id}`);
13
+ return response;
14
+ },
15
+ });
16
+
17
+ // Broadcast updates from anywhere
18
+ ctx.core.sse.broadcast(`user:${userId}`, "notification", {
19
+ message: "You have a new message",
20
+ });
21
+ ```
22
+
23
+ ---
24
+
25
+ ## API Reference
26
+
27
+ ### Interface
28
+
29
+ ```ts
30
+ interface SSE {
31
+ addClient(options?: { lastEventId?: string }): { client: SSEClient; response: Response };
32
+ removeClient(clientId: string): void;
33
+ getClient(clientId: string): SSEClient | undefined;
34
+ subscribe(clientId: string, channel: string): boolean;
35
+ unsubscribe(clientId: string, channel: string): boolean;
36
+ broadcast(channel: string, event: string, data: any, id?: string): void;
37
+ broadcastAll(event: string, data: any, id?: string): void;
38
+ sendTo(clientId: string, event: string, data: any, id?: string): boolean;
39
+ getClients(): SSEClient[];
40
+ getClientsByChannel(channel: string): SSEClient[];
41
+ shutdown(): void;
42
+ }
43
+
44
+ interface SSEClient {
45
+ id: string;
46
+ channels: Set<string>;
47
+ controller: ReadableStreamDefaultController<Uint8Array>;
48
+ createdAt: Date;
49
+ lastEventId?: string;
50
+ }
51
+ ```
52
+
53
+ ### Methods
54
+
55
+ | Method | Description |
56
+ |--------|-------------|
57
+ | `addClient(opts?)` | Create new SSE client, returns client and response |
58
+ | `removeClient(id)` | Disconnect and remove client |
59
+ | `getClient(id)` | Get client by ID |
60
+ | `subscribe(clientId, channel)` | Subscribe client to channel |
61
+ | `unsubscribe(clientId, channel)` | Unsubscribe from channel |
62
+ | `broadcast(channel, event, data, id?)` | Send to all channel subscribers |
63
+ | `broadcastAll(event, data, id?)` | Send to all connected clients |
64
+ | `sendTo(clientId, event, data, id?)` | Send to specific client |
65
+ | `getClients()` | Get all connected clients |
66
+ | `getClientsByChannel(channel)` | Get clients subscribed to channel |
67
+ | `shutdown()` | Close all connections |
68
+
69
+ ---
70
+
71
+ ## Configuration
72
+
73
+ ```ts
74
+ const server = new AppServer({
75
+ db,
76
+ sse: {
77
+ heartbeatInterval: 30000, // Keep-alive interval (default: 30s)
78
+ retryInterval: 3000, // Client reconnect hint (default: 3s)
79
+ },
80
+ });
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Usage Examples
86
+
87
+ ### Basic SSE Endpoint
88
+
89
+ ```ts
90
+ // Subscribe to user-specific updates
91
+ router.route("subscribe").raw({
92
+ handle: async (req, ctx) => {
93
+ // Create SSE client
94
+ const { client, response } = ctx.core.sse.addClient();
95
+
96
+ // Subscribe to user's channel
97
+ ctx.core.sse.subscribe(client.id, `user:${ctx.user.id}`);
98
+
99
+ // Log connection
100
+ ctx.core.logger.info("SSE client connected", {
101
+ clientId: client.id,
102
+ userId: ctx.user.id,
103
+ });
104
+
105
+ return response;
106
+ },
107
+ });
108
+ ```
109
+
110
+ ### Client-Side JavaScript
111
+
112
+ ```js
113
+ // Connect to SSE endpoint
114
+ const eventSource = new EventSource("/subscribe", {
115
+ withCredentials: true, // For cookies/auth
116
+ });
117
+
118
+ // Listen for specific events
119
+ eventSource.addEventListener("notification", (event) => {
120
+ const data = JSON.parse(event.data);
121
+ console.log("Notification:", data);
122
+ showNotification(data.message);
123
+ });
124
+
125
+ eventSource.addEventListener("message", (event) => {
126
+ console.log("Message:", JSON.parse(event.data));
127
+ });
128
+
129
+ // Handle connection errors
130
+ eventSource.onerror = (error) => {
131
+ console.error("SSE error:", error);
132
+ // EventSource auto-reconnects
133
+ };
134
+
135
+ // Close connection
136
+ eventSource.close();
137
+ ```
138
+
139
+ ### Broadcasting Events
140
+
141
+ ```ts
142
+ // From route handler
143
+ router.route("sendMessage").typed({
144
+ handle: async (input, ctx) => {
145
+ const message = await ctx.db.insertInto("messages").values({
146
+ fromUserId: ctx.user.id,
147
+ toUserId: input.recipientId,
148
+ content: input.content,
149
+ }).returningAll().executeTakeFirstOrThrow();
150
+
151
+ // Push to recipient in real-time
152
+ ctx.core.sse.broadcast(`user:${input.recipientId}`, "newMessage", {
153
+ id: message.id,
154
+ from: ctx.user.name,
155
+ content: input.content,
156
+ });
157
+
158
+ return message;
159
+ },
160
+ });
161
+
162
+ // From job handler
163
+ ctx.core.jobs.register("broadcastAnnouncement", async (data) => {
164
+ // Send to all connected clients
165
+ ctx.core.sse.broadcastAll("announcement", {
166
+ title: data.title,
167
+ message: data.message,
168
+ priority: data.priority,
169
+ });
170
+ });
171
+ ```
172
+
173
+ ### Channel-Based Subscriptions
174
+
175
+ ```ts
176
+ // Multiple channel subscriptions
177
+ router.route("subscribe").raw({
178
+ handle: async (req, ctx) => {
179
+ const url = new URL(req.url);
180
+ const channels = url.searchParams.getAll("channel");
181
+
182
+ const { client, response } = ctx.core.sse.addClient();
183
+
184
+ // Subscribe to requested channels
185
+ for (const channel of channels) {
186
+ // Validate channel access
187
+ if (await canAccessChannel(ctx.user, channel)) {
188
+ ctx.core.sse.subscribe(client.id, channel);
189
+ }
190
+ }
191
+
192
+ // Always subscribe to user channel
193
+ ctx.core.sse.subscribe(client.id, `user:${ctx.user.id}`);
194
+
195
+ return response;
196
+ },
197
+ });
198
+
199
+ // Client: /subscribe?channel=orders&channel=notifications
200
+ ```
201
+
202
+ ---
203
+
204
+ ## Real-World Examples
205
+
206
+ ### Live Notifications
207
+
208
+ ```ts
209
+ // plugins/notifications/index.ts
210
+ service: async (ctx) => {
211
+ return {
212
+ async create(userId: number, notification: NotificationData) {
213
+ // Save to database
214
+ const saved = await ctx.db.insertInto("notifications").values({
215
+ userId,
216
+ ...notification,
217
+ read: false,
218
+ createdAt: new Date().toISOString(),
219
+ }).returningAll().executeTakeFirstOrThrow();
220
+
221
+ // Push to user in real-time
222
+ ctx.core.sse.broadcast(`user:${userId}`, "notification", {
223
+ id: saved.id,
224
+ title: notification.title,
225
+ body: notification.body,
226
+ type: notification.type,
227
+ createdAt: saved.createdAt,
228
+ });
229
+
230
+ return saved;
231
+ },
232
+
233
+ async markAsRead(userId: number, notificationId: number) {
234
+ await ctx.db.updateTable("notifications")
235
+ .set({ read: true })
236
+ .where("id", "=", notificationId)
237
+ .where("userId", "=", userId)
238
+ .execute();
239
+
240
+ // Update badge count in real-time
241
+ const unreadCount = await ctx.db
242
+ .selectFrom("notifications")
243
+ .where("userId", "=", userId)
244
+ .where("read", "=", false)
245
+ .count()
246
+ .executeTakeFirst();
247
+
248
+ ctx.core.sse.broadcast(`user:${userId}`, "unreadCount", {
249
+ count: Number(unreadCount?.count ?? 0),
250
+ });
251
+ },
252
+ };
253
+ };
254
+ ```
255
+
256
+ ### Live Dashboard Updates
257
+
258
+ ```ts
259
+ // Subscribe to dashboard updates
260
+ router.route("dashboard/live").raw({
261
+ handle: async (req, ctx) => {
262
+ const { client, response } = ctx.core.sse.addClient();
263
+
264
+ ctx.core.sse.subscribe(client.id, "dashboard:stats");
265
+ ctx.core.sse.subscribe(client.id, "dashboard:orders");
266
+ ctx.core.sse.subscribe(client.id, "dashboard:alerts");
267
+
268
+ // Send initial data
269
+ const stats = await getDashboardStats();
270
+ ctx.core.sse.sendTo(client.id, "initialData", stats);
271
+
272
+ return response;
273
+ },
274
+ });
275
+
276
+ // Update all dashboard viewers when data changes
277
+ ctx.core.events.on("order.created", async (order) => {
278
+ ctx.core.sse.broadcast("dashboard:orders", "newOrder", {
279
+ id: order.id,
280
+ total: order.total,
281
+ customer: order.customerName,
282
+ });
283
+ });
284
+
285
+ ctx.core.cron.schedule("*/30 * * * * *", async () => {
286
+ const stats = await getDashboardStats();
287
+ ctx.core.sse.broadcast("dashboard:stats", "statsUpdate", stats);
288
+ });
289
+ ```
290
+
291
+ ### Collaborative Features
292
+
293
+ ```ts
294
+ // Document collaboration
295
+ router.route("document/:id/live").raw({
296
+ handle: async (req, ctx) => {
297
+ const docId = ctx.params.id;
298
+
299
+ // Verify access
300
+ const canAccess = await checkDocumentAccess(ctx.user.id, docId);
301
+ if (!canAccess) {
302
+ return new Response("Forbidden", { status: 403 });
303
+ }
304
+
305
+ const { client, response } = ctx.core.sse.addClient();
306
+ ctx.core.sse.subscribe(client.id, `document:${docId}`);
307
+
308
+ // Notify others of new collaborator
309
+ ctx.core.sse.broadcast(`document:${docId}`, "userJoined", {
310
+ userId: ctx.user.id,
311
+ name: ctx.user.name,
312
+ });
313
+
314
+ return response;
315
+ },
316
+ });
317
+
318
+ // Broadcast document changes
319
+ router.route("document/:id/edit").typed({
320
+ handle: async (input, ctx) => {
321
+ const docId = ctx.params.id;
322
+
323
+ // Save changes
324
+ await saveDocumentChanges(docId, input.changes);
325
+
326
+ // Broadcast to all viewers
327
+ ctx.core.sse.broadcast(`document:${docId}`, "documentChanged", {
328
+ changes: input.changes,
329
+ userId: ctx.user.id,
330
+ timestamp: Date.now(),
331
+ });
332
+
333
+ return { success: true };
334
+ },
335
+ });
336
+ ```
337
+
338
+ ### Progress Updates
339
+
340
+ ```ts
341
+ // Long-running task with progress
342
+ router.route("process").typed({
343
+ handle: async (input, ctx) => {
344
+ const taskId = crypto.randomUUID();
345
+
346
+ // Start background processing
347
+ ctx.core.jobs.enqueue("longProcess", {
348
+ taskId,
349
+ data: input.data,
350
+ userId: ctx.user.id,
351
+ });
352
+
353
+ return { taskId };
354
+ },
355
+ });
356
+
357
+ // In job handler
358
+ ctx.core.jobs.register("longProcess", async (data) => {
359
+ const { taskId, data: items, userId } = data;
360
+ const total = items.length;
361
+
362
+ for (let i = 0; i < total; i++) {
363
+ await processItem(items[i]);
364
+
365
+ // Send progress update
366
+ ctx.core.sse.broadcast(`user:${userId}`, "taskProgress", {
367
+ taskId,
368
+ current: i + 1,
369
+ total,
370
+ percent: Math.round(((i + 1) / total) * 100),
371
+ });
372
+ }
373
+
374
+ // Send completion
375
+ ctx.core.sse.broadcast(`user:${userId}`, "taskComplete", {
376
+ taskId,
377
+ result: "success",
378
+ });
379
+ });
380
+ ```
381
+
382
+ ---
383
+
384
+ ## SSE Message Format
385
+
386
+ Messages sent to clients follow the SSE format:
387
+
388
+ ```
389
+ id: optional-event-id
390
+ event: eventName
391
+ data: {"json":"payload"}
392
+
393
+ ```
394
+
395
+ Example:
396
+
397
+ ```ts
398
+ ctx.core.sse.sendTo(clientId, "notification", { message: "Hello" }, "msg-123");
399
+ ```
400
+
401
+ Client receives:
402
+
403
+ ```
404
+ id: msg-123
405
+ event: notification
406
+ data: {"message":"Hello"}
407
+
408
+ ```
409
+
410
+ ---
411
+
412
+ ## Connection Management
413
+
414
+ ### Tracking Connections
415
+
416
+ ```ts
417
+ // Get connection statistics
418
+ router.route("admin/connections").typed({
419
+ handle: async (input, ctx) => {
420
+ const clients = ctx.core.sse.getClients();
421
+
422
+ const byChannel: Record<string, number> = {};
423
+ for (const client of clients) {
424
+ for (const channel of client.channels) {
425
+ byChannel[channel] = (byChannel[channel] || 0) + 1;
426
+ }
427
+ }
428
+
429
+ return {
430
+ totalConnections: clients.length,
431
+ byChannel,
432
+ oldestConnection: clients.reduce(
433
+ (oldest, c) => (c.createdAt < oldest ? c.createdAt : oldest),
434
+ new Date()
435
+ ),
436
+ };
437
+ },
438
+ });
439
+ ```
440
+
441
+ ### Graceful Disconnect
442
+
443
+ ```ts
444
+ // Client disconnection is handled automatically when:
445
+ // 1. Client closes connection
446
+ // 2. Network error occurs
447
+ // 3. Server calls removeClient()
448
+
449
+ // Force disconnect a client
450
+ ctx.core.sse.removeClient(clientId);
451
+
452
+ // Shutdown all connections (called by server.shutdown())
453
+ ctx.core.sse.shutdown();
454
+ ```
455
+
456
+ ---
457
+
458
+ ## Best Practices
459
+
460
+ ### 1. Use Specific Channels
461
+
462
+ ```ts
463
+ // Good - targeted channels
464
+ ctx.core.sse.subscribe(clientId, `user:${userId}`);
465
+ ctx.core.sse.subscribe(clientId, `order:${orderId}`);
466
+ ctx.core.sse.subscribe(clientId, `team:${teamId}`);
467
+
468
+ // Bad - one channel for everything
469
+ ctx.core.sse.subscribe(clientId, "all");
470
+ ```
471
+
472
+ ### 2. Include Event IDs for Resume
473
+
474
+ ```ts
475
+ // Include ID for client resume capability
476
+ ctx.core.sse.broadcast(channel, event, data, `evt-${Date.now()}`);
477
+
478
+ // Client can resume from last event
479
+ const { client, response } = ctx.core.sse.addClient({
480
+ lastEventId: req.headers.get("Last-Event-ID") ?? undefined,
481
+ });
482
+ ```
483
+
484
+ ### 3. Send Initial State
485
+
486
+ ```ts
487
+ router.route("subscribe").raw({
488
+ handle: async (req, ctx) => {
489
+ const { client, response } = ctx.core.sse.addClient();
490
+
491
+ ctx.core.sse.subscribe(client.id, `user:${ctx.user.id}`);
492
+
493
+ // Send initial state immediately
494
+ const unreadCount = await getUnreadCount(ctx.user.id);
495
+ ctx.core.sse.sendTo(client.id, "initialState", {
496
+ unreadNotifications: unreadCount,
497
+ online: true,
498
+ });
499
+
500
+ return response;
501
+ },
502
+ });
503
+ ```
504
+
505
+ ### 4. Handle Client Limits
506
+
507
+ ```ts
508
+ router.route("subscribe").raw({
509
+ handle: async (req, ctx) => {
510
+ // Limit connections per user
511
+ const existingClients = ctx.core.sse.getClientsByChannel(`user:${ctx.user.id}`);
512
+
513
+ if (existingClients.length >= 5) {
514
+ // Remove oldest connection
515
+ ctx.core.sse.removeClient(existingClients[0].id);
516
+ }
517
+
518
+ const { client, response } = ctx.core.sse.addClient();
519
+ ctx.core.sse.subscribe(client.id, `user:${ctx.user.id}`);
520
+
521
+ return response;
522
+ },
523
+ });
524
+ ```
525
+
526
+ ### 5. Keep Payloads Small
527
+
528
+ ```ts
529
+ // Good - minimal data, client fetches details if needed
530
+ ctx.core.sse.broadcast(channel, "orderUpdated", {
531
+ orderId: order.id,
532
+ status: order.status,
533
+ });
534
+
535
+ // Bad - full object over SSE
536
+ ctx.core.sse.broadcast(channel, "orderUpdated", {
537
+ ...order,
538
+ items: order.items,
539
+ customer: order.customer,
540
+ // Large nested data
541
+ });
542
+ ```