@spfn/core 0.3.0-beta.18 → 0.3.0-beta.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * SSE Token Manager
3
+ *
4
+ * Auth-agnostic token issuance and verification for SSE connections.
5
+ * Issues one-time-use tokens with TTL for Token Exchange pattern.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * const manager = new SSETokenManager({ ttl: 30000 });
10
+ *
11
+ * // Issue token for authenticated user
12
+ * const token = await manager.issue('user-123');
13
+ *
14
+ * // Verify and consume token (one-time use)
15
+ * const subject = await manager.verify(token); // 'user-123'
16
+ * const again = await manager.verify(token); // null (already consumed)
17
+ *
18
+ * // Cleanup on shutdown
19
+ * manager.destroy();
20
+ * ```
21
+ */
22
+ /**
23
+ * Minimal cache client interface (compatible with ioredis Redis | Cluster)
24
+ */
25
+ type CacheClient = {
26
+ set(key: string, value: string, ...args: any[]): Promise<any>;
27
+ getdel?(key: string): Promise<string | null>;
28
+ get(key: string): Promise<string | null>;
29
+ del(...keys: string[]): Promise<number>;
30
+ };
31
+ /**
32
+ * Stored SSE token data
33
+ */
34
+ interface SSEToken {
35
+ subject: string;
36
+ expiresAt: number;
37
+ }
38
+ /**
39
+ * Token storage interface
40
+ *
41
+ * Implement this for custom storage backends (e.g., Redis for multi-instance).
42
+ */
43
+ interface SSETokenStore {
44
+ /** Store a token */
45
+ set(token: string, data: SSEToken): Promise<void>;
46
+ /** Get and delete a token (one-time use) */
47
+ consume(token: string): Promise<SSEToken | null>;
48
+ /** Remove expired tokens */
49
+ cleanup(): Promise<void>;
50
+ }
51
+ /**
52
+ * SSETokenManager configuration
53
+ */
54
+ interface SSETokenManagerConfig {
55
+ /**
56
+ * Token time-to-live in milliseconds
57
+ * @default 30000
58
+ */
59
+ ttl?: number;
60
+ /**
61
+ * Custom token store (default: in-memory Map)
62
+ */
63
+ store?: SSETokenStore;
64
+ /**
65
+ * Cleanup interval in milliseconds
66
+ * @default 60000
67
+ */
68
+ cleanupInterval?: number;
69
+ }
70
+ /**
71
+ * Redis/Valkey-backed token store for multi-instance deployments.
72
+ *
73
+ * Uses SET EX for automatic TTL expiry and GETDEL for atomic one-time consumption.
74
+ * No cleanup needed — Redis handles expiration automatically.
75
+ *
76
+ * @example
77
+ * ```typescript
78
+ * import { getCache } from '@spfn/core/cache';
79
+ *
80
+ * const cache = getCache();
81
+ * if (cache) {
82
+ * const store = new CacheTokenStore(cache);
83
+ * const manager = new SSETokenManager({ store });
84
+ * }
85
+ * ```
86
+ */
87
+ declare class CacheTokenStore implements SSETokenStore {
88
+ private cache;
89
+ private prefix;
90
+ constructor(cache: CacheClient);
91
+ set(token: string, data: SSEToken): Promise<void>;
92
+ consume(token: string): Promise<SSEToken | null>;
93
+ cleanup(): Promise<void>;
94
+ }
95
+ declare class SSETokenManager {
96
+ private store;
97
+ private ttl;
98
+ private cleanupTimer;
99
+ constructor(config?: SSETokenManagerConfig);
100
+ /**
101
+ * How long an issued token stays valid, in milliseconds.
102
+ *
103
+ * Exposed so a caller that has to report an expiry to its client computes the same
104
+ * number this manager stamps on the token, instead of assuming the default.
105
+ */
106
+ get ttlMs(): number;
107
+ /**
108
+ * Issue a new one-time-use token for the given subject
109
+ */
110
+ issue(subject: string): Promise<string>;
111
+ /**
112
+ * Verify and consume a token
113
+ * @returns subject string if valid, null if invalid/expired/already consumed
114
+ */
115
+ verify(token: string): Promise<string | null>;
116
+ /**
117
+ * Cleanup timer and resources
118
+ */
119
+ destroy(): void;
120
+ }
121
+
122
+ export { CacheTokenStore as C, SSETokenManager as S, type SSEToken as a, type SSETokenManagerConfig as b, type SSETokenStore as c };
@@ -62,6 +62,38 @@ interface ContractOperation {
62
62
  /** What the client reads. */
63
63
  response: JsonSchema;
64
64
  }
65
+ /**
66
+ * One contracted SSE event.
67
+ *
68
+ * On the wire the frame's `event:` field is `name`, and its `data` is the JSON
69
+ * `{ "event": name, "data": payload }` with `payload` matching `payload` here.
70
+ */
71
+ interface ContractEvent {
72
+ /**
73
+ * Router key — what a client subscribes to with `events=` and what the
74
+ * frame's `event:` field carries. Not `defineEvent`'s first argument, which
75
+ * never reaches the wire.
76
+ */
77
+ name: string;
78
+ /** Contract version the event first appeared in. */
79
+ since: string;
80
+ /** Present only when the event is announced for removal. */
81
+ deprecatedIn?: string;
82
+ /** Present only when the event is gone. */
83
+ removedIn?: string;
84
+ /** The payload schema. Compared under the response rules: events flow server → client. */
85
+ payload: JsonSchema;
86
+ }
87
+ /** How a client reaches the contracted events, and which events it may receive. */
88
+ interface ContractEvents {
89
+ /** The path a client opens the stream on. */
90
+ streamPath: string;
91
+ /** The path a client posts to for a one-time stream token when `auth` is `tokenExchange`. */
92
+ tokenPath: string;
93
+ auth: 'none' | 'tokenExchange';
94
+ /** Sorted by name. */
95
+ items: ContractEvent[];
96
+ }
65
97
  /**
66
98
  * How a client's version is judged against the server's.
67
99
  *
@@ -91,6 +123,11 @@ interface ContractDocument {
91
123
  compatibilityPolicy: CompatibilityPolicy;
92
124
  /** Sorted by name, so the file does not churn on router reordering. */
93
125
  operations: ContractOperation[];
126
+ /**
127
+ * Present only when the app's event router contracts at least one event,
128
+ * so an app without contracted events writes the document it always did.
129
+ */
130
+ events?: ContractEvents;
94
131
  }
95
132
  /** A released snapshot: the document plus the digest that pins it. */
96
133
  interface ContractSnapshot {
@@ -100,16 +137,18 @@ interface ContractSnapshot {
100
137
  document: ContractDocument;
101
138
  }
102
139
  /** What a gate violation is about. */
103
- type ContractViolationKind = 'operation.removed' | 'operation.path-changed' | 'operation.method-changed' | 'request.required-field-added' | 'request.field-became-required' | 'request.type-changed' | 'response.field-removed' | 'response.field-became-optional' | 'response.type-changed' | 'usage.undecidable' | 'usage.still-called' | 'snapshot.digest-mismatch';
140
+ type ContractViolationKind = 'operation.removed' | 'operation.path-changed' | 'operation.method-changed' | 'request.required-field-added' | 'request.field-became-required' | 'request.type-changed' | 'response.field-removed' | 'response.field-became-optional' | 'response.type-changed' | 'event.removed' | 'events.stream-path-changed' | 'events.auth-changed' | 'event.payload.field-removed' | 'event.payload.field-became-optional' | 'event.payload.type-changed' | 'usage.undecidable' | 'usage.still-called' | 'usage.still-subscribed' | 'snapshot.digest-mismatch';
104
141
  /** One reason the build refuses. */
105
142
  interface ContractViolation {
106
143
  kind: ContractViolationKind;
107
144
  /** Operation name, when the violation belongs to one. */
108
145
  operation?: string;
109
- /** Where inside the operation, e.g. `request.body.email`. */
146
+ /** Event name, when the violation belongs to one. */
147
+ event?: string;
148
+ /** Where inside the operation or event, e.g. `request.body.email`, `payload.userId`. */
110
149
  location?: string;
111
150
  /** What went wrong, in one line. */
112
151
  detail: string;
113
152
  }
114
153
 
115
- export type { ContractDocument as C, JsonSchema as J, ContractViolation as a, ContractOperation as b, ContractSnapshot as c, CompatibilityPolicy as d, ContractRequest as e, ContractViolationKind as f };
154
+ export type { ContractDocument as C, JsonSchema as J, ContractViolation as a, ContractOperation as b, ContractSnapshot as c, CompatibilityPolicy as d, ContractEvent as e, ContractEvents as f, ContractRequest as g, ContractViolationKind as h };
@@ -1,5 +1,6 @@
1
1
  import { Context } from 'hono';
2
- import { b as EventRouterDef, I as InferEventNames, e as InferEventPayload, i as SSETokenStore, S as SSETokenManager } from './token-manager-BT5EnUAR.js';
2
+ import { E as EventRouterDef, I as InferEventNames, g as InferEventPayload } from './router-CysBmgv3.js';
3
+ import { c as SSETokenStore, S as SSETokenManager } from './token-manager-Ckjt2-qq.js';
3
4
 
4
5
  /**
5
6
  * SSE Types
@@ -1,5 +1,6 @@
1
1
  import { Context } from 'hono';
2
- import { E as EventDef, b as EventRouterDef, I as InferEventNames, e as InferEventPayload, i as SSETokenStore, S as SSETokenManager } from './token-manager-BT5EnUAR.js';
2
+ import { a as EventDef, i as EventRouterBase, I as InferEventNames, g as InferEventPayload } from './router-CysBmgv3.js';
3
+ import { c as SSETokenStore, S as SSETokenManager } from './token-manager-Ckjt2-qq.js';
3
4
 
4
5
  /**
5
6
  * WebSocket Types
@@ -8,9 +9,9 @@ import { E as EventDef, b as EventRouterDef, I as InferEventNames, e as InferEve
8
9
  /**
9
10
  * WebSocket Router Definition
10
11
  *
11
- * Extends EventRouterDef with client→server message handlers.
12
+ * Extends the event router shape with client→server message handlers.
12
13
  */
13
- interface WSRouterDef<TEvents extends Record<string, EventDef<any>>, TMessages extends WSMessageHandlers = WSMessageHandlers> extends EventRouterDef<TEvents> {
14
+ interface WSRouterDef<TEvents extends Record<string, EventDef<any>>, TMessages extends WSMessageHandlers = WSMessageHandlers> extends EventRouterBase<TEvents> {
14
15
  messages: TMessages;
15
16
  }
16
17
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spfn/core",
3
- "version": "0.3.0-beta.18",
3
+ "version": "0.3.0-beta.19",
4
4
  "description": "Full-stack TypeScript backend for Next.js: file-based typed routes, Drizzle entities and repositories, PostgreSQL transactions and a generated end-to-end client, in one fixed vertical slice per feature",
5
5
  "type": "module",
6
6
  "exports": {
@@ -264,7 +264,8 @@ export type RouteName = keyof RouteMap;
264
264
 
265
265
  ## Built-in: `@spfn/core:contract`
266
266
 
267
- Writes `contracts/current.json` — every route carrying `.contract()` — and on a **build** compares
267
+ Writes `contracts/current.json` — every route carrying `.contract()`, and with `eventRouterPath`
268
+ every SSE event carrying `.contract()` — and on a **build** compares
268
269
  it against the newest released snapshot, refusing changes that would break a client already in the
269
270
  field. Full behaviour lives in [`../contract/README.md`](../contract/README.md); this section is the
270
271
  generator's own surface.
@@ -276,6 +277,8 @@ generator's own surface.
276
277
  | `name` | `'@spfn/core:contract'` | yes | — | Generator identifier (literal). |
277
278
  | `routerPath` | `string` | yes | — | Router file, relative to project root. Throws at construction if missing. |
278
279
  | `routerExport` | `string` | no | `appRouter` → `default` → `router` | Export holding the `defineRouter()` result. |
280
+ | `eventRouterPath` | `string` | no | — | Event router file, relative to project root. Loaded like the router (same `NODE_ENV` pin, same tsconfig aliases) and watched; its contracted events become the document's `events` section. Unset → no section. |
281
+ | `eventRouterExport` | `string` | no | `eventRouter` → `default` | Export holding the `defineEventRouter()` result. |
279
282
  | `outputDir` | `string` | no | `'./contracts'` | Holds `current.json`, `released/`, `usage/`. |
280
283
  | `additionalRouteDirs` | `string[]` | no | `[]` | Extra route dirs to watch. |
281
284
 
@@ -298,12 +301,18 @@ costs a module import and no infrastructure — `@spfn/auth`'s 43 routes load in
298
301
  - The router file is missing, or the module will not load (it names the module and the cause —
299
302
  it never skips quietly).
300
303
  - No router export is found under the configured or default names.
304
+ - `eventRouterPath` is set and the file is missing, or holds no event router (an object with
305
+ `events` and `eventNames`) under the configured or default names — the message names the
306
+ candidates it tried.
307
+ - The event router contracts events without a router-level `.contract()`, or a contracted event
308
+ has no payload schema, no `since`, or the reserved key `connected` / `ping`.
301
309
  - Two contracted routes share a name, or a contracted route has no method, path, `since` or
302
310
  `response`.
303
311
  - The router the generator was pointed at contains a conditional spread
304
312
  (`...(flag ? { route } : {})`), which would make the contract describe whichever way the
305
313
  generator happened to run. Same guard as `route-map` — see its bullet for exactly what is
306
- scanned and what is allowed.
314
+ scanned and what is allowed. The event router file gets the same scan for
315
+ `defineEventRouter({ ...(flag ? { event } : {}) })`.
307
316
  - On the `build` trigger only: the contract breaks the newest released snapshot.
308
317
 
309
318
  `spfn dev` generates but never refuses — being unable to hold a half-finished route mid-edit would
@@ -28,6 +28,7 @@ The clients that need a contract are the ones compiled and shipped separately.
28
28
  | Path | Contents |
29
29
  |------|----------|
30
30
  | `@spfn/core/route` | `.contract()` on the route builder, `RouteContract`, `RouteAuthProfile` |
31
+ | `@spfn/core/event` | `.contract()` on `defineEvent` and `defineEventRouter`, `EventContract`, `EventRouterContract` |
31
32
  | `@spfn/core/contract` | Everything below: collect, compare, snapshots, usage, the gate |
32
33
  | `@spfn/core/codegen` | `@spfn/core:contract` generator, `ContractGeneratorConfig` |
33
34
 
@@ -127,6 +128,8 @@ export default defineConfig({
127
128
  |--------|---------|---------|
128
129
  | `routerPath` | — | Router file, relative to the project root. Required. |
129
130
  | `routerExport` | `appRouter`, then `default`, then `router` | Export holding the `defineRouter()` result. |
131
+ | `eventRouterPath` | — | Event router file, relative to the project root. Set it to publish contracted SSE events — see [Events](#events). |
132
+ | `eventRouterExport` | `eventRouter`, then `default` | Export holding the `defineEventRouter()` result. |
130
133
  | `outputDir` | `./contracts` | Directory holding `current.json`, `released/` and `usage/`. |
131
134
  | `additionalRouteDirs` | `[]` | Extra directories to watch, for routes outside `src/server/routes`. |
132
135
 
@@ -182,6 +185,98 @@ path. That is what lets a moved path be reported as a broken promise instead of
182
185
  operation vanishing and another appearing. Names must therefore be unique across the whole router
183
186
  tree; two contracted routes sharing a name is refused at generation time.
184
187
 
188
+ ### Events
189
+
190
+ A native app subscribes to SSE events by name and decodes each payload itself. Without the events
191
+ in the contract both are hand-written, and both break at runtime — the stream answers 400 with
192
+ `validEvents`, or a decoder throws — instead of at build. Contracted events close that gap.
193
+
194
+ | Change | Previous snapshot exists | No snapshot yet |
195
+ |--------|--------------------------|-----------------|
196
+ | event added | pass | pass |
197
+ | event removed | **usage check**, against the usage file's `events` | pass |
198
+ | stream path changed | refuse (`events.stream-path-changed`) | pass |
199
+ | auth mode changed | refuse (`events.auth-changed`) | pass |
200
+ | payload field added | pass | pass |
201
+ | payload field removed | refuse (`event.payload.field-removed`) | pass |
202
+ | payload required → optional | refuse (`event.payload.field-became-optional`) | pass |
203
+ | payload optional → required | pass | pass |
204
+ | payload type or constraint changed | refuse (`event.payload.type-changed`) | pass |
205
+ | baseline has no `events` section | pass — nothing was promised | pass |
206
+ | current has no `events` section | every baseline event is **removed** | pass |
207
+
208
+ A payload flows server → client, so it is judged under the **response** rules — the same walk,
209
+ with the violation kinds renamed.
210
+
211
+ **Declaring.** Opt in per event, and once on the router:
212
+
213
+ ```ts
214
+ // src/server/events.ts
215
+ import { defineEvent, defineEventRouter } from '@spfn/core/event';
216
+
217
+ const sessionActivity = defineEvent('session.activity', Type.Object({ sessionId: Type.String() }))
218
+ .contract({ since: '1.0.0' }); // deprecatedIn?, removedIn? as on routes
219
+
220
+ export const eventRouter = defineEventRouter({ sessionActivity })
221
+ .contract({ auth: 'tokenExchange' }); // streamPath?: string, default '/events/stream'
222
+ ```
223
+
224
+ and point the generator at the file with `eventRouterPath: './src/server/events.ts'`. An event
225
+ without `.contract()` is untouched and never appears. A router with no contracted event produces no
226
+ `events` section at all, so an app that contracts none writes exactly the document it wrote before.
227
+
228
+ `.contract()` on an event may be called once — an event is defined once and shared, so a second call
229
+ throws instead of one declaration silently winning. A router `streamPath` that does not start with
230
+ `/` throws where it is declared.
231
+
232
+ **Refused at collection**, each with a message saying what to do:
233
+
234
+ - contracted events on a router with no `.contract()` of its own — the contract cannot say whether
235
+ a client must exchange a token first;
236
+ - a contracted event without a payload schema (`defineEvent(name)`) — its frame carries no `data`;
237
+ declare `Type.Object({})` and emit `{}` instead;
238
+ - a contracted event under the key `connected` or `ping` — those are the stream's own frames;
239
+ - a contracted event with no `since`.
240
+
241
+ **What a reader of the document needs.**
242
+
243
+ ```json
244
+ "events": {
245
+ "streamPath": "/events/stream",
246
+ "tokenPath": "/events/token",
247
+ "auth": "tokenExchange",
248
+ "items": [{ "name": "sessionActivity", "since": "1.0.0", "payload": { "type": "object", "…": "…" } }]
249
+ }
250
+ ```
251
+
252
+ - `name` is the **router key** — what a client lists in `?events=` and what the frame's `event:`
253
+ field carries. `defineEvent`'s first argument never reaches the wire and is not published. The same
254
+ `EventDef` registered under two keys is two items.
255
+ - A frame's `data` is the JSON `{ "event": name, "data": payload }`, with `payload` matching the
256
+ item's schema.
257
+ - `connected` (once, when the stream opens) and `ping` (keep-alive) frames are the stream's own and
258
+ are not items.
259
+ - With `auth: "tokenExchange"` the client first posts to `tokenPath`, then opens
260
+ `streamPath?events=…&token=…`. `tokenPath` is derived from `streamPath` exactly as the server
261
+ derives it.
262
+ - An `authorize` hook may narrow which contracted events a given connection receives. That is
263
+ per-connection policy, not a promise the contract makes, and the document does not describe it.
264
+
265
+ **The boot check.** The generator reads the event router only, never the server config. So
266
+ `createServer` checks the router's contract against `.events()` before anything is served: the
267
+ contract's `streamPath` (default `/events/stream`) must equal the path `.events()` registers, and
268
+ `auth: 'tokenExchange'` must match `.events({ auth: { enabled: true } })`. A mismatch refuses to
269
+ boot:
270
+
271
+ ```
272
+ SSE event router contract declares streamPath /sse but .events() registers /events/stream; the contract would describe a path the server does not serve
273
+ ```
274
+
275
+ A router without `.contract()` is not checked.
276
+
277
+ **Conditional registration is refused here too.** `defineEventRouter({ ...(flag ? { x } : {}) })`
278
+ in the event router file stops the generator, for the same reason as a conditional route.
279
+
185
280
  ### Request — safe when the server grows more tolerant
186
281
 
187
282
  | Change | Result | Why |
@@ -225,9 +320,15 @@ A removal is decided against `contracts/usage/<platform>-<appVersion>.json`, whi
225
320
  client writes:
226
321
 
227
322
  ```json
228
- { "platform": "ios", "appVersion": "2.4.1", "operations": ["getUser", "listItems"] }
323
+ { "platform": "ios", "appVersion": "2.4.1", "operations": ["getUser", "listItems"], "events": ["sessionActivity"] }
229
324
  ```
230
325
 
326
+ `events` lists the contracted events the client subscribes to. A file without the key subscribes
327
+ to none — an app released before events were contracted cannot be subscribed to one — and a value
328
+ that is not an array of names is refused like a bad `operations`. A removed event is judged by the
329
+ table below exactly as a removed operation, and a client still listing it is reported as
330
+ `usage.still-subscribed` with its platform and version.
331
+
231
332
  | Situation | Verdict |
232
333
  |-----------|---------|
233
334
  | usage directory missing, or holds no file | **refuse** — undecidable, which is not a pass |
@@ -250,7 +351,9 @@ Named here rather than left to be discovered:
250
351
  approved case table; adding it is a deliberate decision, not a quiet extension.
251
352
  - **Runtime response validation.** Whether a handler actually returns what it declared is a separate
252
353
  feature from generating a contract, and integration tests already cover it for contracted routes.
253
- - **WebSocket and SSE.** The contract covers REST operations only.
354
+ - **WebSocket.** The contract covers REST operations and SSE events; a WebSocket router is not
355
+ contracted.
356
+ - **`since` and `deprecatedIn` changes on an event**, and per-connection narrowing by `authorize`.
254
357
  - **How a client produces its usage file.** That belongs to the client's toolchain.
255
358
  - **Multipart routes are refused, not checked.** See below.
256
359
 
@@ -307,16 +410,21 @@ route.get('/x').contract({ since, response, auth?, requiresSession?, deprecatedI
307
410
  defineRouter({...}).contractVersion('1.2.0') // the version's source
308
411
  type RouteContract, RouteAuthProfile
309
412
 
413
+ // @spfn/core/event
414
+ defineEvent(name, schema).contract({ since, deprecatedIn?, removedIn? })
415
+ defineEventRouter({...}).contract({ auth, streamPath? })
416
+ type EventContract, EventRouterContract
417
+
310
418
  // @spfn/core/contract
311
- collectContractDocument(router) // Router → ContractDocument
312
- compareDocuments(before, after) // → { violations, removedOperations }
419
+ collectContractDocument(router, eventRouter?) // Router (+ event router) → ContractDocument
420
+ compareDocuments(before, after) // → { violations, removedOperations, removedEvents }
313
421
  compareOperation(before, after) // → ContractViolation[]
314
422
  checkContract(contractsDir, current) // the gate → { baselineVersion, violations, warnings }
315
423
  formatViolations(violations) // → the message a failing build prints
316
424
 
317
425
  readCurrentDocument(dir) / writeCurrentDocument(dir, document)
318
426
  listSnapshots(dir) / newestSnapshot(dir) / readSnapshot(file) / writeSnapshot(dir, document)
319
- readUsageRecords(usageDir) / callersOf(operation, records)
427
+ readUsageRecords(usageDir) / callersOf(operation, records) / subscribersOf(event, records)
320
428
  compareVersions(a, b)
321
429
  canonicalize / stableStringify / stableStringifyPretty / stableDigest
322
430
 
@@ -42,7 +42,11 @@ From `@spfn/core/event`:
42
42
  - `defineEventRouter(events)` — group events for SSE
43
43
  - `defineWSRouter({ events, messages? })` — group events + message handlers for WebSocket
44
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))
45
48
  - Types: `EventDef`, `EventHandler`, `InferEventPayload`, `PubSubCache`, `JobQueueSender`,
49
+ `EventContract`, `EventRouterContract`,
46
50
  `EventRouterDef`, `InferEventNames`, `InferEventPayloads`,
47
51
  `InferRouterEventPayload` (the two-arg router variant — see note)
48
52
 
@@ -128,6 +132,7 @@ Promise<void>` otherwise. Handlers run via `Promise.allSettled` — see
128
132
  | `unsubscribeAll()` | `() => void` | drop all in-memory handlers |
129
133
  | `emit(payload?)` | `Promise<void>` | fan-out to handlers (or cache) + job queues |
130
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 |
131
136
 
132
137
  > `_registerJobQueue` and `_payload` exist on the interface but are **internal** —
133
138
  > `_registerJobQueue` is called by `@spfn/core/job` when a job does `.on(event)`; `_payload`
@@ -204,6 +209,42 @@ SSEAuthConfig<TRouter> }`. The `auth` field is the **generic** `SSEAuthConfig<TR
204
209
  > `pingInterval`, `auth`, and `maxQueue`. Setting custom `headers` here is a no-op — set
205
210
  > response headers in middleware instead.
206
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
+
207
248
  ---
208
249
 
209
250
  ## SSE authentication (Token Exchange)
@@ -699,8 +740,12 @@ interface EventDef<TPayload = void> {
699
740
  unsubscribeAll: () => void;
700
741
  emit: TPayload extends void ? () => Promise<void> : (payload: TPayload) => Promise<void>;
701
742
  useCache: (cache: PubSubCache) => Promise<EventDef<TPayload>>;
743
+ readonly _contract?: EventContract;
744
+ contract: (contract: EventContract) => EventDef<TPayload>;
702
745
  }
703
746
 
747
+ interface EventContract { since: string; deprecatedIn?: string; removedIn?: string; }
748
+
704
749
  type EventHandler<TPayload> = (payload: TPayload) => void | Promise<void>;
705
750
  type InferEventPayload<TEvent> = TEvent extends EventDef<infer P> ? P : never; // single-arg
706
751
 
@@ -714,7 +759,10 @@ interface EventRouterDef<TEvents> {
714
759
  readonly events: TEvents;
715
760
  readonly eventNames: (keyof TEvents)[];
716
761
  readonly _types: { [K in keyof TEvents]: TEvents[K]['_payload'] };
762
+ readonly _contract?: EventRouterContract;
763
+ contract: (contract: EventRouterContract) => EventRouterDef<TEvents>;
717
764
  }
765
+ interface EventRouterContract { auth: 'none' | 'tokenExchange'; streamPath?: string; }
718
766
  type InferEventNames<T> = /* keyof router events as string union */;
719
767
  type InferEventPayloads<T> = T['_types'];
720
768
  // router two-arg payload, exported as `InferRouterEventPayload`: