@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.
@@ -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
- In-process event system for decoupled communication.
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, defineEventHandler } from '@spfn/core/event';
10
-
11
- // Define event types
12
- export const userCreated = defineEvent<{
13
- userId: string;
14
- email: string;
15
- }>('user.created');
16
-
17
- export const userUpdated = defineEvent<{
18
- userId: string;
19
- changes: Record<string, any>;
20
- }>('user.updated');
21
-
22
- export const userDeleted = defineEvent<{
23
- userId: string;
24
- }>('user.deleted');
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 Events
27
+ ## Subscribe & Emit
28
28
 
29
29
  ```typescript
30
- import { emit } from '@spfn/core/event';
31
- import { userCreated } from './events';
30
+ import { userCreated, serverStarted } from './events';
32
31
 
33
- // In repository or service
34
- async function createUser(data: NewUser)
35
- {
36
- const user = await this._create(users, data);
32
+ // Subscribe to event
33
+ const unsubscribe = userCreated.subscribe((payload) => {
34
+ console.log('User created:', payload.userId);
35
+ });
37
36
 
38
- await emit(userCreated, {
39
- userId: user.id,
40
- email: user.email
41
- });
37
+ // Emit event
38
+ await userCreated.emit({ userId: '123', email: 'user@example.com' });
42
39
 
43
- return user;
44
- }
40
+ // Emit event without payload
41
+ await serverStarted.emit();
42
+
43
+ // Unsubscribe when done
44
+ unsubscribe();
45
45
  ```
46
46
 
47
- ## Handle Events
47
+ ## Multiple Subscribers
48
48
 
49
49
  ```typescript
50
- import { on } from '@spfn/core/event';
51
- import { userCreated, userDeleted } from './events';
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
- // Register handlers
54
- on(userCreated, async (payload) => {
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
- on(userCreated, async (payload) => {
60
- // Create default settings
61
- await settingsRepo.createDefaults(payload.userId);
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
- on(userDeleted, async (payload) => {
65
- // Cleanup related data
66
- await cleanupUserData(payload.userId);
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
- ## Handler Registration
138
+ ## Simple Subscribe Helper
71
139
 
72
140
  ```typescript
73
- // src/server/events/handlers.ts
74
- import { on } from '@spfn/core/event';
75
- import { userCreated, userUpdated, userDeleted } from './index';
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 all handlers
78
- export function registerEventHandlers()
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
- on(userCreated, handleUserCreated);
81
- on(userUpdated, handleUserUpdated);
82
- on(userDeleted, handleUserDeleted);
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
- // Call in server startup
86
- import { registerEventHandlers } from './events/handlers';
87
- registerEventHandlers();
221
+ // Events now broadcast to all instances
222
+ await userCreated.emit({ userId: '123' });
88
223
  ```
89
224
 
90
- ## Best Practices
225
+ ## API Reference
226
+
227
+ ### defineEvent(name)
228
+
229
+ Define an event without payload.
91
230
 
92
231
  ```typescript
93
- // 1. Define events in a central location
94
- // src/server/events/index.ts
232
+ export const serverStarted = defineEvent('server.started');
233
+
234
+ serverStarted.subscribe(() => { ... });
235
+ await serverStarted.emit();
236
+ ```
95
237
 
96
- // 2. Use descriptive event names
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
- // 3. Keep payloads minimal
102
- defineEvent<{ userId: string }>('user.deleted') // Just ID, not full user
297
+ // 2. Keep payloads minimal - just IDs, not full objects
298
+ defineEvent('user.deleted', Type.Object({
299
+ userId: Type.String(),
300
+ }));
103
301
 
104
- // 4. Handle errors in handlers
105
- on(userCreated, async (payload) => {
106
- try {
107
- await sendEmail(payload.email);
108
- } catch (error) {
109
- logger.error('Failed to send email', { error });
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
- // 5. Use events for side effects, not core logic
312
+ // 4. Use events for side effects, not core logic
114
313
  // Core: await userRepo.create(data);
115
- // Side effect: emit(userCreated, { ... });
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spfn/core",
3
- "version": "0.2.0-beta.10",
3
+ "version": "0.2.0-beta.12",
4
4
  "description": "SPFN Framework Core - File-based routing, transactions, repository pattern",
5
5
  "type": "module",
6
6
  "exports": {