@moqtap/codec 0.1.0

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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -0
  3. package/dist/chunk-23YG7F46.js +764 -0
  4. package/dist/chunk-2NARXGVA.cjs +194 -0
  5. package/dist/chunk-3BSZ55L3.cjs +307 -0
  6. package/dist/chunk-5WFXFLL4.cjs +1185 -0
  7. package/dist/chunk-DC4L6ZIT.js +307 -0
  8. package/dist/chunk-GDRGWFEK.cjs +498 -0
  9. package/dist/chunk-IQPDRQVC.js +1185 -0
  10. package/dist/chunk-QYG6KGOV.cjs +101 -0
  11. package/dist/chunk-UOBWHJA5.js +101 -0
  12. package/dist/chunk-WNTXF3DE.cjs +764 -0
  13. package/dist/chunk-YBSEOSSP.js +194 -0
  14. package/dist/chunk-YPXLV5YK.js +498 -0
  15. package/dist/codec-CTvFtQQI.d.cts +86 -0
  16. package/dist/codec-qPzfmLNu.d.ts +86 -0
  17. package/dist/draft14-session.cjs +6 -0
  18. package/dist/draft14-session.d.cts +8 -0
  19. package/dist/draft14-session.d.ts +8 -0
  20. package/dist/draft14-session.js +6 -0
  21. package/dist/draft14.cjs +121 -0
  22. package/dist/draft14.d.cts +96 -0
  23. package/dist/draft14.d.ts +96 -0
  24. package/dist/draft14.js +121 -0
  25. package/dist/draft7-session.cjs +7 -0
  26. package/dist/draft7-session.d.cts +7 -0
  27. package/dist/draft7-session.d.ts +7 -0
  28. package/dist/draft7-session.js +7 -0
  29. package/dist/draft7.cjs +60 -0
  30. package/dist/draft7.d.cts +72 -0
  31. package/dist/draft7.d.ts +72 -0
  32. package/dist/draft7.js +60 -0
  33. package/dist/index.cjs +40 -0
  34. package/dist/index.d.cts +40 -0
  35. package/dist/index.d.ts +40 -0
  36. package/dist/index.js +40 -0
  37. package/dist/session-types-B9NIf7_F.d.ts +101 -0
  38. package/dist/session-types-CCo-oA-d.d.cts +101 -0
  39. package/dist/session.cjs +27 -0
  40. package/dist/session.d.cts +24 -0
  41. package/dist/session.d.ts +24 -0
  42. package/dist/session.js +27 -0
  43. package/dist/types-CIk5W10V.d.cts +249 -0
  44. package/dist/types-CIk5W10V.d.ts +249 -0
  45. package/dist/types-ClXELFGN.d.cts +241 -0
  46. package/dist/types-ClXELFGN.d.ts +241 -0
  47. package/package.json +84 -0
  48. package/src/core/buffer-reader.ts +107 -0
  49. package/src/core/buffer-writer.ts +91 -0
  50. package/src/core/errors.ts +1 -0
  51. package/src/core/session-types.ts +103 -0
  52. package/src/core/types.ts +363 -0
  53. package/src/drafts/draft07/announce-fsm.ts +2 -0
  54. package/src/drafts/draft07/codec.ts +874 -0
  55. package/src/drafts/draft07/index.ts +70 -0
  56. package/src/drafts/draft07/messages.ts +44 -0
  57. package/src/drafts/draft07/parameters.ts +12 -0
  58. package/src/drafts/draft07/rules.ts +75 -0
  59. package/src/drafts/draft07/session-fsm.ts +353 -0
  60. package/src/drafts/draft07/session.ts +21 -0
  61. package/src/drafts/draft07/subscription-fsm.ts +3 -0
  62. package/src/drafts/draft07/varint.ts +23 -0
  63. package/src/drafts/draft14/codec.ts +1330 -0
  64. package/src/drafts/draft14/index.ts +132 -0
  65. package/src/drafts/draft14/messages.ts +76 -0
  66. package/src/drafts/draft14/rules.ts +70 -0
  67. package/src/drafts/draft14/session-fsm.ts +480 -0
  68. package/src/drafts/draft14/session.ts +26 -0
  69. package/src/drafts/draft14/types.ts +365 -0
  70. package/src/index.ts +85 -0
  71. package/src/session.ts +58 -0
@@ -0,0 +1,480 @@
1
+ import type { Draft14Message, Draft14MessageType } from './types.js';
2
+ import type {
3
+ SessionPhase,
4
+ TransitionResult,
5
+ ValidationResult,
6
+ ProtocolViolation,
7
+ SideEffect,
8
+ SubscriptionState,
9
+ AnnounceState,
10
+ PublishState,
11
+ FetchState,
12
+ } from '../../core/session-types.js';
13
+ import { getLegalOutgoing, getLegalIncoming, CLIENT_ONLY_MESSAGES, SERVER_ONLY_MESSAGES } from './rules.js';
14
+
15
+ function violation(
16
+ code: ProtocolViolation<Draft14MessageType>['code'],
17
+ message: string,
18
+ currentPhase: SessionPhase,
19
+ offendingMessage: Draft14MessageType,
20
+ ): ProtocolViolation<Draft14MessageType> {
21
+ return { code, message, currentPhase, offendingMessage };
22
+ }
23
+
24
+ export class Draft14SessionFSM {
25
+ private _phase: SessionPhase = 'idle';
26
+ private _role: 'client' | 'server';
27
+ private _subscriptions = new Map<bigint, SubscriptionState>();
28
+ private _publishes = new Map<bigint, PublishState>();
29
+ private _fetches = new Map<bigint, FetchState>();
30
+ private _requestIds = new Set<bigint>();
31
+
32
+ constructor(role: 'client' | 'server') {
33
+ this._role = role;
34
+ }
35
+
36
+ get phase(): SessionPhase { return this._phase; }
37
+ get role(): 'client' | 'server' { return this._role; }
38
+ get subscriptions(): ReadonlyMap<bigint, SubscriptionState> { return this._subscriptions; }
39
+ get announces(): ReadonlyMap<string, AnnounceState> { return new Map(); }
40
+ get publishes(): ReadonlyMap<bigint, PublishState> { return this._publishes; }
41
+ get fetches(): ReadonlyMap<bigint, FetchState> { return this._fetches; }
42
+
43
+ get legalOutgoing(): ReadonlySet<Draft14MessageType> {
44
+ return getLegalOutgoing(this._phase, this._role);
45
+ }
46
+
47
+ get legalIncoming(): ReadonlySet<Draft14MessageType> {
48
+ return getLegalIncoming(this._phase, this._role);
49
+ }
50
+
51
+ // Validate role constraints
52
+ private checkRole(message: Draft14Message, direction: 'inbound' | 'outbound'): ProtocolViolation<Draft14MessageType> | null {
53
+ const senderRole = direction === 'outbound' ? this._role : (this._role === 'client' ? 'server' : 'client');
54
+
55
+ if (CLIENT_ONLY_MESSAGES.has(message.type) && senderRole !== 'client') {
56
+ return violation('ROLE_VIOLATION', `${message.type} can only be sent by client`, this._phase, message.type);
57
+ }
58
+ if (SERVER_ONLY_MESSAGES.has(message.type) && senderRole !== 'server') {
59
+ return violation('ROLE_VIOLATION', `${message.type} can only be sent by server`, this._phase, message.type);
60
+ }
61
+ return null;
62
+ }
63
+
64
+ private checkDuplicateRequestId(requestId: bigint, msgType: Draft14MessageType): ProtocolViolation<Draft14MessageType> | null {
65
+ if (this._requestIds.has(requestId)) {
66
+ return violation('DUPLICATE_REQUEST_ID', `Request ID ${requestId} already in use`, this._phase, msgType);
67
+ }
68
+ return null;
69
+ }
70
+
71
+ private checkKnownRequestId(requestId: bigint, msgType: Draft14MessageType): ProtocolViolation<Draft14MessageType> | null {
72
+ if (!this._requestIds.has(requestId)) {
73
+ return violation('UNKNOWN_REQUEST_ID', `No request with ID ${requestId}`, this._phase, msgType);
74
+ }
75
+ return null;
76
+ }
77
+
78
+ validateOutgoing(message: Draft14Message): ValidationResult<Draft14MessageType> {
79
+ const roleViolation = this.checkRole(message, 'outbound');
80
+ if (roleViolation) return { ok: false, violation: roleViolation };
81
+
82
+ if (!this.legalOutgoing.has(message.type)) {
83
+ return {
84
+ ok: false,
85
+ violation: violation(
86
+ this._phase === 'idle' || this._phase === 'setup' ? 'MESSAGE_BEFORE_SETUP' : 'UNEXPECTED_MESSAGE',
87
+ `Cannot send ${message.type} in phase ${this._phase}`,
88
+ this._phase,
89
+ message.type,
90
+ ),
91
+ };
92
+ }
93
+ return { ok: true };
94
+ }
95
+
96
+ receive(message: Draft14Message): TransitionResult<Draft14MessageType> {
97
+ const roleViolation = this.checkRole(message, 'inbound');
98
+ if (roleViolation) return { ok: false, violation: roleViolation };
99
+
100
+ return this.applyTransition(message, 'inbound');
101
+ }
102
+
103
+ send(message: Draft14Message): TransitionResult<Draft14MessageType> {
104
+ const roleViolation = this.checkRole(message, 'outbound');
105
+ if (roleViolation) return { ok: false, violation: roleViolation };
106
+
107
+ return this.applyTransition(message, 'outbound');
108
+ }
109
+
110
+ private applyTransition(message: Draft14Message, direction: 'inbound' | 'outbound'): TransitionResult<Draft14MessageType> {
111
+ const sideEffects: SideEffect[] = [];
112
+
113
+ switch (message.type) {
114
+ case 'client_setup':
115
+ return this.handleClientSetup(message, direction);
116
+ case 'server_setup':
117
+ return this.handleServerSetup(message, direction);
118
+ case 'goaway':
119
+ return this.handleGoAway(message, direction, sideEffects);
120
+
121
+ // Subscribe lifecycle
122
+ case 'subscribe':
123
+ return this.handleSubscribe(message, direction, sideEffects);
124
+ case 'subscribe_ok':
125
+ return this.handleSubscribeOk(message, direction, sideEffects);
126
+ case 'subscribe_error':
127
+ return this.handleSubscribeError(message, direction, sideEffects);
128
+ case 'subscribe_update':
129
+ return this.handleSubscribeUpdate(message, direction, sideEffects);
130
+ case 'unsubscribe':
131
+ return this.handleUnsubscribe(message, direction, sideEffects);
132
+
133
+ // Publish lifecycle
134
+ case 'publish':
135
+ return this.handlePublish(message, direction, sideEffects);
136
+ case 'publish_ok':
137
+ return this.handlePublishOk(message, direction, sideEffects);
138
+ case 'publish_error':
139
+ return this.handlePublishError(message, direction, sideEffects);
140
+ case 'publish_done':
141
+ return this.handlePublishDone(message, direction, sideEffects);
142
+
143
+ // Fetch lifecycle
144
+ case 'fetch':
145
+ return this.handleFetch(message, direction, sideEffects);
146
+ case 'fetch_ok':
147
+ return this.handleFetchOk(message, direction, sideEffects);
148
+ case 'fetch_error':
149
+ return this.handleFetchError(message, direction, sideEffects);
150
+ case 'fetch_cancel':
151
+ return this.handleFetchCancel(message, direction, sideEffects);
152
+
153
+ // Publish namespace, subscribe namespace, track status, and other ready-phase messages
154
+ default:
155
+ return this.handleReadyPhaseMessage(message);
156
+ }
157
+ }
158
+
159
+ private handleClientSetup(_message: Draft14Message, direction: 'inbound' | 'outbound'): TransitionResult<Draft14MessageType> {
160
+ if (this._phase !== 'idle') {
161
+ return { ok: false, violation: violation('SETUP_VIOLATION', 'CLIENT_SETUP already sent/received', this._phase, 'client_setup') };
162
+ }
163
+
164
+ if (direction === 'outbound' && this._role !== 'client') {
165
+ return { ok: false, violation: violation('ROLE_VIOLATION', 'Only client can send CLIENT_SETUP', this._phase, 'client_setup') };
166
+ }
167
+
168
+ this._phase = 'setup';
169
+ return { ok: true, phase: this._phase, sideEffects: [] };
170
+ }
171
+
172
+ private handleServerSetup(_message: Draft14Message, direction: 'inbound' | 'outbound'): TransitionResult<Draft14MessageType> {
173
+ if (this._phase !== 'setup') {
174
+ return { ok: false, violation: violation('SETUP_VIOLATION', 'SERVER_SETUP before CLIENT_SETUP', this._phase, 'server_setup') };
175
+ }
176
+
177
+ if (direction === 'outbound' && this._role !== 'server') {
178
+ return { ok: false, violation: violation('ROLE_VIOLATION', 'Only server can send SERVER_SETUP', this._phase, 'server_setup') };
179
+ }
180
+
181
+ this._phase = 'ready';
182
+ return { ok: true, phase: this._phase, sideEffects: [{ type: 'session-ready' }] };
183
+ }
184
+
185
+ private handleGoAway(message: Draft14Message, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult<Draft14MessageType> {
186
+ if (this._phase !== 'ready' && this._phase !== 'draining') {
187
+ return { ok: false, violation: violation('UNEXPECTED_MESSAGE', `GOAWAY not valid in phase ${this._phase}`, this._phase, 'goaway') };
188
+ }
189
+ this._phase = 'draining';
190
+ const goaway = message as import('./types.js').Draft14GoAway;
191
+ sideEffects.push({ type: 'session-draining', goAwayUri: goaway.new_session_uri });
192
+ return { ok: true, phase: this._phase, sideEffects };
193
+ }
194
+
195
+ private requireReady(msgType: Draft14MessageType): ProtocolViolation<Draft14MessageType> | null {
196
+ if (this._phase !== 'ready' && this._phase !== 'draining') {
197
+ return violation(
198
+ this._phase === 'idle' || this._phase === 'setup' ? 'MESSAGE_BEFORE_SETUP' : 'UNEXPECTED_MESSAGE',
199
+ `${msgType} requires ready phase, current: ${this._phase}`,
200
+ this._phase,
201
+ msgType,
202
+ );
203
+ }
204
+ return null;
205
+ }
206
+
207
+ // ─── Subscribe lifecycle ──────────────────────────────────────────────────────
208
+
209
+ private handleSubscribe(message: Draft14Message, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult<Draft14MessageType> {
210
+ const err = this.requireReady(message.type);
211
+ if (err) return { ok: false, violation: err };
212
+
213
+ const sub = message as import('./types.js').Draft14Subscribe;
214
+ const dupErr = this.checkDuplicateRequestId(sub.request_id, message.type);
215
+ if (dupErr) return { ok: false, violation: dupErr };
216
+
217
+ this._requestIds.add(sub.request_id);
218
+ this._subscriptions.set(sub.request_id, {
219
+ subscribeId: sub.request_id,
220
+ phase: 'pending',
221
+ trackNamespace: sub.track_namespace,
222
+ trackName: sub.track_name,
223
+ });
224
+
225
+ return { ok: true, phase: this._phase, sideEffects };
226
+ }
227
+
228
+ private handleSubscribeOk(message: Draft14Message, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult<Draft14MessageType> {
229
+ const err = this.requireReady(message.type);
230
+ if (err) return { ok: false, violation: err };
231
+
232
+ const ok = message as import('./types.js').Draft14SubscribeOk;
233
+ const idErr = this.checkKnownRequestId(ok.request_id, message.type);
234
+ if (idErr) return { ok: false, violation: idErr };
235
+
236
+ const existing = this._subscriptions.get(ok.request_id);
237
+ if (!existing) {
238
+ return { ok: false, violation: violation('UNKNOWN_REQUEST_ID', `No subscription with request ID ${ok.request_id}`, this._phase, message.type) };
239
+ }
240
+ if (existing.phase !== 'pending') {
241
+ return { ok: false, violation: violation('STATE_VIOLATION', `Subscription ${ok.request_id} is ${existing.phase}, not pending`, this._phase, message.type) };
242
+ }
243
+
244
+ this._subscriptions.set(ok.request_id, { ...existing, phase: 'active' });
245
+ sideEffects.push({ type: 'subscription-activated', subscribeId: ok.request_id });
246
+ return { ok: true, phase: this._phase, sideEffects };
247
+ }
248
+
249
+ private handleSubscribeError(message: Draft14Message, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult<Draft14MessageType> {
250
+ const err = this.requireReady(message.type);
251
+ if (err) return { ok: false, violation: err };
252
+
253
+ const subErr = message as import('./types.js').Draft14SubscribeError;
254
+ const idErr = this.checkKnownRequestId(subErr.request_id, message.type);
255
+ if (idErr) return { ok: false, violation: idErr };
256
+
257
+ const existing = this._subscriptions.get(subErr.request_id);
258
+ if (!existing) {
259
+ return { ok: false, violation: violation('UNKNOWN_REQUEST_ID', `No subscription with request ID ${subErr.request_id}`, this._phase, message.type) };
260
+ }
261
+ if (existing.phase !== 'pending') {
262
+ return { ok: false, violation: violation('STATE_VIOLATION', `Subscription ${subErr.request_id} is ${existing.phase}, not pending`, this._phase, message.type) };
263
+ }
264
+
265
+ this._subscriptions.set(subErr.request_id, { ...existing, phase: 'error' });
266
+ sideEffects.push({ type: 'subscription-ended', subscribeId: subErr.request_id, reason: subErr.reason_phrase });
267
+ return { ok: true, phase: this._phase, sideEffects };
268
+ }
269
+
270
+ private handleSubscribeUpdate(message: Draft14Message, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult<Draft14MessageType> {
271
+ const err = this.requireReady(message.type);
272
+ if (err) return { ok: false, violation: err };
273
+
274
+ const update = message as import('./types.js').Draft14SubscribeUpdate;
275
+ const idErr = this.checkKnownRequestId(update.request_id, message.type);
276
+ if (idErr) return { ok: false, violation: idErr };
277
+
278
+ const existing = this._subscriptions.get(update.request_id);
279
+ if (!existing) {
280
+ return { ok: false, violation: violation('UNKNOWN_REQUEST_ID', `No subscription with request ID ${update.request_id}`, this._phase, message.type) };
281
+ }
282
+ if (existing.phase !== 'active') {
283
+ return { ok: false, violation: violation('STATE_VIOLATION', `Subscription ${update.request_id} is ${existing.phase}, not active`, this._phase, message.type) };
284
+ }
285
+
286
+ return { ok: true, phase: this._phase, sideEffects };
287
+ }
288
+
289
+ private handleUnsubscribe(message: Draft14Message, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult<Draft14MessageType> {
290
+ const err = this.requireReady(message.type);
291
+ if (err) return { ok: false, violation: err };
292
+
293
+ const unsub = message as import('./types.js').Draft14Unsubscribe;
294
+ const idErr = this.checkKnownRequestId(unsub.request_id, message.type);
295
+ if (idErr) return { ok: false, violation: idErr };
296
+
297
+ const existing = this._subscriptions.get(unsub.request_id);
298
+ if (!existing) {
299
+ return { ok: false, violation: violation('UNKNOWN_REQUEST_ID', `No subscription with request ID ${unsub.request_id}`, this._phase, message.type) };
300
+ }
301
+
302
+ this._subscriptions.set(unsub.request_id, { ...existing, phase: 'done' });
303
+ sideEffects.push({ type: 'subscription-ended', subscribeId: unsub.request_id, reason: 'unsubscribed' });
304
+ return { ok: true, phase: this._phase, sideEffects };
305
+ }
306
+
307
+ // ─── Publish lifecycle ────────────────────────────────────────────────────────
308
+
309
+ private handlePublish(message: Draft14Message, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult<Draft14MessageType> {
310
+ const err = this.requireReady(message.type);
311
+ if (err) return { ok: false, violation: err };
312
+
313
+ const pub = message as import('./types.js').Draft14Publish;
314
+ const dupErr = this.checkDuplicateRequestId(pub.request_id, message.type);
315
+ if (dupErr) return { ok: false, violation: dupErr };
316
+
317
+ this._requestIds.add(pub.request_id);
318
+ this._publishes.set(pub.request_id, {
319
+ requestId: pub.request_id,
320
+ phase: 'pending',
321
+ });
322
+
323
+ return { ok: true, phase: this._phase, sideEffects };
324
+ }
325
+
326
+ private handlePublishOk(message: Draft14Message, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult<Draft14MessageType> {
327
+ const err = this.requireReady(message.type);
328
+ if (err) return { ok: false, violation: err };
329
+
330
+ const ok = message as import('./types.js').Draft14PublishOk;
331
+ const idErr = this.checkKnownRequestId(ok.request_id, message.type);
332
+ if (idErr) return { ok: false, violation: idErr };
333
+
334
+ const existing = this._publishes.get(ok.request_id);
335
+ if (!existing) {
336
+ return { ok: false, violation: violation('UNKNOWN_REQUEST_ID', `No publish with request ID ${ok.request_id}`, this._phase, message.type) };
337
+ }
338
+ if (existing.phase !== 'pending') {
339
+ return { ok: false, violation: violation('STATE_VIOLATION', `Publish ${ok.request_id} is ${existing.phase}, not pending`, this._phase, message.type) };
340
+ }
341
+
342
+ this._publishes.set(ok.request_id, { ...existing, phase: 'active' });
343
+ sideEffects.push({ type: 'publish-activated', requestId: ok.request_id });
344
+ return { ok: true, phase: this._phase, sideEffects };
345
+ }
346
+
347
+ private handlePublishError(message: Draft14Message, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult<Draft14MessageType> {
348
+ const err = this.requireReady(message.type);
349
+ if (err) return { ok: false, violation: err };
350
+
351
+ const pubErr = message as import('./types.js').Draft14PublishError;
352
+ const idErr = this.checkKnownRequestId(pubErr.request_id, message.type);
353
+ if (idErr) return { ok: false, violation: idErr };
354
+
355
+ const existing = this._publishes.get(pubErr.request_id);
356
+ if (!existing) {
357
+ return { ok: false, violation: violation('UNKNOWN_REQUEST_ID', `No publish with request ID ${pubErr.request_id}`, this._phase, message.type) };
358
+ }
359
+ if (existing.phase !== 'pending') {
360
+ return { ok: false, violation: violation('STATE_VIOLATION', `Publish ${pubErr.request_id} is ${existing.phase}, not pending`, this._phase, message.type) };
361
+ }
362
+
363
+ this._publishes.set(pubErr.request_id, { ...existing, phase: 'error' });
364
+ sideEffects.push({ type: 'publish-ended', requestId: pubErr.request_id, reason: pubErr.reason_phrase });
365
+ return { ok: true, phase: this._phase, sideEffects };
366
+ }
367
+
368
+ private handlePublishDone(message: Draft14Message, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult<Draft14MessageType> {
369
+ const err = this.requireReady(message.type);
370
+ if (err) return { ok: false, violation: err };
371
+
372
+ const done = message as import('./types.js').Draft14PublishDone;
373
+ const idErr = this.checkKnownRequestId(done.request_id, message.type);
374
+ if (idErr) return { ok: false, violation: idErr };
375
+
376
+ const existing = this._publishes.get(done.request_id);
377
+ if (!existing) {
378
+ return { ok: false, violation: violation('UNKNOWN_REQUEST_ID', `No publish with request ID ${done.request_id}`, this._phase, message.type) };
379
+ }
380
+
381
+ this._publishes.set(done.request_id, { ...existing, phase: 'done' });
382
+ sideEffects.push({ type: 'publish-ended', requestId: done.request_id, reason: done.reason_phrase });
383
+ return { ok: true, phase: this._phase, sideEffects };
384
+ }
385
+
386
+ // ─── Fetch lifecycle ──────────────────────────────────────────────────────────
387
+
388
+ private handleFetch(message: Draft14Message, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult<Draft14MessageType> {
389
+ const err = this.requireReady(message.type);
390
+ if (err) return { ok: false, violation: err };
391
+
392
+ const fetch = message as import('./types.js').Draft14Fetch;
393
+ const dupErr = this.checkDuplicateRequestId(fetch.request_id, message.type);
394
+ if (dupErr) return { ok: false, violation: dupErr };
395
+
396
+ this._requestIds.add(fetch.request_id);
397
+ this._fetches.set(fetch.request_id, {
398
+ requestId: fetch.request_id,
399
+ phase: 'pending',
400
+ });
401
+
402
+ return { ok: true, phase: this._phase, sideEffects };
403
+ }
404
+
405
+ private handleFetchOk(message: Draft14Message, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult<Draft14MessageType> {
406
+ const err = this.requireReady(message.type);
407
+ if (err) return { ok: false, violation: err };
408
+
409
+ const ok = message as import('./types.js').Draft14FetchOk;
410
+ const idErr = this.checkKnownRequestId(ok.request_id, message.type);
411
+ if (idErr) return { ok: false, violation: idErr };
412
+
413
+ const existing = this._fetches.get(ok.request_id);
414
+ if (!existing) {
415
+ return { ok: false, violation: violation('UNKNOWN_REQUEST_ID', `No fetch with request ID ${ok.request_id}`, this._phase, message.type) };
416
+ }
417
+ if (existing.phase !== 'pending') {
418
+ return { ok: false, violation: violation('STATE_VIOLATION', `Fetch ${ok.request_id} is ${existing.phase}, not pending`, this._phase, message.type) };
419
+ }
420
+
421
+ this._fetches.set(ok.request_id, { ...existing, phase: 'active' });
422
+ sideEffects.push({ type: 'fetch-activated', requestId: ok.request_id });
423
+ return { ok: true, phase: this._phase, sideEffects };
424
+ }
425
+
426
+ private handleFetchError(message: Draft14Message, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult<Draft14MessageType> {
427
+ const err = this.requireReady(message.type);
428
+ if (err) return { ok: false, violation: err };
429
+
430
+ const fetchErr = message as import('./types.js').Draft14FetchError;
431
+ const idErr = this.checkKnownRequestId(fetchErr.request_id, message.type);
432
+ if (idErr) return { ok: false, violation: idErr };
433
+
434
+ const existing = this._fetches.get(fetchErr.request_id);
435
+ if (!existing) {
436
+ return { ok: false, violation: violation('UNKNOWN_REQUEST_ID', `No fetch with request ID ${fetchErr.request_id}`, this._phase, message.type) };
437
+ }
438
+ if (existing.phase !== 'pending') {
439
+ return { ok: false, violation: violation('STATE_VIOLATION', `Fetch ${fetchErr.request_id} is ${existing.phase}, not pending`, this._phase, message.type) };
440
+ }
441
+
442
+ this._fetches.set(fetchErr.request_id, { ...existing, phase: 'error' });
443
+ sideEffects.push({ type: 'fetch-ended', requestId: fetchErr.request_id, reason: fetchErr.reason_phrase });
444
+ return { ok: true, phase: this._phase, sideEffects };
445
+ }
446
+
447
+ private handleFetchCancel(message: Draft14Message, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult<Draft14MessageType> {
448
+ const err = this.requireReady(message.type);
449
+ if (err) return { ok: false, violation: err };
450
+
451
+ const cancel = message as import('./types.js').Draft14FetchCancel;
452
+ const idErr = this.checkKnownRequestId(cancel.request_id, message.type);
453
+ if (idErr) return { ok: false, violation: idErr };
454
+
455
+ const existing = this._fetches.get(cancel.request_id);
456
+ if (!existing) {
457
+ return { ok: false, violation: violation('UNKNOWN_REQUEST_ID', `No fetch with request ID ${cancel.request_id}`, this._phase, message.type) };
458
+ }
459
+
460
+ this._fetches.set(cancel.request_id, { ...existing, phase: 'cancelled' });
461
+ sideEffects.push({ type: 'fetch-ended', requestId: cancel.request_id, reason: 'cancelled' });
462
+ return { ok: true, phase: this._phase, sideEffects };
463
+ }
464
+
465
+ // ─── Generic ready-phase handler ──────────────────────────────────────────────
466
+
467
+ private handleReadyPhaseMessage(message: Draft14Message): TransitionResult<Draft14MessageType> {
468
+ const err = this.requireReady(message.type);
469
+ if (err) return { ok: false, violation: err };
470
+ return { ok: true, phase: this._phase, sideEffects: [] };
471
+ }
472
+
473
+ reset(): void {
474
+ this._phase = 'idle';
475
+ this._subscriptions.clear();
476
+ this._publishes.clear();
477
+ this._fetches.clear();
478
+ this._requestIds.clear();
479
+ }
480
+ }
@@ -0,0 +1,26 @@
1
+ import type { Draft14Message, Draft14MessageType } from './types.js';
2
+ import type { SessionState, SessionStateOptions } from '../../core/session-types.js';
3
+ import { Draft14SessionFSM } from './session-fsm.js';
4
+
5
+ export function createDraft14SessionState(
6
+ options: SessionStateOptions,
7
+ ): SessionState<Draft14Message, Draft14MessageType> {
8
+ return new Draft14SessionFSM(options.role);
9
+ }
10
+
11
+ export type {
12
+ SessionState,
13
+ SessionStateOptions,
14
+ SessionPhase,
15
+ SubscriptionState,
16
+ SubscriptionPhase,
17
+ PublishState,
18
+ PublishPhase,
19
+ FetchState,
20
+ FetchPhase,
21
+ TransitionResult,
22
+ ValidationResult,
23
+ ProtocolViolation,
24
+ ProtocolViolationCode,
25
+ SideEffect,
26
+ } from '../../core/session-types.js';