@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/events.md
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# Events Service
|
|
2
|
+
|
|
3
|
+
Asynchronous pub/sub event system with pattern matching, history tracking, and support for both sync and async handlers.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
// Subscribe to events
|
|
9
|
+
ctx.core.events.on("user.created", async (user) => {
|
|
10
|
+
console.log("New user:", user.email);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Emit events
|
|
14
|
+
await ctx.core.events.emit("user.created", { id: 1, email: "alice@example.com" });
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## API Reference
|
|
20
|
+
|
|
21
|
+
### Interface
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
interface Events {
|
|
25
|
+
emit<T = any>(event: string, data: T): Promise<void>;
|
|
26
|
+
on<T = any>(event: string, handler: EventHandler<T>): Subscription;
|
|
27
|
+
once<T = any>(event: string, handler: EventHandler<T>): Subscription;
|
|
28
|
+
off(event: string, handler?: EventHandler): void;
|
|
29
|
+
getHistory(event: string, limit?: number): Promise<EventRecord[]>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Subscription {
|
|
33
|
+
unsubscribe(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface EventRecord {
|
|
37
|
+
event: string;
|
|
38
|
+
data: any;
|
|
39
|
+
timestamp: Date;
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Methods
|
|
44
|
+
|
|
45
|
+
| Method | Description |
|
|
46
|
+
|--------|-------------|
|
|
47
|
+
| `emit(event, data)` | Emit event to all subscribers |
|
|
48
|
+
| `on(event, handler)` | Subscribe to event, returns subscription |
|
|
49
|
+
| `once(event, handler)` | Subscribe to single occurrence |
|
|
50
|
+
| `off(event, handler?)` | Unsubscribe handler or all handlers |
|
|
51
|
+
| `getHistory(event, limit?)` | Get past events for replay |
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const server = new AppServer({
|
|
59
|
+
db,
|
|
60
|
+
events: {
|
|
61
|
+
maxHistorySize: 1000, // Events to keep in history (default: 1000)
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Usage Examples
|
|
69
|
+
|
|
70
|
+
### Basic Pub/Sub
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
// Subscribe
|
|
74
|
+
const subscription = ctx.core.events.on("order.created", (order) => {
|
|
75
|
+
console.log("Order received:", order.id);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Emit
|
|
79
|
+
await ctx.core.events.emit("order.created", {
|
|
80
|
+
id: "order-123",
|
|
81
|
+
total: 99.99,
|
|
82
|
+
items: ["item-1", "item-2"],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Unsubscribe
|
|
86
|
+
subscription.unsubscribe();
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Once Handler
|
|
90
|
+
|
|
91
|
+
Subscribe to a single event occurrence:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// Only fires once
|
|
95
|
+
ctx.core.events.once("app.ready", () => {
|
|
96
|
+
console.log("Application started!");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await ctx.core.events.emit("app.ready", {});
|
|
100
|
+
await ctx.core.events.emit("app.ready", {}); // Handler not called
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Pattern Matching
|
|
104
|
+
|
|
105
|
+
Subscribe to events matching a pattern using wildcards:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// Match all user events
|
|
109
|
+
ctx.core.events.on("user.*", (data) => {
|
|
110
|
+
console.log("User event:", data);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// These all match
|
|
114
|
+
await ctx.core.events.emit("user.created", { id: 1 });
|
|
115
|
+
await ctx.core.events.emit("user.updated", { id: 1 });
|
|
116
|
+
await ctx.core.events.emit("user.deleted", { id: 1 });
|
|
117
|
+
|
|
118
|
+
// Match all events in a namespace
|
|
119
|
+
ctx.core.events.on("analytics.*", (data) => {
|
|
120
|
+
// Handle all analytics events
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Multi-level patterns
|
|
124
|
+
ctx.core.events.on("shop.order.*", (data) => {
|
|
125
|
+
// Matches shop.order.created, shop.order.paid, etc.
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Async Handlers
|
|
130
|
+
|
|
131
|
+
Handlers can be async - emit() waits for all handlers:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
ctx.core.events.on("order.paid", async (order) => {
|
|
135
|
+
// These run in parallel
|
|
136
|
+
await sendConfirmationEmail(order);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
ctx.core.events.on("order.paid", async (order) => {
|
|
140
|
+
await updateInventory(order);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
ctx.core.events.on("order.paid", async (order) => {
|
|
144
|
+
await notifyWarehouse(order);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// emit() waits for all handlers to complete
|
|
148
|
+
await ctx.core.events.emit("order.paid", order);
|
|
149
|
+
console.log("All handlers finished");
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Event History
|
|
153
|
+
|
|
154
|
+
Retrieve past events for debugging or replay:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
// Get last 10 events of a type
|
|
158
|
+
const history = await ctx.core.events.getHistory("user.login", 10);
|
|
159
|
+
|
|
160
|
+
for (const record of history) {
|
|
161
|
+
console.log(`${record.timestamp}: ${record.event}`, record.data);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Get all events (use "*" pattern)
|
|
165
|
+
const allEvents = await ctx.core.events.getHistory("*", 100);
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Real-World Examples
|
|
171
|
+
|
|
172
|
+
### Event-Driven Architecture
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// plugins/orders/index.ts
|
|
176
|
+
service: async (ctx) => ({
|
|
177
|
+
async createOrder(data: OrderData) {
|
|
178
|
+
const order = await ctx.db
|
|
179
|
+
.insertInto("orders")
|
|
180
|
+
.values(data)
|
|
181
|
+
.returningAll()
|
|
182
|
+
.executeTakeFirstOrThrow();
|
|
183
|
+
|
|
184
|
+
// Emit event - let other systems react
|
|
185
|
+
await ctx.core.events.emit("order.created", order);
|
|
186
|
+
|
|
187
|
+
return order;
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async markAsPaid(orderId: string) {
|
|
191
|
+
const order = await ctx.db
|
|
192
|
+
.updateTable("orders")
|
|
193
|
+
.set({ status: "paid", paidAt: new Date().toISOString() })
|
|
194
|
+
.where("id", "=", orderId)
|
|
195
|
+
.returningAll()
|
|
196
|
+
.executeTakeFirstOrThrow();
|
|
197
|
+
|
|
198
|
+
await ctx.core.events.emit("order.paid", order);
|
|
199
|
+
|
|
200
|
+
return order;
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// plugins/inventory/index.ts
|
|
205
|
+
service: async (ctx) => {
|
|
206
|
+
// React to order events
|
|
207
|
+
ctx.core.events.on("order.paid", async (order) => {
|
|
208
|
+
for (const item of order.items) {
|
|
209
|
+
await ctx.db
|
|
210
|
+
.updateTable("inventory")
|
|
211
|
+
.set((eb) => ({ quantity: eb("quantity", "-", item.quantity) }))
|
|
212
|
+
.where("productId", "=", item.productId)
|
|
213
|
+
.execute();
|
|
214
|
+
}
|
|
215
|
+
ctx.core.logger.info("Inventory updated", { orderId: order.id });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return { /* inventory methods */ };
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// plugins/notifications/index.ts
|
|
222
|
+
service: async (ctx) => {
|
|
223
|
+
ctx.core.events.on("order.created", async (order) => {
|
|
224
|
+
await sendEmail(order.customerEmail, "Order Confirmation", { order });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
ctx.core.events.on("order.paid", async (order) => {
|
|
228
|
+
await sendEmail(order.customerEmail, "Payment Received", { order });
|
|
229
|
+
await notifySlack(`New paid order: ${order.id} - $${order.total}`);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return { /* notification methods */ };
|
|
233
|
+
};
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Audit Logging
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
// Log all events for audit trail
|
|
240
|
+
ctx.core.events.on("*", async (data) => {
|
|
241
|
+
// This catches ALL events
|
|
242
|
+
await ctx.db.insertInto("audit_log").values({
|
|
243
|
+
event: data._eventName, // Added automatically
|
|
244
|
+
data: JSON.stringify(data),
|
|
245
|
+
timestamp: new Date().toISOString(),
|
|
246
|
+
}).execute();
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Real-Time Updates with SSE
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
// Bridge events to SSE for real-time UI updates
|
|
254
|
+
ctx.core.events.on("notification.*", async (data) => {
|
|
255
|
+
const { userId } = data;
|
|
256
|
+
|
|
257
|
+
// Broadcast to user's SSE channel
|
|
258
|
+
ctx.core.sse.broadcast(`user:${userId}`, "notification", data);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// When notification is created
|
|
262
|
+
await ctx.core.events.emit("notification.created", {
|
|
263
|
+
userId: 123,
|
|
264
|
+
message: "You have a new message",
|
|
265
|
+
});
|
|
266
|
+
// -> Automatically pushed to user's browser via SSE
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Deferred Processing with Jobs
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
// Convert events to background jobs for heavy processing
|
|
273
|
+
ctx.core.events.on("video.uploaded", async (video) => {
|
|
274
|
+
// Queue for background processing instead of blocking
|
|
275
|
+
await ctx.core.jobs.enqueue("processVideo", {
|
|
276
|
+
videoId: video.id,
|
|
277
|
+
operations: ["transcode", "thumbnail", "analyze"],
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## Event Naming Conventions
|
|
285
|
+
|
|
286
|
+
Use consistent, hierarchical event names:
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
// Resource lifecycle events
|
|
290
|
+
"user.created"
|
|
291
|
+
"user.updated"
|
|
292
|
+
"user.deleted"
|
|
293
|
+
|
|
294
|
+
// State transitions
|
|
295
|
+
"order.pending"
|
|
296
|
+
"order.paid"
|
|
297
|
+
"order.shipped"
|
|
298
|
+
"order.delivered"
|
|
299
|
+
|
|
300
|
+
// Actions
|
|
301
|
+
"email.sent"
|
|
302
|
+
"payment.processed"
|
|
303
|
+
"file.uploaded"
|
|
304
|
+
|
|
305
|
+
// Namespaced events
|
|
306
|
+
"auth.login.success"
|
|
307
|
+
"auth.login.failed"
|
|
308
|
+
"auth.logout"
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## Error Handling
|
|
314
|
+
|
|
315
|
+
Handler errors are caught and logged, but don't prevent other handlers:
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
ctx.core.events.on("test", async () => {
|
|
319
|
+
throw new Error("Handler 1 failed");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
ctx.core.events.on("test", async () => {
|
|
323
|
+
console.log("Handler 2 still runs");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Both handlers are called, error is logged
|
|
327
|
+
await ctx.core.events.emit("test", {});
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
To handle errors explicitly:
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
ctx.core.events.on("critical.event", async (data) => {
|
|
334
|
+
try {
|
|
335
|
+
await riskyOperation(data);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
ctx.core.logger.error("Handler failed", { error: error.message, data });
|
|
338
|
+
// Optionally emit error event
|
|
339
|
+
await ctx.core.events.emit("critical.event.failed", { error, data });
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## Custom Adapters
|
|
347
|
+
|
|
348
|
+
Implement `EventAdapter` for persistence or distribution:
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
interface EventAdapter {
|
|
352
|
+
publish(event: string, data: any): Promise<void>;
|
|
353
|
+
getHistory(event: string, limit?: number): Promise<EventRecord[]>;
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### Redis Pub/Sub Adapter
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
import { createEvents, type EventAdapter } from "./core/events";
|
|
361
|
+
import Redis from "ioredis";
|
|
362
|
+
|
|
363
|
+
class RedisEventAdapter implements EventAdapter {
|
|
364
|
+
private history: EventRecord[] = [];
|
|
365
|
+
|
|
366
|
+
constructor(
|
|
367
|
+
private publisher: Redis,
|
|
368
|
+
private subscriber: Redis,
|
|
369
|
+
private onMessage: (event: string, data: any) => void
|
|
370
|
+
) {
|
|
371
|
+
subscriber.on("pmessage", (pattern, channel, message) => {
|
|
372
|
+
const data = JSON.parse(message);
|
|
373
|
+
this.onMessage(channel, data);
|
|
374
|
+
});
|
|
375
|
+
subscriber.psubscribe("*");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async publish(event: string, data: any): Promise<void> {
|
|
379
|
+
this.history.push({ event, data, timestamp: new Date() });
|
|
380
|
+
await this.publisher.publish(event, JSON.stringify(data));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async getHistory(event: string, limit: number = 100): Promise<EventRecord[]> {
|
|
384
|
+
return this.history
|
|
385
|
+
.filter((r) => r.event === event || event === "*")
|
|
386
|
+
.slice(-limit);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
## Best Practices
|
|
394
|
+
|
|
395
|
+
### 1. Keep Events Small
|
|
396
|
+
|
|
397
|
+
```ts
|
|
398
|
+
// Good - include only what's needed
|
|
399
|
+
await events.emit("user.created", {
|
|
400
|
+
id: user.id,
|
|
401
|
+
email: user.email,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Bad - including unnecessary data
|
|
405
|
+
await events.emit("user.created", {
|
|
406
|
+
...user, // Full user object
|
|
407
|
+
password: user.password, // Sensitive!
|
|
408
|
+
internalData: {...}, // Unnecessary
|
|
409
|
+
});
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### 2. Use Past Tense for Completed Actions
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
// Good - indicates action completed
|
|
416
|
+
"order.created"
|
|
417
|
+
"payment.processed"
|
|
418
|
+
"email.sent"
|
|
419
|
+
|
|
420
|
+
// Avoid - ambiguous timing
|
|
421
|
+
"order.create"
|
|
422
|
+
"payment.process"
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
### 3. Include Identifiers for Correlation
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
await events.emit("order.shipped", {
|
|
429
|
+
orderId: order.id,
|
|
430
|
+
userId: order.userId,
|
|
431
|
+
trackingNumber: shipment.trackingNumber,
|
|
432
|
+
// Correlate with requestId if available
|
|
433
|
+
requestId: ctx.requestId,
|
|
434
|
+
});
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### 4. Document Event Schemas
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
// events.ts - Central event type definitions
|
|
441
|
+
export interface UserCreatedEvent {
|
|
442
|
+
id: number;
|
|
443
|
+
email: string;
|
|
444
|
+
createdAt: string;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export interface OrderPaidEvent {
|
|
448
|
+
orderId: string;
|
|
449
|
+
amount: number;
|
|
450
|
+
currency: string;
|
|
451
|
+
paidAt: string;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Type-safe emission
|
|
455
|
+
await ctx.core.events.emit<UserCreatedEvent>("user.created", {
|
|
456
|
+
id: 1,
|
|
457
|
+
email: "test@example.com",
|
|
458
|
+
createdAt: new Date().toISOString(),
|
|
459
|
+
});
|
|
460
|
+
```
|