@spfn/core 0.2.0-beta.12 → 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.
@@ -0,0 +1,298 @@
1
+ import { Context } from 'hono';
2
+ import { E as EventRouterDef, I as InferEventNames, b as InferEventPayload } from './router-Di7ENoah.js';
3
+
4
+ /**
5
+ * SSE Token Manager
6
+ *
7
+ * Auth-agnostic token issuance and verification for SSE connections.
8
+ * Issues one-time-use tokens with TTL for Token Exchange pattern.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * const manager = new SSETokenManager({ ttl: 30000 });
13
+ *
14
+ * // Issue token for authenticated user
15
+ * const token = await manager.issue('user-123');
16
+ *
17
+ * // Verify and consume token (one-time use)
18
+ * const subject = await manager.verify(token); // 'user-123'
19
+ * const again = await manager.verify(token); // null (already consumed)
20
+ *
21
+ * // Cleanup on shutdown
22
+ * manager.destroy();
23
+ * ```
24
+ */
25
+ /**
26
+ * Stored SSE token data
27
+ */
28
+ interface SSEToken {
29
+ token: string;
30
+ subject: string;
31
+ expiresAt: number;
32
+ }
33
+ /**
34
+ * Token storage interface
35
+ *
36
+ * Implement this for custom storage backends (e.g., Redis for multi-instance).
37
+ */
38
+ interface SSETokenStore {
39
+ /** Store a token */
40
+ set(token: string, data: SSEToken): Promise<void>;
41
+ /** Get and delete a token (one-time use) */
42
+ consume(token: string): Promise<SSEToken | null>;
43
+ /** Remove expired tokens */
44
+ cleanup(): Promise<void>;
45
+ }
46
+ /**
47
+ * SSETokenManager configuration
48
+ */
49
+ interface SSETokenManagerConfig {
50
+ /**
51
+ * Token time-to-live in milliseconds
52
+ * @default 30000
53
+ */
54
+ ttl?: number;
55
+ /**
56
+ * Custom token store (default: in-memory Map)
57
+ */
58
+ store?: SSETokenStore;
59
+ /**
60
+ * Cleanup interval in milliseconds
61
+ * @default 60000
62
+ */
63
+ cleanupInterval?: number;
64
+ }
65
+ declare class SSETokenManager {
66
+ private store;
67
+ private ttl;
68
+ private cleanupTimer;
69
+ constructor(config?: SSETokenManagerConfig);
70
+ /**
71
+ * Issue a new one-time-use token for the given subject
72
+ */
73
+ issue(subject: string): Promise<string>;
74
+ /**
75
+ * Verify and consume a token
76
+ * @returns subject string if valid, null if invalid/expired/already consumed
77
+ */
78
+ verify(token: string): Promise<string | null>;
79
+ /**
80
+ * Cleanup timer and resources
81
+ */
82
+ destroy(): void;
83
+ }
84
+
85
+ /**
86
+ * SSE Types
87
+ *
88
+ * Type definitions for Server-Sent Events
89
+ */
90
+
91
+ /**
92
+ * SSE message sent from server
93
+ */
94
+ interface SSEMessage<TEvent extends string = string, TPayload = unknown> {
95
+ /** Event name */
96
+ event: TEvent;
97
+ /** Event payload */
98
+ data: TPayload;
99
+ /** Optional message ID for reconnection */
100
+ id?: string;
101
+ }
102
+ /**
103
+ * SSE auth configuration (internal, non-generic)
104
+ *
105
+ * Stored in SSEHandlerConfig. Generic user-facing version is SSEAuthConfig.
106
+ */
107
+ interface SSEHandlerAuthConfig {
108
+ /**
109
+ * Enable SSE token authentication
110
+ * @default false
111
+ */
112
+ enabled?: boolean;
113
+ /**
114
+ * Token TTL in milliseconds
115
+ * @default 30000
116
+ */
117
+ tokenTtl?: number;
118
+ /**
119
+ * Custom token store (e.g., Redis for multi-instance)
120
+ */
121
+ store?: SSETokenStore;
122
+ /**
123
+ * Extract subject (user ID) from Hono context
124
+ * @default (c) => c.get('auth')?.userId ?? null
125
+ */
126
+ getSubject?: (c: Context) => string | null;
127
+ /**
128
+ * Subscription authorization hook (called once on connect)
129
+ *
130
+ * Return allowed events subset. Empty array = 403 rejection.
131
+ */
132
+ authorize?: (subject: string, events: string[]) => Promise<string[]> | string[];
133
+ /**
134
+ * Per-event payload filter map (called on every event emission)
135
+ *
136
+ * Return false to skip sending the event to this user.
137
+ */
138
+ filter?: Record<string, (subject: string, payload: unknown) => boolean>;
139
+ }
140
+ /**
141
+ * SSE auth configuration (user-facing, generic)
142
+ *
143
+ * Provides type-safe event names and payload inference from EventRouter.
144
+ *
145
+ * @example
146
+ * ```typescript
147
+ * .events(eventRouter, {
148
+ * auth: {
149
+ * enabled: true,
150
+ * authorize: async (subject, events) => {
151
+ * // events: ('userCreated' | 'orderUpdated')[]
152
+ * return events.filter(e => hasPermission(subject, e));
153
+ * },
154
+ * filter: {
155
+ * orderUpdated: (subject, payload) => {
156
+ * // payload: { orderId: string; userId: string }
157
+ * return payload.userId === subject;
158
+ * },
159
+ * },
160
+ * },
161
+ * })
162
+ * ```
163
+ */
164
+ interface SSEAuthConfig<TRouter extends EventRouterDef<any>> {
165
+ enabled?: boolean;
166
+ tokenTtl?: number;
167
+ store?: SSETokenStore;
168
+ getSubject?: (c: Context) => string | null;
169
+ authorize?: (subject: string, events: InferEventNames<TRouter>[]) => Promise<InferEventNames<TRouter>[]> | InferEventNames<TRouter>[];
170
+ filter?: {
171
+ [K in InferEventNames<TRouter>]?: (subject: string, payload: InferEventPayload<TRouter, K>) => boolean;
172
+ };
173
+ }
174
+ /**
175
+ * SSE Handler configuration
176
+ */
177
+ interface SSEHandlerConfig {
178
+ /**
179
+ * Keep-alive ping interval in milliseconds
180
+ * @default 30000
181
+ */
182
+ pingInterval?: number;
183
+ /**
184
+ * Custom headers for SSE response
185
+ */
186
+ headers?: Record<string, string>;
187
+ /**
188
+ * Authentication and authorization configuration
189
+ */
190
+ auth?: SSEHandlerAuthConfig;
191
+ }
192
+ /**
193
+ * SSE Client configuration
194
+ */
195
+ interface SSEClientConfig {
196
+ /**
197
+ * Backend API host URL
198
+ * @default NEXT_PUBLIC_SPFN_API_URL || 'http://localhost:8790'
199
+ * @example 'http://localhost:8790'
200
+ * @example 'https://api.example.com'
201
+ */
202
+ host?: string;
203
+ /**
204
+ * SSE endpoint pathname
205
+ * @default '/events/stream'
206
+ */
207
+ pathname?: string;
208
+ /**
209
+ * Full URL (overrides host + pathname)
210
+ * @deprecated Use host and pathname instead
211
+ * @example 'http://localhost:8790/events/stream'
212
+ */
213
+ url?: string;
214
+ /**
215
+ * Auto reconnect on disconnect
216
+ * @default true
217
+ */
218
+ reconnect?: boolean;
219
+ /**
220
+ * Reconnect delay in milliseconds
221
+ * @default 3000
222
+ */
223
+ reconnectDelay?: number;
224
+ /**
225
+ * Maximum reconnect attempts (0 = infinite)
226
+ * @default 0
227
+ */
228
+ maxReconnectAttempts?: number;
229
+ /**
230
+ * Include credentials (cookies) in request
231
+ * @default false
232
+ */
233
+ withCredentials?: boolean;
234
+ /**
235
+ * Acquire a one-time SSE token before connecting.
236
+ *
237
+ * Called on every (re)connect. The returned token is appended
238
+ * to the SSE URL as `?token=...`.
239
+ *
240
+ * @example
241
+ * ```typescript
242
+ * acquireToken: async () => {
243
+ * const res = await fetch('/api/events/token', {
244
+ * method: 'POST',
245
+ * credentials: 'include',
246
+ * });
247
+ * const data = await res.json();
248
+ * return data.token;
249
+ * }
250
+ * ```
251
+ */
252
+ acquireToken?: () => Promise<string>;
253
+ }
254
+ /**
255
+ * Event handler function
256
+ */
257
+ type SSEEventHandler<TPayload> = (payload: TPayload) => void;
258
+ /**
259
+ * Event handlers map for EventRouter
260
+ */
261
+ type SSEEventHandlers<TRouter extends EventRouterDef<any>> = {
262
+ [K in InferEventNames<TRouter>]?: SSEEventHandler<InferEventPayload<TRouter, K>>;
263
+ };
264
+ /**
265
+ * Subscription options
266
+ */
267
+ interface SSESubscribeOptions<TRouter extends EventRouterDef<any>> {
268
+ /**
269
+ * Events to subscribe
270
+ */
271
+ events: InferEventNames<TRouter>[];
272
+ /**
273
+ * Event handlers
274
+ */
275
+ handlers: SSEEventHandlers<TRouter>;
276
+ /**
277
+ * Called when connection opens
278
+ */
279
+ onOpen?: () => void;
280
+ /**
281
+ * Called on connection error
282
+ */
283
+ onError?: (error: Event) => void;
284
+ /**
285
+ * Called when reconnecting
286
+ */
287
+ onReconnect?: (attempt: number) => void;
288
+ }
289
+ /**
290
+ * SSE connection state
291
+ */
292
+ type SSEConnectionState = 'connecting' | 'open' | 'closed' | 'error';
293
+ /**
294
+ * Unsubscribe function
295
+ */
296
+ type SSEUnsubscribe = () => void;
297
+
298
+ export { type SSEHandlerConfig as S, type SSEAuthConfig as a, SSETokenManager as b, type SSEToken as c, type SSETokenStore as d, type SSETokenManagerConfig as e, type SSEMessage as f, type SSEHandlerAuthConfig as g, type SSEClientConfig as h, type SSEEventHandler as i, type SSEEventHandlers as j, type SSESubscribeOptions as k, type SSEConnectionState as l, type SSEUnsubscribe as m };
package/docs/event.md CHANGED
@@ -99,6 +99,64 @@ export default defineServerConfig()
99
99
  })
100
100
  ```
101
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
+ })
143
+ ```
144
+
145
+ #### `filter` — Payload Filtering (on every event emission)
146
+
147
+ ```typescript
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
+ ```
159
+
102
160
  ## Browser Client
103
161
 
104
162
  ```typescript
@@ -135,6 +193,24 @@ const unsubscribe = client.subscribe({
135
193
  unsubscribe();
136
194
  ```
137
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
+
138
214
  ## Simple Subscribe Helper
139
215
 
140
216
  ```typescript
@@ -267,6 +343,18 @@ await userCreated.emit({ userId: '123' });
267
343
  | `reconnectDelay` | number | `3000` | Reconnect delay (ms) |
268
344
  | `maxReconnectAttempts` | number | `0` | Max attempts (0 = infinite) |
269
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 |
270
358
 
271
359
  ## Event Flow Architecture
272
360
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spfn/core",
3
- "version": "0.2.0-beta.12",
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 };