@spfn/core 0.2.0-beta.10 → 0.2.0-beta.12
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/dist/codegen/index.js +29 -9
- package/dist/codegen/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +2 -2
- package/dist/nextjs/server.d.ts +5 -1
- package/dist/nextjs/server.js +4 -2
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server/index.js +5 -30
- package/dist/server/index.js.map +1 -1
- package/dist/{types-BOPTApC2.d.ts → types-DP8nzQcY.d.ts} +5 -0
- package/docs/event.md +289 -70
- package/package.json +1 -1
|
@@ -202,6 +202,11 @@ interface ApiConfig {
|
|
|
202
202
|
* Per-call options
|
|
203
203
|
*/
|
|
204
204
|
interface CallOptions {
|
|
205
|
+
/**
|
|
206
|
+
* Request timeout in milliseconds
|
|
207
|
+
* Overrides the global timeout set in ApiConfig
|
|
208
|
+
*/
|
|
209
|
+
timeout?: number;
|
|
205
210
|
/**
|
|
206
211
|
* Additional headers for this request
|
|
207
212
|
*/
|
package/docs/event.md
CHANGED
|
@@ -1,116 +1,335 @@
|
|
|
1
1
|
# Event
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Decoupled pub/sub event system with SSE support for real-time frontend updates.
|
|
4
4
|
|
|
5
5
|
## Define Events
|
|
6
6
|
|
|
7
7
|
```typescript
|
|
8
8
|
// src/server/events/index.ts
|
|
9
|
-
import { defineEvent
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
9
|
+
import { defineEvent } from '@spfn/core/event';
|
|
10
|
+
import { Type } from '@sinclair/typebox';
|
|
11
|
+
|
|
12
|
+
// Event with typed payload
|
|
13
|
+
export const userCreated = defineEvent('user.created', Type.Object({
|
|
14
|
+
userId: Type.String(),
|
|
15
|
+
email: Type.String(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
export const orderPlaced = defineEvent('order.placed', Type.Object({
|
|
19
|
+
orderId: Type.String(),
|
|
20
|
+
amount: Type.Number(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Event without payload
|
|
24
|
+
export const serverStarted = defineEvent('server.started');
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
## Emit
|
|
27
|
+
## Subscribe & Emit
|
|
28
28
|
|
|
29
29
|
```typescript
|
|
30
|
-
import {
|
|
31
|
-
import { userCreated } from './events';
|
|
30
|
+
import { userCreated, serverStarted } from './events';
|
|
32
31
|
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
// Subscribe to event
|
|
33
|
+
const unsubscribe = userCreated.subscribe((payload) => {
|
|
34
|
+
console.log('User created:', payload.userId);
|
|
35
|
+
});
|
|
37
36
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
email: user.email
|
|
41
|
-
});
|
|
37
|
+
// Emit event
|
|
38
|
+
await userCreated.emit({ userId: '123', email: 'user@example.com' });
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
// Emit event without payload
|
|
41
|
+
await serverStarted.emit();
|
|
42
|
+
|
|
43
|
+
// Unsubscribe when done
|
|
44
|
+
unsubscribe();
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
##
|
|
47
|
+
## Multiple Subscribers
|
|
48
48
|
|
|
49
49
|
```typescript
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// Multiple independent handlers
|
|
51
|
+
userCreated.subscribe(async (payload) => {
|
|
52
|
+
await sendWelcomeEmail(payload.email);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
userCreated.subscribe(async (payload) => {
|
|
56
|
+
await createDefaultSettings(payload.userId);
|
|
57
|
+
});
|
|
52
58
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// Send welcome email
|
|
56
|
-
await emailService.sendWelcome(payload.email);
|
|
59
|
+
userCreated.subscribe(async (payload) => {
|
|
60
|
+
await notifyAdmins(payload.userId);
|
|
57
61
|
});
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
// All handlers execute when event is emitted
|
|
64
|
+
await userCreated.emit({ userId: '123', email: 'user@example.com' });
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Event Router for SSE
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
// src/server/events/router.ts
|
|
71
|
+
import { defineEventRouter } from '@spfn/core/event';
|
|
72
|
+
import { userCreated, orderPlaced } from './index';
|
|
73
|
+
|
|
74
|
+
export const eventRouter = defineEventRouter({
|
|
75
|
+
userCreated,
|
|
76
|
+
orderPlaced,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export type EventRouter = typeof eventRouter;
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Server Setup
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// server.config.ts
|
|
86
|
+
import { defineServerConfig } from '@spfn/core/server';
|
|
87
|
+
import { eventRouter } from './events/router';
|
|
88
|
+
|
|
89
|
+
export default defineServerConfig()
|
|
90
|
+
.routes(appRouter)
|
|
91
|
+
.jobs(jobRouter)
|
|
92
|
+
.events(eventRouter) // → GET /events/stream
|
|
93
|
+
.build();
|
|
94
|
+
|
|
95
|
+
// Custom path and options
|
|
96
|
+
.events(eventRouter, {
|
|
97
|
+
path: '/sse', // Custom endpoint path
|
|
98
|
+
pingInterval: 30000, // Keep-alive interval (default: 30s)
|
|
99
|
+
})
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Browser Client
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
import { createSSEClient } from '@spfn/core/event/sse/client';
|
|
106
|
+
import type { EventRouter } from '@/server/events/router';
|
|
107
|
+
|
|
108
|
+
// Create client (uses defaults: NEXT_PUBLIC_SPFN_API_URL + /events/stream)
|
|
109
|
+
const client = createSSEClient<EventRouter>();
|
|
110
|
+
|
|
111
|
+
// Or with custom configuration
|
|
112
|
+
const client = createSSEClient<EventRouter>({
|
|
113
|
+
host: 'https://api.example.com',
|
|
114
|
+
pathname: '/sse',
|
|
115
|
+
reconnect: true,
|
|
116
|
+
reconnectDelay: 3000,
|
|
62
117
|
});
|
|
63
118
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
119
|
+
// Subscribe to events
|
|
120
|
+
const unsubscribe = client.subscribe({
|
|
121
|
+
events: ['userCreated', 'orderPlaced'],
|
|
122
|
+
handlers: {
|
|
123
|
+
userCreated: (payload) => {
|
|
124
|
+
console.log('New user:', payload.userId);
|
|
125
|
+
},
|
|
126
|
+
orderPlaced: (payload) => {
|
|
127
|
+
console.log('New order:', payload.orderId);
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
onOpen: () => console.log('SSE connected'),
|
|
131
|
+
onError: (err) => console.error('SSE error:', err),
|
|
67
132
|
});
|
|
133
|
+
|
|
134
|
+
// Cleanup
|
|
135
|
+
unsubscribe();
|
|
68
136
|
```
|
|
69
137
|
|
|
70
|
-
##
|
|
138
|
+
## Simple Subscribe Helper
|
|
71
139
|
|
|
72
140
|
```typescript
|
|
73
|
-
|
|
74
|
-
import {
|
|
75
|
-
|
|
141
|
+
import { subscribeToEvents } from '@spfn/core/event/sse/client';
|
|
142
|
+
import type { EventRouter } from '@/server/events/router';
|
|
143
|
+
|
|
144
|
+
// One-liner subscription
|
|
145
|
+
const unsubscribe = subscribeToEvents<EventRouter>(
|
|
146
|
+
['userCreated'],
|
|
147
|
+
{
|
|
148
|
+
userCreated: (payload) => console.log('User:', payload),
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Job Integration
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
import { defineEvent } from '@spfn/core/event';
|
|
157
|
+
import { job, defineJobRouter } from '@spfn/core/job';
|
|
158
|
+
|
|
159
|
+
// Define event
|
|
160
|
+
export const orderPlaced = defineEvent('order.placed', Type.Object({
|
|
161
|
+
orderId: Type.String(),
|
|
162
|
+
userId: Type.String(),
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
// Jobs subscribe to event
|
|
166
|
+
export const sendOrderConfirmation = job('send-order-confirmation')
|
|
167
|
+
.on(orderPlaced)
|
|
168
|
+
.handler(async (payload) => {
|
|
169
|
+
await emailService.sendOrderConfirmation(payload.orderId);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
export const updateInventory = job('update-inventory')
|
|
173
|
+
.on(orderPlaced)
|
|
174
|
+
.handler(async (payload) => {
|
|
175
|
+
await inventoryService.reserve(payload.orderId);
|
|
176
|
+
});
|
|
76
177
|
|
|
77
|
-
// Register
|
|
78
|
-
export
|
|
178
|
+
// Register jobs
|
|
179
|
+
export const jobRouter = defineJobRouter({
|
|
180
|
+
sendOrderConfirmation,
|
|
181
|
+
updateInventory,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Emit event - all subscribed jobs execute
|
|
185
|
+
await orderPlaced.emit({ orderId: 'ord-123', userId: 'user-456' });
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Multi-Instance Support
|
|
189
|
+
|
|
190
|
+
For applications running multiple instances, use cache-based pub/sub.
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
import { defineEvent } from '@spfn/core/event';
|
|
194
|
+
import { getCache } from '@spfn/core/cache';
|
|
195
|
+
|
|
196
|
+
const userCreated = defineEvent('user.created', Type.Object({
|
|
197
|
+
userId: Type.String(),
|
|
198
|
+
}));
|
|
199
|
+
|
|
200
|
+
// Enable cache-based pub/sub
|
|
201
|
+
const cache = getCache();
|
|
202
|
+
if (cache)
|
|
79
203
|
{
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
204
|
+
await userCreated.useCache({
|
|
205
|
+
publish: async (channel, message) => {
|
|
206
|
+
await cache.publish(channel, JSON.stringify(message));
|
|
207
|
+
},
|
|
208
|
+
subscribe: async (channel, handler) => {
|
|
209
|
+
const subscriber = cache.duplicate();
|
|
210
|
+
await subscriber.subscribe(channel);
|
|
211
|
+
subscriber.on('message', (ch, msg) => {
|
|
212
|
+
if (ch === channel)
|
|
213
|
+
{
|
|
214
|
+
handler(JSON.parse(msg));
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
});
|
|
83
219
|
}
|
|
84
220
|
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
registerEventHandlers();
|
|
221
|
+
// Events now broadcast to all instances
|
|
222
|
+
await userCreated.emit({ userId: '123' });
|
|
88
223
|
```
|
|
89
224
|
|
|
90
|
-
##
|
|
225
|
+
## API Reference
|
|
226
|
+
|
|
227
|
+
### defineEvent(name)
|
|
228
|
+
|
|
229
|
+
Define an event without payload.
|
|
91
230
|
|
|
92
231
|
```typescript
|
|
93
|
-
|
|
94
|
-
|
|
232
|
+
export const serverStarted = defineEvent('server.started');
|
|
233
|
+
|
|
234
|
+
serverStarted.subscribe(() => { ... });
|
|
235
|
+
await serverStarted.emit();
|
|
236
|
+
```
|
|
95
237
|
|
|
96
|
-
|
|
238
|
+
### defineEvent(name, schema)
|
|
239
|
+
|
|
240
|
+
Define an event with typed payload.
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
export const userCreated = defineEvent('user.created', Type.Object({
|
|
244
|
+
userId: Type.String(),
|
|
245
|
+
}));
|
|
246
|
+
|
|
247
|
+
userCreated.subscribe((payload) => { ... });
|
|
248
|
+
await userCreated.emit({ userId: '123' });
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### EventDef Methods
|
|
252
|
+
|
|
253
|
+
| Method | Description |
|
|
254
|
+
|--------|-------------|
|
|
255
|
+
| `subscribe(handler)` | Subscribe to event. Returns unsubscribe function |
|
|
256
|
+
| `unsubscribeAll()` | Remove all subscribers |
|
|
257
|
+
| `emit(payload?)` | Emit event to all subscribers |
|
|
258
|
+
| `useCache(cache)` | Enable cache-based pub/sub for multi-instance |
|
|
259
|
+
|
|
260
|
+
### SSE Client Options
|
|
261
|
+
|
|
262
|
+
| Option | Type | Default | Description |
|
|
263
|
+
|--------|------|---------|-------------|
|
|
264
|
+
| `host` | string | `NEXT_PUBLIC_SPFN_API_URL` | Backend API host URL |
|
|
265
|
+
| `pathname` | string | `/events/stream` | SSE endpoint pathname |
|
|
266
|
+
| `reconnect` | boolean | `true` | Auto reconnect on disconnect |
|
|
267
|
+
| `reconnectDelay` | number | `3000` | Reconnect delay (ms) |
|
|
268
|
+
| `maxReconnectAttempts` | number | `0` | Max attempts (0 = infinite) |
|
|
269
|
+
| `withCredentials` | boolean | `false` | Include cookies |
|
|
270
|
+
|
|
271
|
+
## Event Flow Architecture
|
|
272
|
+
|
|
273
|
+
```
|
|
274
|
+
userCreated.emit({ ... })
|
|
275
|
+
│
|
|
276
|
+
┌───────────────────┼───────────────────┐
|
|
277
|
+
▼ ▼ ▼
|
|
278
|
+
┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
279
|
+
│ Backend │ │ Job │ │ SSE │
|
|
280
|
+
│ Handler │ │ Queue │ │ Stream │
|
|
281
|
+
└──────────┘ └──────────┘ └──────────┘
|
|
282
|
+
.subscribe() .on(event) ↓
|
|
283
|
+
│ │ ┌──────────┐
|
|
284
|
+
▼ ▼ │ Browser │
|
|
285
|
+
[Logging, [Background │ Client │
|
|
286
|
+
Analytics] Processing] └──────────┘
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## Best Practices
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
// 1. Use descriptive event names
|
|
97
293
|
defineEvent('user.created')
|
|
98
294
|
defineEvent('order.completed')
|
|
99
295
|
defineEvent('payment.failed')
|
|
100
296
|
|
|
101
|
-
//
|
|
102
|
-
defineEvent
|
|
297
|
+
// 2. Keep payloads minimal - just IDs, not full objects
|
|
298
|
+
defineEvent('user.deleted', Type.Object({
|
|
299
|
+
userId: Type.String(),
|
|
300
|
+
}));
|
|
103
301
|
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
302
|
+
// 3. Handler errors are isolated - one failing handler doesn't affect others
|
|
303
|
+
userCreated.subscribe(async (payload) => {
|
|
304
|
+
throw new Error('This fails');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
userCreated.subscribe(async (payload) => {
|
|
308
|
+
// This still executes
|
|
309
|
+
console.log('Handler 2 runs');
|
|
111
310
|
});
|
|
112
311
|
|
|
113
|
-
//
|
|
312
|
+
// 4. Use events for side effects, not core logic
|
|
114
313
|
// Core: await userRepo.create(data);
|
|
115
|
-
// Side effect: emit(
|
|
314
|
+
// Side effect: await userCreated.emit({ ... });
|
|
315
|
+
|
|
316
|
+
// 5. Await useCache() before emitting for multi-instance
|
|
317
|
+
await userCreated.useCache(cache);
|
|
318
|
+
await userCreated.emit({ userId: '123' });
|
|
116
319
|
```
|
|
320
|
+
|
|
321
|
+
## Event vs Direct Job
|
|
322
|
+
|
|
323
|
+
| Aspect | Event + Job | Direct Job |
|
|
324
|
+
|--------|-------------|------------|
|
|
325
|
+
| Coupling | Loose (producer doesn't know consumers) | Tight (producer calls specific job) |
|
|
326
|
+
| Multiple consumers | Easy (multiple jobs subscribe) | Manual (call each job) |
|
|
327
|
+
| Extensibility | Add consumers without modifying producer | Modify producer for each consumer |
|
|
328
|
+
|
|
329
|
+
**Use Event when:**
|
|
330
|
+
- Multiple systems need to react to the same occurrence
|
|
331
|
+
- You want to decouple producers from consumers
|
|
332
|
+
|
|
333
|
+
**Use Direct Job when:**
|
|
334
|
+
- Single, known consumer
|
|
335
|
+
- Simpler mental model preferred
|