@donkeylabs/server 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/donkeylabs +6 -0
- package/context.d.ts +17 -0
- package/docs/api-client.md +520 -0
- package/docs/cache.md +437 -0
- package/docs/cli.md +353 -0
- package/docs/core-services.md +338 -0
- package/docs/cron.md +465 -0
- package/docs/errors.md +303 -0
- package/docs/events.md +460 -0
- package/docs/handlers.md +549 -0
- package/docs/jobs.md +556 -0
- package/docs/logger.md +316 -0
- package/docs/middleware.md +682 -0
- package/docs/plugins.md +524 -0
- package/docs/project-structure.md +493 -0
- package/docs/rate-limiter.md +525 -0
- package/docs/router.md +566 -0
- package/docs/sse.md +542 -0
- package/docs/svelte-frontend.md +324 -0
- package/package.json +12 -9
- package/registry.d.ts +11 -0
- package/src/index.ts +1 -1
- package/src/server.ts +1 -0
package/docs/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
|
+
```
|