@spfn/core 0.3.0-beta.2 → 0.3.0-beta.21

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.
Files changed (94) hide show
  1. package/README.md +343 -16
  2. package/dist/app-config/index.d.ts +134 -0
  3. package/dist/app-config/index.js +58 -0
  4. package/dist/app-config/index.js.map +1 -0
  5. package/dist/authz/index.js +1 -381
  6. package/dist/authz/index.js.map +1 -1
  7. package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
  8. package/dist/codegen/index.d.ts +93 -15
  9. package/dist/codegen/index.js +626 -165
  10. package/dist/codegen/index.js.map +1 -1
  11. package/dist/config/index.d.ts +0 -63
  12. package/dist/config/index.js +15 -15
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/contract/index.d.ts +40 -17
  15. package/dist/contract/index.js +147 -23
  16. package/dist/contract/index.js.map +1 -1
  17. package/dist/db/index.d.ts +201 -34
  18. package/dist/db/index.js +294 -158
  19. package/dist/db/index.js.map +1 -1
  20. package/dist/{define-middleware-DfDP39Nq.d.ts → define-middleware-CVKgqo8S.d.ts} +1 -1
  21. package/dist/env/index.d.ts +6 -442
  22. package/dist/env/index.js +13 -16
  23. package/dist/env/index.js.map +1 -1
  24. package/dist/env/loader.js +24 -1
  25. package/dist/env/loader.js.map +1 -1
  26. package/dist/error-TsUIjp_x.d.ts +157 -0
  27. package/dist/errors/index.js +1 -381
  28. package/dist/errors/index.js.map +1 -1
  29. package/dist/event/index.d.ts +5 -5
  30. package/dist/event/index.js +32 -1
  31. package/dist/event/index.js.map +1 -1
  32. package/dist/event/sse/client.d.ts +18 -4
  33. package/dist/event/sse/client.js +38 -2
  34. package/dist/event/sse/client.js.map +1 -1
  35. package/dist/event/sse/index.d.ts +5 -4
  36. package/dist/event/ws/client.d.ts +3 -2
  37. package/dist/event/ws/index.d.ts +4 -3
  38. package/dist/job/index.d.ts +48 -3
  39. package/dist/job/index.js +131 -52
  40. package/dist/job/index.js.map +1 -1
  41. package/dist/logger/index.js +0 -12
  42. package/dist/logger/index.js.map +1 -1
  43. package/dist/middleware/index.d.ts +6 -2
  44. package/dist/middleware/index.js +7 -388
  45. package/dist/middleware/index.js.map +1 -1
  46. package/dist/nextjs/index.d.ts +88 -8
  47. package/dist/nextjs/index.js +138 -62
  48. package/dist/nextjs/index.js.map +1 -1
  49. package/dist/nextjs/server.d.ts +45 -7
  50. package/dist/nextjs/server.js +14 -0
  51. package/dist/nextjs/server.js.map +1 -1
  52. package/dist/ops/index.d.ts +62 -43
  53. package/dist/ops/index.js +638 -97
  54. package/dist/ops/index.js.map +1 -1
  55. package/dist/registry-C2KQCh0m.d.ts +473 -0
  56. package/dist/route/index.d.ts +6 -3
  57. package/dist/route/index.js +97 -28
  58. package/dist/route/index.js.map +1 -1
  59. package/dist/route/types.d.ts +7 -1
  60. package/dist/{router-Qbssr11H.d.ts → route-builder-DRW-MSCJ.d.ts} +52 -127
  61. package/dist/router-4YoIy5fN.d.ts +172 -0
  62. package/dist/{token-manager-BT5EnUAR.d.ts → router-CysBmgv3.d.ts} +81 -127
  63. package/dist/server/index.d.ts +185 -14
  64. package/dist/server/index.js +883 -78
  65. package/dist/server/index.js.map +1 -1
  66. package/dist/surface/index.d.ts +115 -0
  67. package/dist/surface/index.js +1003 -0
  68. package/dist/surface/index.js.map +1 -0
  69. package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
  70. package/dist/{types-D1c57Ko-.d.ts → types-5Rl11_Nb.d.ts} +43 -4
  71. package/dist/{types-ZQODsBft.d.ts → types-B8zAxFsE.d.ts} +2 -1
  72. package/dist/types-CxhPH4s_.d.ts +375 -0
  73. package/dist/{types-2AbaW4Ie.d.ts → types-W4u_S0lF.d.ts} +4 -3
  74. package/docs/file-upload.md +195 -333
  75. package/package.json +20 -9
  76. package/src/cache/README.md +330 -0
  77. package/src/codegen/README.md +634 -0
  78. package/src/config/README.md +333 -0
  79. package/src/contract/README.md +440 -0
  80. package/src/db/README.md +589 -0
  81. package/src/db/manager/README.md +500 -0
  82. package/src/db/schema/README.md +354 -0
  83. package/src/db/transaction/README.md +822 -0
  84. package/src/env/README.md +787 -0
  85. package/src/errors/README.md +429 -0
  86. package/src/event/README.md +789 -0
  87. package/src/job/README.md +613 -0
  88. package/src/logger/README.md +321 -0
  89. package/src/middleware/README.md +634 -0
  90. package/src/nextjs/README.md +688 -0
  91. package/src/route/README.md +763 -0
  92. package/src/security/README.md +100 -0
  93. package/src/server/README.md +704 -0
  94. package/dist/types-9oszaJqp.d.ts +0 -250
@@ -0,0 +1,789 @@
1
+ # @spfn/core/event — Decoupled pub/sub events + SSE streaming
2
+
3
+ In-memory pub/sub event system with TypeBox-typed payloads, optional cache-backed
4
+ multi-instance broadcast, job-queue fan-out, and Server-Sent Events (SSE) streaming to the
5
+ browser. A WebSocket variant shares the same event definitions.
6
+
7
+ ## Import paths
8
+
9
+ There are **four** entry points. Picking the wrong one breaks the build (client code must
10
+ never pull the Hono handler; the handler must never pull the EventSource client).
11
+
12
+ ```typescript
13
+ // Event definitions, router, route-map — isomorphic (define + emit + subscribe)
14
+ import { defineEvent, defineEventRouter, eventRouteMap } from '@spfn/core/event';
15
+
16
+ // Server SSE handler + token manager — SERVER ONLY (Hono, node:crypto)
17
+ import { createSSEHandler, SSETokenManager, CacheTokenStore } from '@spfn/core/event/sse';
18
+
19
+ // Browser SSE client — CLIENT ONLY (EventSource)
20
+ import { createSSEClient, createAuthSSEClient, subscribeToEvents } from '@spfn/core/event/sse/client';
21
+
22
+ // WebSocket router/handler/client — separate surface (see "WebSocket" below)
23
+ import { defineWSRouter } from '@spfn/core/event'; // or '@spfn/core/event/ws'
24
+ import { attachWSHandler } from '@spfn/core/event/ws'; // server
25
+ import { createWSClient } from '@spfn/core/event/ws/client'; // browser
26
+ ```
27
+
28
+ > `createSSEClient` / `createAuthSSEClient` / `subscribeToEvents` live in
29
+ > `@spfn/core/event/sse/**client**` — **not** in `@spfn/core/event/sse`. The bare
30
+ > `@spfn/core/event/sse` is the server handler surface.
31
+
32
+ In practice you rarely call `createSSEHandler` directly — `defineServerConfig().events(router)`
33
+ mounts it for you (see [Server setup](#server-setup-via-defineserverconfig)).
34
+
35
+ ---
36
+
37
+ ## Public API (complete)
38
+
39
+ From `@spfn/core/event`:
40
+
41
+ - `defineEvent(name)` / `defineEvent(name, schema)` — define an event
42
+ - `defineEventRouter(events)` — group events for SSE
43
+ - `defineWSRouter({ events, messages? })` — group events + message handlers for WebSocket
44
+ - `eventRouteMap` — `{ eventsToken: { method: 'POST', path: '/events/token' } }` (merge into RPC proxy)
45
+ - `.contract({ since, deprecatedIn?, removedIn? })` on an event and `.contract({ auth, streamPath? })`
46
+ on an event router — publish the event in `contracts/current.json` (see
47
+ [Contracted events](#contracted-events-for-separately-compiled-clients))
48
+ - Types: `EventDef`, `EventHandler`, `InferEventPayload`, `PubSubCache`, `JobQueueSender`,
49
+ `EventContract`, `EventRouterContract`,
50
+ `EventRouterDef`, `InferEventNames`, `InferEventPayloads`,
51
+ `InferRouterEventPayload` (the two-arg router variant — see note)
52
+
53
+ From `@spfn/core/event/sse`:
54
+
55
+ - `createSSEHandler(router, config?, tokenManager?)` — Hono GET handler
56
+ - `SSETokenManager` (class), `CacheTokenStore` (class)
57
+ - Types: `SSEToken`, `SSETokenStore`, `SSETokenManagerConfig`, `SSEMessage`,
58
+ `SSEHandlerConfig`, `SSEHandlerAuthConfig`, `SSEAuthConfig`, `SSEClientConfig`,
59
+ `SSEEventHandler`, `SSEEventHandlers`, `SSESubscribeOptions`, `SSEConnectionState`,
60
+ `SSEUnsubscribe`
61
+
62
+ From `@spfn/core/event/sse/client`:
63
+
64
+ - `createSSEClient(config?)`, `createAuthSSEClient(config?)`, `subscribeToEvents(events, handlers, config?)`
65
+ - Type: `SSEClient`, `AuthSSEClientConfig`
66
+
67
+ From `@spfn/core/event/ws` / `@spfn/core/event/ws/client`: see [WebSocket](#websocket).
68
+
69
+ > **Two `InferEventPayload`s.** The name `InferEventPayload` exported from
70
+ > `@spfn/core/event` is the **single-arg** one (`InferEventPayload<typeof userCreated>` →
71
+ > payload of one event). The router module's **two-arg** version
72
+ > (`<Router, 'eventName'>`) is re-exported under the alias **`InferRouterEventPayload`** to
73
+ > avoid a clash. Inside the SSE types it is the two-arg form.
74
+ >
75
+ > **No such API.** There is no `event.on(...)`, no `event.publish(...)`, no
76
+ > `createEventBus()`, no `emitSync()`. The model is: `defineEvent` → `.subscribe()` /
77
+ > `.emit()` / `.useCache()`. SSE clients are created with `createSSEClient` /
78
+ > `createAuthSSEClient`, never `new EventSource` directly (that loses typing and token handling).
79
+
80
+ ---
81
+
82
+ ## Quick Start
83
+
84
+ ```typescript
85
+ import { defineEvent } from '@spfn/core/event';
86
+ import { Type } from '@sinclair/typebox';
87
+
88
+ // 1. Define an event with a typed payload
89
+ export const userCreated = defineEvent('user.created', Type.Object({
90
+ userId: Type.String(),
91
+ email: Type.String(),
92
+ }));
93
+
94
+ // 2. Subscribe (in-memory) — returns an unsubscribe function
95
+ const unsubscribe = userCreated.subscribe((payload) =>
96
+ {
97
+ // payload: { userId: string; email: string }
98
+ console.log('User created:', payload.userId);
99
+ });
100
+
101
+ // 3. Emit — awaits all handlers
102
+ await userCreated.emit({ userId: '123', email: 'user@example.com' });
103
+
104
+ // 4. Cleanup
105
+ unsubscribe();
106
+ ```
107
+
108
+ ### Event without payload
109
+
110
+ ```typescript
111
+ export const serverStarted = defineEvent('server.started'); // EventDef<void>
112
+
113
+ serverStarted.subscribe(() => console.log('started'));
114
+ await serverStarted.emit(); // emit() takes no argument when there is no schema
115
+ ```
116
+
117
+ `emit` is typed off the payload: `() => Promise<void>` for void events, `(payload) =>
118
+ Promise<void>` otherwise. Handlers run via `Promise.allSettled` — see
119
+ [error isolation](#error-isolation).
120
+
121
+ ---
122
+
123
+ ## EventDef methods
124
+
125
+ `defineEvent` returns an `EventDef<TPayload>`:
126
+
127
+ | Member | Signature | Notes |
128
+ |--------|-----------|-------|
129
+ | `name` | `readonly string` | event name (e.g. `'user.created'`) |
130
+ | `schema` | `readonly TSchema?` | the TypeBox schema, if provided |
131
+ | `subscribe(handler)` | `(payload) => void \| Promise<void>` ⇒ returns `() => void` | in-memory; returns unsubscribe |
132
+ | `unsubscribeAll()` | `() => void` | drop all in-memory handlers |
133
+ | `emit(payload?)` | `Promise<void>` | fan-out to handlers (or cache) + job queues |
134
+ | `useCache(cache)` | `(PubSubCache) => Promise<EventDef>` | enable cross-instance broadcast (must `await`) |
135
+ | `contract(contract)` | `(EventContract) => EventDef` | publish in the contract; returns the same event; a second call throws |
136
+
137
+ > `_registerJobQueue` and `_payload` exist on the interface but are **internal** —
138
+ > `_registerJobQueue` is called by `@spfn/core/job` when a job does `.on(event)`; `_payload`
139
+ > is a type-inference carrier (always `undefined` at runtime). Don't call them.
140
+
141
+ ---
142
+
143
+ ## Server setup (via `defineServerConfig`)
144
+
145
+ To stream events to the browser, group them with `defineEventRouter` and register the
146
+ router on the server. `.events()` mounts the SSE handler — you don't call `createSSEHandler`
147
+ yourself.
148
+
149
+ ```typescript
150
+ // src/server/events.ts
151
+ import { defineEvent, defineEventRouter } from '@spfn/core/event';
152
+ import { Type } from '@sinclair/typebox';
153
+
154
+ export const userCreated = defineEvent('user.created', Type.Object({
155
+ userId: Type.String(),
156
+ email: Type.String(),
157
+ }));
158
+ export const orderPlaced = defineEvent('order.placed', Type.Object({
159
+ orderId: Type.String(),
160
+ userId: Type.String(),
161
+ amount: Type.Number(),
162
+ }));
163
+
164
+ export const eventRouter = defineEventRouter({ userCreated, orderPlaced });
165
+ export type EventRouter = typeof eventRouter;
166
+ ```
167
+
168
+ ```typescript
169
+ // server.config.ts
170
+ import { defineServerConfig } from '@spfn/core/server';
171
+ import { eventRouter } from './server/events';
172
+
173
+ export default defineServerConfig()
174
+ .routes(appRouter)
175
+ .jobs(jobRouter)
176
+ .events(eventRouter) // → GET /events/stream
177
+ .build();
178
+
179
+ // Custom path + ping interval:
180
+ .events(eventRouter, {
181
+ path: '/sse', // default: '/events/stream'
182
+ pingInterval: 10000, // keep-alive interval, ms (default: 10000 — under proxy idle timeouts)
183
+ })
184
+ ```
185
+
186
+ `.events(router, config)` accepts `Omit<SSEHandlerConfig, 'auth'> & { path?, auth?:
187
+ SSEAuthConfig<TRouter> }`. The `auth` field is the **generic** `SSEAuthConfig<TRouter>` so
188
+ `authorize`/`filter` get full event-name and payload inference from your router.
189
+
190
+ `config` also carries the cross-pod transport knobs (see
191
+ [Multi-instance broadcast](#multi-instance-broadcast-cross-pod-fan-out)):
192
+
193
+ | Option | Type | Default | Description |
194
+ |--------|------|---------|-------------|
195
+ | `multiInstance` | `boolean` | `true` | auto-wire Redis pub/sub when a cache is configured; `false` forces in-process |
196
+ | `channelPrefix` | `string` | env `SPFN_SSE_CHANNEL_PREFIX` or `spfn:sse:` | pub/sub channel prefix; set distinct prefixes to isolate apps sharing one Redis |
197
+
198
+ > `.websockets(router, config)` accepts the same `multiInstance` / `channelPrefix` knobs.
199
+ > Events shared by both routers are wired once.
200
+
201
+ > **Backpressure (`maxQueue`, default 1000).** Each connection's outbound frames go through a
202
+ > single drain loop that `await`s every write, so a slow client applies real backpressure
203
+ > instead of letting frames buffer unboundedly in memory. If the per-connection queue exceeds
204
+ > `maxQueue`, the connection is **closed** (the client reconnects) rather than dropping frames —
205
+ > dropping a chunk would corrupt an ordered token stream. Raise `maxQueue` for very bursty
206
+ > producers; lower it to cap memory more aggressively.
207
+
208
+ > `SSEHandlerConfig` also declares a `headers` field, but the current handler only reads
209
+ > `pingInterval`, `auth`, and `maxQueue`. Setting custom `headers` here is a no-op — set
210
+ > response headers in middleware instead.
211
+
212
+ ### Contracted events (for separately compiled clients)
213
+
214
+ A web client takes its event names and payload types from `typeof eventRouter`. A native app
215
+ cannot, so it hand-writes both — and they break at runtime. Declaring `.contract()` puts the
216
+ events into `contracts/current.json`, where the contract gate refuses a breaking change at build:
217
+
218
+ ```typescript
219
+ export const sessionActivity = defineEvent('session.activity', Type.Object({
220
+ sessionId: Type.String(),
221
+ })).contract({ since: '1.0.0' }); // deprecatedIn?, removedIn? as on routes
222
+
223
+ export const eventRouter = defineEventRouter({ sessionActivity, userCreated })
224
+ .contract({ auth: 'tokenExchange' }); // streamPath?: string, default '/events/stream'
225
+ ```
226
+
227
+ - Only events carrying `.contract()` are published (`userCreated` above is not). The published
228
+ name is the **router key** (`sessionActivity`), the name on the wire — not `'session.activity'`.
229
+ - `.contract()` on an event returns the same event and may be called once; a second call throws.
230
+ - The router's `auth` is required: `'tokenExchange'` when `.events()` enables `auth`, else
231
+ `'none'`. A `streamPath` not starting with `/` throws where it is declared.
232
+ - Point the contract generator at the file with `eventRouterPath` — see
233
+ [the contract README](../contract/README.md#events).
234
+
235
+ **Boot check.** When the event router declares `.contract()`, `createServer` compares it with
236
+ `.events()` before anything is served and refuses to start on a mismatch: the contract's
237
+ `streamPath` (default `/events/stream`) must equal `.events({ path })` (same default), and
238
+ `auth: 'tokenExchange'` must match `auth.enabled: true`. A router without `.contract()` is not
239
+ checked.
240
+
241
+ ```
242
+ SSE event router contract declares streamPath /sse but .events() registers /events/stream; the contract would describe a path the server does not serve
243
+ ```
244
+
245
+ An `authorize` hook narrowing which events a connection receives is per-connection policy and
246
+ not part of the contract.
247
+
248
+ ---
249
+
250
+ ## SSE authentication (Token Exchange)
251
+
252
+ The browser `EventSource` API cannot send custom headers, so a `Authorization: Bearer` JWT
253
+ can't ride along on the stream request. SPFN uses a **token exchange**: an authenticated
254
+ `POST /events/token` mints a one-time, short-TTL token; the stream request carries it as
255
+ `?token=...`.
256
+
257
+ ```
258
+ Client Server
259
+ │ POST /events/token (Bearer JWT) │ ← protected by config.middlewares (e.g. authenticate)
260
+ │ ───────────────────────────────► │ getSubject(c) → issue one-time token
261
+ │ ◄───────────────────────────── │ { token: "..." } (default TTL 30s)
262
+ │ GET /events/stream?token=…&events=…
263
+ │ ───────────────────────────────► │ verify+consume token (one-time)
264
+ │ │ → authorize(subject, events)
265
+ │ ◄═══════════════════════════════ │ SSE stream opens
266
+ │ event: <name> / data: {…} │ ← filter(subject, payload) per emission
267
+ ```
268
+
269
+ Enable it with `auth: { enabled: true }`:
270
+
271
+ ```typescript
272
+ import { defineServerConfig } from '@spfn/core/server';
273
+ import { authenticate } from '@spfn/auth/server';
274
+
275
+ export default defineServerConfig()
276
+ .middlewares([authenticate]) // protects POST /events/token
277
+ .routes(appRouter)
278
+ .events(eventRouter, {
279
+ auth: { enabled: true },
280
+ })
281
+ .build();
282
+ // → POST /events/token (mints token; subject = c.get('auth').userId by default)
283
+ // → GET /events/stream?token=…&events=…
284
+ ```
285
+
286
+ The token endpoint path is **derived** from the stream path: `/events/stream` →
287
+ `/events/token`, `/sse` → `/token`. It is registered with your app's named middleware
288
+ applied — both the server-level `.middlewares([...])` and the router's `.use([...])` — so
289
+ whatever authenticates your routes also authenticates token issuance.
290
+
291
+ ### Token store (multi-instance)
292
+
293
+ One-time tokens are stored. With `auth.enabled` and no explicit `store`, the server
294
+ auto-detects a cache at startup via `getCache()`:
295
+
296
+ | Environment | Store | How |
297
+ |-------------|-------|-----|
298
+ | No cache (`CACHE_URL` unset) | `InMemoryTokenStore` (Map) | automatic fallback |
299
+ | Cache connected | `CacheTokenStore` (Redis/Valkey, `SET EX` + `GETDEL`, prefix `sse:token:`) | auto-detected |
300
+ | Custom | your `SSETokenStore` impl | pass `auth.store` |
301
+
302
+ Manual / custom store:
303
+
304
+ ```typescript
305
+ import { CacheTokenStore } from '@spfn/core/event/sse';
306
+ import { getCache } from '@spfn/core/cache';
307
+
308
+ .events(eventRouter, {
309
+ auth: { enabled: true, store: new CacheTokenStore(getCache()!) },
310
+ })
311
+ ```
312
+
313
+ ```typescript
314
+ import type { SSETokenStore, SSEToken } from '@spfn/core/event/sse';
315
+
316
+ class DynamoTokenStore implements SSETokenStore
317
+ {
318
+ async set(token: string, data: SSEToken): Promise<void> { /* ... */ }
319
+ async consume(token: string): Promise<SSEToken | null> { /* one-time get+delete */ }
320
+ async cleanup(): Promise<void> { /* remove expired */ }
321
+ }
322
+ ```
323
+
324
+ ### Sharing a token manager with `@spfn/auth`
325
+
326
+ If you already run `@spfn/auth`'s one-time-token system, hand SSE the **same** manager via
327
+ `tokenManager` instead of letting it create its own — both SSE and direct API calls then
328
+ draw from one token pool. Use a **lazy resolver** because `getOneTimeTokenManager()` only
329
+ exists after the auth lifecycle's `afterInfrastructure` runs:
330
+
331
+ ```typescript
332
+ import { createAuthLifecycle, getOneTimeTokenManager } from '@spfn/auth/server';
333
+
334
+ export default defineServerConfig()
335
+ .lifecycle(createAuthLifecycle())
336
+ .events(eventRouter, {
337
+ auth: {
338
+ enabled: true,
339
+ tokenManager: () => getOneTimeTokenManager(), // resolved at server start
340
+ },
341
+ })
342
+ .build();
343
+ ```
344
+
345
+ When `tokenManager` is provided, `store`/`tokenTtl` are ignored (the external manager owns them).
346
+
347
+ ### Authorization hooks
348
+
349
+ Both hooks get full type inference from the router (no casting).
350
+
351
+ `authorize` — runs **once on connect**, decides which events the subject may subscribe to.
352
+ Return the allowed subset; an empty array ⇒ `403`.
353
+
354
+ ```typescript
355
+ .events(eventRouter, {
356
+ auth: {
357
+ enabled: true,
358
+ authorize: async (subject, events) =>
359
+ {
360
+ // events: ('userCreated' | 'orderPlaced')[] — inferred
361
+ const user = await usersRepository.findById(subject);
362
+ return user.role === 'admin' ? events : events.filter(e => !e.startsWith('admin.'));
363
+ },
364
+ },
365
+ })
366
+ ```
367
+
368
+ `filter` — runs **per emission**, decides whether a given payload goes to this subject.
369
+ Return `false` to skip. Payload is typed per event.
370
+
371
+ ```typescript
372
+ .events(eventRouter, {
373
+ auth: {
374
+ enabled: true,
375
+ filter: {
376
+ // payload: { orderId; userId; amount } — inferred
377
+ orderPlaced: (subject, payload) => payload.userId === subject,
378
+ // userCreated: omitted → delivered to all authorized subscribers
379
+ },
380
+ },
381
+ })
382
+ ```
383
+
384
+ ### SSE auth config (`SSEAuthConfig<TRouter>`)
385
+
386
+ | Option | Type | Default | Description |
387
+ |--------|------|---------|-------------|
388
+ | `enabled` | `boolean` | `false` | turn on token auth |
389
+ | `tokenTtl` | `number` | `30000` | token TTL (ms) |
390
+ | `store` | `SSETokenStore` | auto (Cache → InMemory) | token storage backend |
391
+ | `tokenManager` | `SSETokenManager \| () => SSETokenManager` | — | reuse an external manager (lazy resolver recommended); overrides `store`/`tokenTtl` |
392
+ | `getSubject` | `(c: Context) => string \| null` | `c.get('auth')?.userId ?? null` | extract subject on the token endpoint |
393
+ | `authorize` | `(subject, events[]) => events[] \| Promise<…>` | — | subscription authorization (once on connect) |
394
+ | `filter` | `{ [event]?: (subject, payload) => boolean }` | — | per-event payload filter |
395
+
396
+ ---
397
+
398
+ ## Browser client
399
+
400
+ ### `createSSEClient(config?)` — no auth
401
+
402
+ ```typescript
403
+ import { createSSEClient } from '@spfn/core/event/sse/client';
404
+ import type { EventRouter } from '@/server/events';
405
+
406
+ // Defaults: NEXT_PUBLIC_SPFN_API_URL (or http://localhost:8790) + /events/stream
407
+ const client = createSSEClient<EventRouter>();
408
+
409
+ const unsubscribe = client.subscribe({
410
+ events: ['userCreated', 'orderPlaced'], // names inferred from EventRouter
411
+ handlers: {
412
+ userCreated: (payload) => console.log('user', payload.userId),
413
+ orderPlaced: (payload) => console.log('order', payload.orderId),
414
+ },
415
+ onOpen: () => console.log('connected'),
416
+ onError: (err) => console.error(err),
417
+ onReconnect: (attempt) => console.log('reconnect', attempt),
418
+ onClose: () => console.log('closed for good'),
419
+ });
420
+
421
+ client.getState(); // 'connecting' | 'open' | 'closed' | 'error'
422
+ client.close(); // tear down the active connection
423
+ unsubscribe(); // same as close() for that subscription
424
+ ```
425
+
426
+ `SSEClientConfig`:
427
+
428
+ | Option | Type | Default | Description |
429
+ |--------|------|---------|-------------|
430
+ | `host` | `string` | `NEXT_PUBLIC_SPFN_API_URL` or `http://localhost:8790` | backend host |
431
+ | `pathname` | `string` | `/events/stream` | SSE endpoint path |
432
+ | `url` | `string` | — | **deprecated**; full URL overriding host+pathname |
433
+ | `reconnect` | `boolean` | `true` | auto-reconnect on drop |
434
+ | `reconnectDelay` | `number` | `3000` | delay between attempts (ms) |
435
+ | `maxReconnectAttempts` | `number` | `0` | `0` = infinite |
436
+ | `withCredentials` | `boolean` | `false` | send cookies with the EventSource request |
437
+ | `acquireToken` | `() => Promise<string>` | — | mint a token before each (re)connect; appended as `?token=…` |
438
+
439
+ ### `createAuthSSEClient(config?)` — with auth (recommended)
440
+
441
+ Wraps `createSSEClient` and wires `acquireToken` to fetch a one-time token from the RPC
442
+ proxy automatically on every (re)connect.
443
+
444
+ ```typescript
445
+ import { createAuthSSEClient } from '@spfn/core/event/sse/client';
446
+ import type { EventRouter } from '@/server/events';
447
+
448
+ const client = createAuthSSEClient<EventRouter>(); // POSTs /api/rpc/eventsToken under the hood
449
+
450
+ client.subscribe({
451
+ events: ['userCreated'],
452
+ handlers: { userCreated: (p) => console.log(p) },
453
+ });
454
+ ```
455
+
456
+ `AuthSSEClientConfig` = `Omit<SSEClientConfig, 'acquireToken'>` plus `rpcBaseUrl` (default
457
+ `/api/rpc`). It requires `eventRouteMap` merged into your RPC proxy so `eventsToken`
458
+ resolves to `POST /events/token`. That token request is a cookie-authenticated POST
459
+ through the proxy, so `@spfn/auth`'s CSRF rule applies to it: the client mirrors the
460
+ readable `spfn_csrf` cookie family into the `x-spfn-csrf` header, the way the api
461
+ client does, so the stream opens under `SPFN_AUTH_CSRF=enforce` too. A custom
462
+ `acquireToken` passed to `createSSEClient` has to send that header itself
463
+ (`csrfHeaderValue` and `documentCookieEntries` from `@spfn/core/nextjs` build it).
464
+
465
+ ```typescript
466
+ // app/api/rpc/[routeName]/route.ts
467
+ import '@spfn/auth/nextjs/api';
468
+ import { createRpcProxy } from '@spfn/core/nextjs/server';
469
+ import { eventRouteMap } from '@spfn/core/event';
470
+ import { routeMap } from '@/generated/route-map';
471
+
472
+ export const { GET, POST } = createRpcProxy({
473
+ routeMap: { ...routeMap, ...eventRouteMap },
474
+ });
475
+ ```
476
+
477
+ ### `subscribeToEvents(events, handlers, config?)` — one-shot helper
478
+
479
+ ```typescript
480
+ import { subscribeToEvents } from '@spfn/core/event/sse/client';
481
+ import type { EventRouter } from '@/server/events';
482
+
483
+ const unsubscribe = subscribeToEvents<EventRouter>(
484
+ ['userCreated'],
485
+ { userCreated: (payload) => console.log(payload) },
486
+ { host: 'https://api.example.com' }, // optional SSEClientConfig
487
+ );
488
+ ```
489
+
490
+ It creates a throwaway client and subscribes once. For auth, prefer `createAuthSSEClient`
491
+ (this helper takes a plain `SSEClientConfig`, so you'd have to wire `acquireToken` yourself).
492
+
493
+ ---
494
+
495
+ ## Multi-instance broadcast (cross-pod fan-out)
496
+
497
+ By default `emit` only reaches handlers in the **same process**. Across multiple pods, an
498
+ `emit` on the pod that handles the chat POST must still reach the SSE stream pinned to a
499
+ **different** pod — otherwise the stream silently stalls.
500
+
501
+ **This is automatic.** When a cache is configured (`CACHE_URL` / `getCache()` returns a
502
+ client), `.events(router)` and `.websockets(router)` wire every event to a Redis/Valkey
503
+ pub/sub transport at startup. No code change in your app — register the router as usual and
504
+ multi-pod fan-out just works. Without a cache it is a no-op and events stay in-process, so a
505
+ single pod (or local dev) behaves exactly as before.
506
+
507
+ ```typescript
508
+ export default defineServerConfig()
509
+ .events(eventRouter) // CACHE_URL set → cross-pod fan-out; unset → in-process
510
+ .build();
511
+
512
+ // Force in-process even when a cache is present, or set an isolation prefix:
513
+ .events(eventRouter, {
514
+ multiInstance: false, // default true
515
+ channelPrefix: 'my-app:', // default: env SPFN_SSE_CHANNEL_PREFIX, else 'spfn:sse:'
516
+ })
517
+ ```
518
+
519
+ Mechanics (from `event.ts`): once a cache is wired, `emit` calls `cache.publish(name,
520
+ payload)` **instead of** triggering local handlers directly — local handlers (including the
521
+ SSE stream on the same pod) fire when the message comes back through the pub/sub `subscribe`
522
+ callback. Auth `filter` still runs per-subscriber on the **receiving** pod's SSE handler —
523
+ the transport only fans out by event name. Job queues receive the payload on every emit
524
+ regardless of cache.
525
+
526
+ > **Process-global.** `multiInstance` and `channelPrefix` are resolved **once per process**
527
+ > (first `.events()`/`.websockets()` wiring wins); a second router can't change them. That's
528
+ > correct because one process serves one app. An event shared by both routers is wired once.
529
+ >
530
+ > **Payloads must be JSON-serializable** when fan-out is on (they cross Redis as JSON). A
531
+ > `Date` arrives as an ISO string; a void event arrives as `null` on remote pods (vs
532
+ > `undefined` in-process). `bigint`/circular payloads can't serialize — see degrade below.
533
+ >
534
+ > **Ordering needs sequential `await` _and_ healthy publishes.** Per-channel order is preserved
535
+ > end-to-end only if the producer awaits each emit in turn (`for (const c of chunks) await
536
+ > event.emit(c)`). Fire-and-forget (`chunks.forEach(c => event.emit(c))`) races the publishes
537
+ > and can reorder a token stream. Also: a mid-stream publish **failure** delivers its event via
538
+ > the synchronous local fallback, which can land ahead of an earlier successful emit whose echo
539
+ > is still in flight — so a Redis blip mid-stream may reorder relative to in-flight emits. The
540
+ > transport adds no sequence number; if strict order matters across failures, carry one in the
541
+ > payload and reorder on the client.
542
+ >
543
+ > **Channel isolation & receive-side trust.** The default prefix `spfn:sse:` is shared by every
544
+ > SPFN app, so apps sharing **one** Redis with a colliding event name would cross-talk. Set
545
+ > `channelPrefix` / `SPFN_SSE_CHANNEL_PREFIX` per app (the server logs a WARN at startup if
546
+ > you're on the default); separate `CACHE_URL`s per app are isolated already. Note the receiving
547
+ > pod does **not** re-validate a payload against the event schema before delivering it to
548
+ > `filter`/handlers — anything with Redis access can publish to a channel, so treat Redis access
549
+ > as a trust boundary (same level as the one-time-token store).
550
+
551
+ **Degrade**: SSE is lossy (at-most-once). If a publish can't reach Redis (a blip) or the
552
+ payload can't serialize, the event is **still delivered to this pod's own subscribers**
553
+ (local fallback) and logged; only **remote** pods miss it. The same local fallback covers the
554
+ asymmetric case where the publish succeeds but **this pod's subscriber socket is down** (its
555
+ echo — the only path to same-pod streams — wouldn't arrive). A sustained publish outage trips
556
+ a short circuit breaker so emits fast-path to local instead of each paying the publish timeout.
557
+ A publish never crashes `emit` or kills a stream. ioredis auto-reconnects and re-subscribes
558
+ (autoResubscribe, default true). A per-event SUBSCRIBE failure at
559
+ startup degrades that event to in-process (logged) rather than aborting boot. Same-pod
560
+ delivery costs one Redis round-trip — for a single-replica deploy with a cache, prefer
561
+ `multiInstance: false` to skip it.
562
+
563
+ ### Manual `useCache` (advanced)
564
+
565
+ You rarely need this — the server wires it for you. `EventDef.useCache(cache)` is the
566
+ underlying primitive if you drive events outside `defineServerConfig` (e.g. a worker). The
567
+ `PubSubCache` shape it expects:
568
+
569
+ ```typescript
570
+ import { getCache } from '@spfn/core/cache';
571
+
572
+ const cache = getCache();
573
+ if (cache)
574
+ {
575
+ await userCreated.useCache({
576
+ publish: async (channel, message) =>
577
+ {
578
+ await cache.publish(channel, JSON.stringify(message));
579
+ },
580
+ subscribe: async (channel, handler) =>
581
+ {
582
+ const sub = cache.duplicate();
583
+ await sub.subscribe(channel);
584
+ sub.on('message', (ch, msg) =>
585
+ {
586
+ if (ch === channel) handler(JSON.parse(msg));
587
+ });
588
+ },
589
+ });
590
+ }
591
+
592
+ await userCreated.emit({ userId: '123', email: 'a@b.com' }); // broadcasts to all instances
593
+ ```
594
+
595
+ **`await` `useCache` before emitting** — it must finish subscribing first. A direct second
596
+ call on the same `EventDef` logs `Cache already configured` and is ignored. (Separately, the
597
+ server's auto-wiring dedups by **event name** across the SSE and WS routers — tracked in the
598
+ transport, so a shared event's `useCache` is invoked only once and the warn never fires.)
599
+
600
+ ---
601
+
602
+ ## Job integration
603
+
604
+ A job subscribing to an event with `.on(event)` registers itself on the event's job queue;
605
+ the event's `emit` then enqueues the payload to every such queue (in addition to in-memory
606
+ handlers / cache broadcast).
607
+
608
+ ```typescript
609
+ import { defineEvent } from '@spfn/core/event';
610
+ import { job, defineJobRouter } from '@spfn/core/job';
611
+ import { Type } from '@sinclair/typebox';
612
+
613
+ export const orderPlaced = defineEvent('order.placed', Type.Object({
614
+ orderId: Type.String(),
615
+ userId: Type.String(),
616
+ }));
617
+
618
+ export const sendConfirmation = job('send-order-confirmation')
619
+ .on(orderPlaced) // payload type inferred from the event
620
+ .handler(async (payload) => emailService.send(payload.orderId));
621
+
622
+ export const updateInventory = job('update-inventory')
623
+ .on(orderPlaced)
624
+ .handler(async (payload) => inventoryService.reserve(payload.orderId));
625
+
626
+ export const jobRouter = defineJobRouter({ sendConfirmation, updateInventory });
627
+
628
+ await orderPlaced.emit({ orderId: 'ord-1', userId: 'u-1' }); // both jobs enqueue
629
+ ```
630
+
631
+ One emit fans out to in-memory handlers, the SSE stream, and every subscribed job queue —
632
+ fully decoupled.
633
+
634
+ ---
635
+
636
+ ## Pitfalls & anti-patterns
637
+
638
+ - **Wrong import depth for the client.** `createSSEClient` / `createAuthSSEClient` /
639
+ `subscribeToEvents` are in `@spfn/core/event/sse/**client**`, not `@spfn/core/event/sse`.
640
+ The bare `/sse` is the server (Hono) surface; importing it into the browser pulls server code.
641
+ - **`useCache` not awaited before `emit`.** `useCache` is async (it subscribes to the
642
+ channel). Emitting before it resolves can drop the very first cross-instance events. Always
643
+ `await event.useCache(...)` first.
644
+ - **Calling `useCache` twice.** A second call logs `Cache already configured for event: …`
645
+ and is ignored. Configure the cache once per event instance (typically at startup).
646
+ - **Emitting before subscribing (in-memory).** `subscribe` registers a handler set; an
647
+ `emit` that runs before any `subscribe` simply has no handlers. Register handlers / jobs at
648
+ startup, not lazily after the first emit.
649
+ - **`auth.headers`/`SSEHandlerConfig.headers` does nothing.** The handler reads only
650
+ `pingInterval`, `auth`, and `maxQueue`. Don't expect custom response headers from config.
651
+ - **A slow client is dropped, not buffered forever.** Writes are bounded (`maxQueue`, default
652
+ 1000): if a client can't keep up with a fast producer, its connection is closed instead of
653
+ growing memory without limit. Expect occasional reconnects under heavy streaming to slow
654
+ clients; that's the backpressure working, not a bug.
655
+ - **Don't `new EventSource(...)` by hand.** You lose type inference, the `connected`/`ping`
656
+ control-event handling, one-time-token reconnect, and StrictMode-safe teardown that the
657
+ client implements. Use `createSSEClient` / `createAuthSSEClient`.
658
+ - **Token-auth + browser auto-retry.** A one-time token is consumed on first use, so the
659
+ browser's built-in EventSource retry would reconnect with a dead token. The client handles
660
+ this: on error with `acquireToken` set, it closes and reconnects through its own path to
661
+ mint a fresh token. Don't disable `reconnect` expecting native retry to work with auth.
662
+ - **Stream-time errors aren't subscription errors.** `authorize` returning `[]` ⇒ `403` at
663
+ connect; invalid event names ⇒ `400`; missing/expired token ⇒ `401`. `onError` on the
664
+ client fires for transport-level drops, not for these HTTP rejections — check the network
665
+ response when a connection never opens.
666
+ - **Subject default.** Without a custom `getSubject`, the token endpoint reads
667
+ `c.get('auth')?.userId`. If your auth middleware stores the user elsewhere, supply
668
+ `getSubject` or token issuance returns `401`.
669
+ - **One subscription per client.** `createSSEClient(...).subscribe(...)` supersedes any
670
+ previous subscription on that client (the prior connection is closed). For independent
671
+ streams, create separate clients.
672
+ - **`SSETokenManager` keeps a cleanup timer.** It runs an `unref`'d interval; the in-memory
673
+ store needs it to expire tokens. If you construct a manager manually (outside the server),
674
+ call `destroy()` on shutdown. The Redis-backed `CacheTokenStore` relies on TTL and its
675
+ `cleanup()` is a no-op.
676
+
677
+ ---
678
+
679
+ ## WebSocket (bidirectional variant)
680
+
681
+ For client→server messages in addition to server→client push, use a WS router. It reuses
682
+ the same `defineEvent` definitions and shares the SSE token manager / auth model.
683
+
684
+ ```typescript
685
+ // server
686
+ import { defineWSRouter } from '@spfn/core/event'; // also re-exported from /event/ws
687
+ import { attachWSHandler } from '@spfn/core/event/ws';
688
+
689
+ export const wsRouter = defineWSRouter({
690
+ events: { userUpdated, notification }, // server → client push
691
+ messages: { // client → server handlers
692
+ ping: ({ ws }) => ws.send('pong', {}),
693
+ 'chat.send': ({ payload, subject }) => handleChat(payload, subject),
694
+ },
695
+ });
696
+ export type WSRouter = typeof wsRouter;
697
+
698
+ // usually mounted via defineServerConfig().websockets(wsRouter)
699
+ ```
700
+
701
+ ```typescript
702
+ // browser
703
+ import { createWSClient } from '@spfn/core/event/ws/client';
704
+ import type { WSRouter } from '@/server/ws';
705
+
706
+ const client = createWSClient<WSRouter>();
707
+ client.subscribe({ events: ['userUpdated'], handlers: { userUpdated: (p) => {} } });
708
+ client.send('ping', {});
709
+ ```
710
+
711
+ `WSRouterDef` extends `EventRouterDef` with a `messages` map. Message handlers receive
712
+ `{ payload, subject?, ws }` (`WSMessageContext`). See `@spfn/core/event/ws` exports for
713
+ `WSHandlerConfig`, `WSAuthConfig`, `WSClientConfig`, etc.
714
+
715
+ ### Resource limits & backpressure (`WSHandlerConfig`)
716
+
717
+ The WS server is hardened like the SSE path — a slow or dead client cannot exhaust memory
718
+ or connection slots:
719
+
720
+ | Option | Default | Effect |
721
+ |---|---|---|
722
+ | `maxPayload` | `1048576` (1 MiB) | Max inbound frame size; larger frames are rejected by `ws` before they are buffered/parsed. |
723
+ | `maxBufferedBytes` | `1048576` (1 MiB) | Backpressure cap — if a connection's outbound buffer (`bufferedAmount`) exceeds this, the connection is closed with `1013` instead of buffering more (no OOM from a slow consumer). The client reconnects and re-subscribes. |
724
+ | `maxConnections` | `10000` | Global concurrent-connection cap; connections beyond it are rejected with `1013`. |
725
+ | `maxConnectionsPerSubject` | `0` (unlimited) | Per-authenticated-subject connection cap. |
726
+
727
+ Keep-alive also tracks **pongs**: a socket that doesn't answer a ping by the next
728
+ `pingInterval` tick is `terminate()`d, so half-open connections (sleeping device, NAT drop)
729
+ are reaped instead of lingering with their subscriptions and buffers.
730
+
731
+ ---
732
+
733
+ ## Types reference
734
+
735
+ ```typescript
736
+ interface EventDef<TPayload = void> {
737
+ readonly name: string;
738
+ readonly schema?: TSchema;
739
+ subscribe: (handler: EventHandler<TPayload>) => () => void;
740
+ unsubscribeAll: () => void;
741
+ emit: TPayload extends void ? () => Promise<void> : (payload: TPayload) => Promise<void>;
742
+ useCache: (cache: PubSubCache) => Promise<EventDef<TPayload>>;
743
+ readonly _contract?: EventContract;
744
+ contract: (contract: EventContract) => EventDef<TPayload>;
745
+ }
746
+
747
+ interface EventContract { since: string; deprecatedIn?: string; removedIn?: string; }
748
+
749
+ type EventHandler<TPayload> = (payload: TPayload) => void | Promise<void>;
750
+ type InferEventPayload<TEvent> = TEvent extends EventDef<infer P> ? P : never; // single-arg
751
+
752
+ interface PubSubCache {
753
+ publish(channel: string, message: unknown): Promise<void>;
754
+ subscribe(channel: string, handler: (message: unknown) => void | Promise<void>): Promise<void>;
755
+ }
756
+ type JobQueueSender = (queueName: string, payload: unknown) => Promise<void>;
757
+
758
+ interface EventRouterDef<TEvents> {
759
+ readonly events: TEvents;
760
+ readonly eventNames: (keyof TEvents)[];
761
+ readonly _types: { [K in keyof TEvents]: TEvents[K]['_payload'] };
762
+ readonly _contract?: EventRouterContract;
763
+ contract: (contract: EventRouterContract) => EventRouterDef<TEvents>;
764
+ }
765
+ interface EventRouterContract { auth: 'none' | 'tokenExchange'; streamPath?: string; }
766
+ type InferEventNames<T> = /* keyof router events as string union */;
767
+ type InferEventPayloads<T> = T['_types'];
768
+ // router two-arg payload, exported as `InferRouterEventPayload`:
769
+ type InferRouterEventPayload<T, K> = T['_types'][K];
770
+
771
+ interface SSEMessage<TEvent extends string = string, TPayload = unknown> {
772
+ event: TEvent; data: TPayload; id?: string;
773
+ }
774
+ type SSEConnectionState = 'connecting' | 'open' | 'closed' | 'error';
775
+
776
+ interface SSEToken { token: string; subject: string; expiresAt: number; }
777
+ interface SSETokenStore {
778
+ set(token: string, data: SSEToken): Promise<void>;
779
+ consume(token: string): Promise<SSEToken | null>; // one-time get+delete
780
+ cleanup(): Promise<void>;
781
+ }
782
+ ```
783
+
784
+ ## Related
785
+
786
+ - [@spfn/core/job](../job/README.md) — background jobs that subscribe to events via `.on()`
787
+ - [@spfn/core/cache](../cache/README.md) — Redis/Valkey backing for multi-instance broadcast + token store
788
+ - [@spfn/core/server](../server/README.md) — `defineServerConfig().events()` / `.websockets()`
789
+ - [MDN: Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)