@spfn/core 0.3.0-beta.3 → 0.3.0-beta.30

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