@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.
- package/dist/codegen/index.d.ts +20 -0
- package/dist/codegen/index.js +68 -10
- package/dist/codegen/index.js.map +1 -1
- package/dist/contract/index.d.ts +37 -15
- package/dist/contract/index.js +147 -23
- package/dist/contract/index.js.map +1 -1
- package/dist/event/index.d.ts +4 -3
- package/dist/event/index.js +32 -1
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +3 -2
- package/dist/event/sse/index.d.ts +5 -4
- package/dist/event/ws/client.d.ts +3 -2
- package/dist/event/ws/index.d.ts +4 -3
- package/dist/ops/index.d.ts +1 -1
- package/dist/{token-manager-BT5EnUAR.d.ts → router-CysBmgv3.d.ts} +81 -127
- package/dist/server/index.d.ts +4 -3
- package/dist/server/index.js +26 -2
- package/dist/server/index.js.map +1 -1
- package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
- package/dist/{types-ClQVomgV.d.ts → types-B4DuGCyM.d.ts} +42 -3
- package/dist/{types-ZQODsBft.d.ts → types-B8zAxFsE.d.ts} +2 -1
- package/dist/{types-2AbaW4Ie.d.ts → types-W4u_S0lF.d.ts} +4 -3
- package/package.json +1 -1
- package/src/codegen/README.md +11 -2
- package/src/contract/README.md +113 -5
- package/src/event/README.md +48 -0
|
@@ -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
|
-
/**
|
|
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,
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
|
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.
|
|
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": {
|
package/src/codegen/README.md
CHANGED
|
@@ -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()
|
|
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
|
package/src/contract/README.md
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
|
package/src/event/README.md
CHANGED
|
@@ -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`:
|