@spfn/core 0.2.0-beta.11 → 0.2.0-beta.13

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/event.md CHANGED
@@ -1,116 +1,423 @@
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';
9
+ import { defineEvent } from '@spfn/core/event';
10
+ import { Type } from '@sinclair/typebox';
10
11
 
11
- // Define event types
12
- export const userCreated = defineEvent<{
13
- userId: string;
14
- email: string;
15
- }>('user.created');
12
+ // Event with typed payload
13
+ export const userCreated = defineEvent('user.created', Type.Object({
14
+ userId: Type.String(),
15
+ email: Type.String(),
16
+ }));
16
17
 
17
- export const userUpdated = defineEvent<{
18
- userId: string;
19
- changes: Record<string, any>;
20
- }>('user.updated');
18
+ export const orderPlaced = defineEvent('order.placed', Type.Object({
19
+ orderId: Type.String(),
20
+ amount: Type.Number(),
21
+ }));
21
22
 
22
- export const userDeleted = defineEvent<{
23
- userId: string;
24
- }>('user.deleted');
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
+ ```
46
+
47
+ ## Multiple Subscribers
48
+
49
+ ```typescript
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
+ });
58
+
59
+ userCreated.subscribe(async (payload) => {
60
+ await notifyAdmins(payload.userId);
61
+ });
62
+
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
+ ## SSE Authentication
103
+
104
+ Browser `EventSource` API does not support custom headers. SPFN uses a **Token Exchange** pattern:
105
+
106
+ 1. Client sends `POST /events/token` with Bearer JWT
107
+ 2. Server returns a one-time token (30s TTL)
108
+ 3. Client connects to `GET /events/stream?token=...&events=...`
109
+
110
+ ```typescript
111
+ // server.config.ts
112
+ import { defineServerConfig } from '@spfn/core/server';
113
+ import { authenticate } from '@spfn/auth/server';
114
+
115
+ export default defineServerConfig()
116
+ .middlewares([authenticate])
117
+ .routes(appRouter)
118
+ .events(eventRouter, {
119
+ auth: { enabled: true }, // This is all you need
120
+ })
121
+ .build();
122
+ // → POST /events/token (protected by authenticate middleware)
123
+ // → GET /events/stream?token=...&events=... (token verified)
124
+ ```
125
+
126
+ ### Authorization Hooks
127
+
128
+ #### `authorize` — Subscription Authorization (once on connect)
129
+
130
+ ```typescript
131
+ .events(eventRouter, {
132
+ auth: {
133
+ enabled: true,
134
+ authorize: async (subject, events) =>
135
+ {
136
+ // events: ('userCreated' | 'orderPlaced')[] — type inferred
137
+ const user = await usersRepository.findById(subject);
138
+ if (user.role === 'admin') return events;
139
+ return events.filter(e => !e.startsWith('admin.'));
140
+ },
141
+ },
142
+ })
45
143
  ```
46
144
 
47
- ## Handle Events
145
+ #### `filter` — Payload Filtering (on every event emission)
48
146
 
49
147
  ```typescript
50
- import { on } from '@spfn/core/event';
51
- import { userCreated, userDeleted } from './events';
148
+ .events(eventRouter, {
149
+ auth: {
150
+ enabled: true,
151
+ filter: {
152
+ // payload type inferred per-event — no casting needed
153
+ orderPlaced: (subject, payload) => payload.userId === subject,
154
+ // userCreated: no filter → sent to all authenticated users
155
+ },
156
+ },
157
+ })
158
+ ```
52
159
 
53
- // Register handlers
54
- on(userCreated, async (payload) => {
55
- // Send welcome email
56
- await emailService.sendWelcome(payload.email);
160
+ ## Browser Client
161
+
162
+ ```typescript
163
+ import { createSSEClient } from '@spfn/core/event/sse/client';
164
+ import type { EventRouter } from '@/server/events/router';
165
+
166
+ // Create client (uses defaults: NEXT_PUBLIC_SPFN_API_URL + /events/stream)
167
+ const client = createSSEClient<EventRouter>();
168
+
169
+ // Or with custom configuration
170
+ const client = createSSEClient<EventRouter>({
171
+ host: 'https://api.example.com',
172
+ pathname: '/sse',
173
+ reconnect: true,
174
+ reconnectDelay: 3000,
57
175
  });
58
176
 
59
- on(userCreated, async (payload) => {
60
- // Create default settings
61
- await settingsRepo.createDefaults(payload.userId);
177
+ // Subscribe to events
178
+ const unsubscribe = client.subscribe({
179
+ events: ['userCreated', 'orderPlaced'],
180
+ handlers: {
181
+ userCreated: (payload) => {
182
+ console.log('New user:', payload.userId);
183
+ },
184
+ orderPlaced: (payload) => {
185
+ console.log('New order:', payload.orderId);
186
+ },
187
+ },
188
+ onOpen: () => console.log('SSE connected'),
189
+ onError: (err) => console.error('SSE error:', err),
62
190
  });
63
191
 
64
- on(userDeleted, async (payload) => {
65
- // Cleanup related data
66
- await cleanupUserData(payload.userId);
192
+ // Cleanup
193
+ unsubscribe();
194
+ ```
195
+
196
+ ### With Authentication
197
+
198
+ ```typescript
199
+ const client = createSSEClient<EventRouter>({
200
+ acquireToken: async () =>
201
+ {
202
+ const res = await fetch('/api/events/token', {
203
+ method: 'POST',
204
+ credentials: 'include',
205
+ });
206
+ const data = await res.json();
207
+ return data.token;
208
+ },
209
+ });
210
+ ```
211
+
212
+ `acquireToken` is called on every (re)connect — one-time tokens are handled automatically.
213
+
214
+ ## Simple Subscribe Helper
215
+
216
+ ```typescript
217
+ import { subscribeToEvents } from '@spfn/core/event/sse/client';
218
+ import type { EventRouter } from '@/server/events/router';
219
+
220
+ // One-liner subscription
221
+ const unsubscribe = subscribeToEvents<EventRouter>(
222
+ ['userCreated'],
223
+ {
224
+ userCreated: (payload) => console.log('User:', payload),
225
+ }
226
+ );
227
+ ```
228
+
229
+ ## Job Integration
230
+
231
+ ```typescript
232
+ import { defineEvent } from '@spfn/core/event';
233
+ import { job, defineJobRouter } from '@spfn/core/job';
234
+
235
+ // Define event
236
+ export const orderPlaced = defineEvent('order.placed', Type.Object({
237
+ orderId: Type.String(),
238
+ userId: Type.String(),
239
+ }));
240
+
241
+ // Jobs subscribe to event
242
+ export const sendOrderConfirmation = job('send-order-confirmation')
243
+ .on(orderPlaced)
244
+ .handler(async (payload) => {
245
+ await emailService.sendOrderConfirmation(payload.orderId);
246
+ });
247
+
248
+ export const updateInventory = job('update-inventory')
249
+ .on(orderPlaced)
250
+ .handler(async (payload) => {
251
+ await inventoryService.reserve(payload.orderId);
252
+ });
253
+
254
+ // Register jobs
255
+ export const jobRouter = defineJobRouter({
256
+ sendOrderConfirmation,
257
+ updateInventory,
67
258
  });
259
+
260
+ // Emit event - all subscribed jobs execute
261
+ await orderPlaced.emit({ orderId: 'ord-123', userId: 'user-456' });
68
262
  ```
69
263
 
70
- ## Handler Registration
264
+ ## Multi-Instance Support
265
+
266
+ For applications running multiple instances, use cache-based pub/sub.
71
267
 
72
268
  ```typescript
73
- // src/server/events/handlers.ts
74
- import { on } from '@spfn/core/event';
75
- import { userCreated, userUpdated, userDeleted } from './index';
269
+ import { defineEvent } from '@spfn/core/event';
270
+ import { getCache } from '@spfn/core/cache';
76
271
 
77
- // Register all handlers
78
- export function registerEventHandlers()
272
+ const userCreated = defineEvent('user.created', Type.Object({
273
+ userId: Type.String(),
274
+ }));
275
+
276
+ // Enable cache-based pub/sub
277
+ const cache = getCache();
278
+ if (cache)
79
279
  {
80
- on(userCreated, handleUserCreated);
81
- on(userUpdated, handleUserUpdated);
82
- on(userDeleted, handleUserDeleted);
280
+ await userCreated.useCache({
281
+ publish: async (channel, message) => {
282
+ await cache.publish(channel, JSON.stringify(message));
283
+ },
284
+ subscribe: async (channel, handler) => {
285
+ const subscriber = cache.duplicate();
286
+ await subscriber.subscribe(channel);
287
+ subscriber.on('message', (ch, msg) => {
288
+ if (ch === channel)
289
+ {
290
+ handler(JSON.parse(msg));
291
+ }
292
+ });
293
+ },
294
+ });
83
295
  }
84
296
 
85
- // Call in server startup
86
- import { registerEventHandlers } from './events/handlers';
87
- registerEventHandlers();
297
+ // Events now broadcast to all instances
298
+ await userCreated.emit({ userId: '123' });
88
299
  ```
89
300
 
90
- ## Best Practices
301
+ ## API Reference
302
+
303
+ ### defineEvent(name)
304
+
305
+ Define an event without payload.
91
306
 
92
307
  ```typescript
93
- // 1. Define events in a central location
94
- // src/server/events/index.ts
308
+ export const serverStarted = defineEvent('server.started');
309
+
310
+ serverStarted.subscribe(() => { ... });
311
+ await serverStarted.emit();
312
+ ```
313
+
314
+ ### defineEvent(name, schema)
95
315
 
96
- // 2. Use descriptive event names
316
+ Define an event with typed payload.
317
+
318
+ ```typescript
319
+ export const userCreated = defineEvent('user.created', Type.Object({
320
+ userId: Type.String(),
321
+ }));
322
+
323
+ userCreated.subscribe((payload) => { ... });
324
+ await userCreated.emit({ userId: '123' });
325
+ ```
326
+
327
+ ### EventDef Methods
328
+
329
+ | Method | Description |
330
+ |--------|-------------|
331
+ | `subscribe(handler)` | Subscribe to event. Returns unsubscribe function |
332
+ | `unsubscribeAll()` | Remove all subscribers |
333
+ | `emit(payload?)` | Emit event to all subscribers |
334
+ | `useCache(cache)` | Enable cache-based pub/sub for multi-instance |
335
+
336
+ ### SSE Client Options
337
+
338
+ | Option | Type | Default | Description |
339
+ |--------|------|---------|-------------|
340
+ | `host` | string | `NEXT_PUBLIC_SPFN_API_URL` | Backend API host URL |
341
+ | `pathname` | string | `/events/stream` | SSE endpoint pathname |
342
+ | `reconnect` | boolean | `true` | Auto reconnect on disconnect |
343
+ | `reconnectDelay` | number | `3000` | Reconnect delay (ms) |
344
+ | `maxReconnectAttempts` | number | `0` | Max attempts (0 = infinite) |
345
+ | `withCredentials` | boolean | `false` | Include cookies |
346
+ | `acquireToken` | () => Promise\<string\> | - | Acquire one-time SSE token before connecting |
347
+
348
+ ### SSE Auth Options
349
+
350
+ | Option | Type | Default | Description |
351
+ |--------|------|---------|-------------|
352
+ | `enabled` | boolean | `false` | Enable token authentication |
353
+ | `tokenTtl` | number | `30000` | Token TTL in milliseconds |
354
+ | `store` | SSETokenStore | InMemory | Custom token store (e.g., Redis) |
355
+ | `getSubject` | (c) => string \| null | `c.get('auth')?.userId` | Extract subject from context |
356
+ | `authorize` | (subject, events) => events[] | - | Subscription authorization hook |
357
+ | `filter` | { [event]: (subject, payload) => boolean } | - | Per-event payload filter |
358
+
359
+ ## Event Flow Architecture
360
+
361
+ ```
362
+ userCreated.emit({ ... })
363
+
364
+ ┌───────────────────┼───────────────────┐
365
+ ▼ ▼ ▼
366
+ ┌──────────┐ ┌──────────┐ ┌──────────┐
367
+ │ Backend │ │ Job │ │ SSE │
368
+ │ Handler │ │ Queue │ │ Stream │
369
+ └──────────┘ └──────────┘ └──────────┘
370
+ .subscribe() .on(event) ↓
371
+ │ │ ┌──────────┐
372
+ ▼ ▼ │ Browser │
373
+ [Logging, [Background │ Client │
374
+ Analytics] Processing] └──────────┘
375
+ ```
376
+
377
+ ## Best Practices
378
+
379
+ ```typescript
380
+ // 1. Use descriptive event names
97
381
  defineEvent('user.created')
98
382
  defineEvent('order.completed')
99
383
  defineEvent('payment.failed')
100
384
 
101
- // 3. Keep payloads minimal
102
- defineEvent<{ userId: string }>('user.deleted') // Just ID, not full user
385
+ // 2. Keep payloads minimal - just IDs, not full objects
386
+ defineEvent('user.deleted', Type.Object({
387
+ userId: Type.String(),
388
+ }));
103
389
 
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
- }
390
+ // 3. Handler errors are isolated - one failing handler doesn't affect others
391
+ userCreated.subscribe(async (payload) => {
392
+ throw new Error('This fails');
111
393
  });
112
394
 
113
- // 5. Use events for side effects, not core logic
395
+ userCreated.subscribe(async (payload) => {
396
+ // This still executes
397
+ console.log('Handler 2 runs');
398
+ });
399
+
400
+ // 4. Use events for side effects, not core logic
114
401
  // Core: await userRepo.create(data);
115
- // Side effect: emit(userCreated, { ... });
402
+ // Side effect: await userCreated.emit({ ... });
403
+
404
+ // 5. Await useCache() before emitting for multi-instance
405
+ await userCreated.useCache(cache);
406
+ await userCreated.emit({ userId: '123' });
116
407
  ```
408
+
409
+ ## Event vs Direct Job
410
+
411
+ | Aspect | Event + Job | Direct Job |
412
+ |--------|-------------|------------|
413
+ | Coupling | Loose (producer doesn't know consumers) | Tight (producer calls specific job) |
414
+ | Multiple consumers | Easy (multiple jobs subscribe) | Manual (call each job) |
415
+ | Extensibility | Add consumers without modifying producer | Modify producer for each consumer |
416
+
417
+ **Use Event when:**
418
+ - Multiple systems need to react to the same occurrence
419
+ - You want to decouple producers from consumers
420
+
421
+ **Use Direct Job when:**
422
+ - Single, known consumer
423
+ - 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.11",
3
+ "version": "0.2.0-beta.13",
4
4
  "description": "SPFN Framework Core - File-based routing, transactions, repository pattern",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,121 +0,0 @@
1
- import { E as EventRouterDef, I as InferEventNames, b as InferEventPayload } from './router-Di7ENoah.js';
2
-
3
- /**
4
- * SSE Types
5
- *
6
- * Type definitions for Server-Sent Events
7
- */
8
-
9
- /**
10
- * SSE message sent from server
11
- */
12
- interface SSEMessage<TEvent extends string = string, TPayload = unknown> {
13
- /** Event name */
14
- event: TEvent;
15
- /** Event payload */
16
- data: TPayload;
17
- /** Optional message ID for reconnection */
18
- id?: string;
19
- }
20
- /**
21
- * SSE Handler configuration
22
- */
23
- interface SSEHandlerConfig {
24
- /**
25
- * Keep-alive ping interval in milliseconds
26
- * @default 30000
27
- */
28
- pingInterval?: number;
29
- /**
30
- * Custom headers for SSE response
31
- */
32
- headers?: Record<string, string>;
33
- }
34
- /**
35
- * SSE Client configuration
36
- */
37
- interface SSEClientConfig {
38
- /**
39
- * Backend API host URL
40
- * @default NEXT_PUBLIC_SPFN_API_URL || 'http://localhost:8790'
41
- * @example 'http://localhost:8790'
42
- * @example 'https://api.example.com'
43
- */
44
- host?: string;
45
- /**
46
- * SSE endpoint pathname
47
- * @default '/events/stream'
48
- */
49
- pathname?: string;
50
- /**
51
- * Full URL (overrides host + pathname)
52
- * @deprecated Use host and pathname instead
53
- * @example 'http://localhost:8790/events/stream'
54
- */
55
- url?: string;
56
- /**
57
- * Auto reconnect on disconnect
58
- * @default true
59
- */
60
- reconnect?: boolean;
61
- /**
62
- * Reconnect delay in milliseconds
63
- * @default 3000
64
- */
65
- reconnectDelay?: number;
66
- /**
67
- * Maximum reconnect attempts (0 = infinite)
68
- * @default 0
69
- */
70
- maxReconnectAttempts?: number;
71
- /**
72
- * Include credentials (cookies) in request
73
- * @default false
74
- */
75
- withCredentials?: boolean;
76
- }
77
- /**
78
- * Event handler function
79
- */
80
- type SSEEventHandler<TPayload> = (payload: TPayload) => void;
81
- /**
82
- * Event handlers map for EventRouter
83
- */
84
- type SSEEventHandlers<TRouter extends EventRouterDef<any>> = {
85
- [K in InferEventNames<TRouter>]?: SSEEventHandler<InferEventPayload<TRouter, K>>;
86
- };
87
- /**
88
- * Subscription options
89
- */
90
- interface SSESubscribeOptions<TRouter extends EventRouterDef<any>> {
91
- /**
92
- * Events to subscribe
93
- */
94
- events: InferEventNames<TRouter>[];
95
- /**
96
- * Event handlers
97
- */
98
- handlers: SSEEventHandlers<TRouter>;
99
- /**
100
- * Called when connection opens
101
- */
102
- onOpen?: () => void;
103
- /**
104
- * Called on connection error
105
- */
106
- onError?: (error: Event) => void;
107
- /**
108
- * Called when reconnecting
109
- */
110
- onReconnect?: (attempt: number) => void;
111
- }
112
- /**
113
- * SSE connection state
114
- */
115
- type SSEConnectionState = 'connecting' | 'open' | 'closed' | 'error';
116
- /**
117
- * Unsubscribe function
118
- */
119
- type SSEUnsubscribe = () => void;
120
-
121
- export type { SSEHandlerConfig as S, SSEMessage as a, SSEClientConfig as b, SSEEventHandler as c, SSEEventHandlers as d, SSESubscribeOptions as e, SSEConnectionState as f, SSEUnsubscribe as g };