@moqtap/codec 0.8.1 → 0.9.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 (127) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +16 -4
  3. package/dist/chunk-BMJMY6FX.cjs +868 -0
  4. package/dist/chunk-MAXEHYVB.cjs +2362 -0
  5. package/dist/{chunk-3YAR3I3S.js → chunk-Q7NSUXXB.js} +3 -1
  6. package/dist/{chunk-VWXUT5R2.cjs → chunk-TVSFDQYE.cjs} +3 -1
  7. package/dist/chunk-WX5PU7HF.js +868 -0
  8. package/dist/chunk-YJGOD3Y4.js +2362 -0
  9. package/dist/{codec-BLYAy2OP.d.ts → codec--CVeqGQv.d.ts} +1 -1
  10. package/dist/{codec-DujHf55Z.d.ts → codec-BJ7BHuiZ.d.ts} +1 -1
  11. package/dist/{codec-D47r_-Rl.d.ts → codec-BKsVu3Fr.d.ts} +1 -1
  12. package/dist/{codec-BNS3gS77.d.ts → codec-BMwgZIgG.d.ts} +1 -1
  13. package/dist/{codec-CI0BjmHc.d.ts → codec-BNy5syCU.d.ts} +1 -1
  14. package/dist/{codec-v6Vloc7H.d.ts → codec-BQa6Qsav.d.ts} +1 -1
  15. package/dist/codec-Bym-K2q2.d.cts +49 -0
  16. package/dist/{codec-D2JbI2tp.d.ts → codec-C006F75E.d.ts} +1 -1
  17. package/dist/codec-C5evFHL-.d.ts +49 -0
  18. package/dist/{codec-DQWQSam3.d.cts → codec-C6Mx4bwz.d.cts} +1 -1
  19. package/dist/{codec-DL0njGkL.d.ts → codec-CA6nbbEk.d.ts} +1 -1
  20. package/dist/{codec-D89DWXbv.d.cts → codec-CSBFdPgP.d.cts} +1 -1
  21. package/dist/{codec-DvcJgYKq.d.cts → codec-CXCjya_G.d.cts} +1 -1
  22. package/dist/{codec-hvkJfCF5.d.cts → codec-C_xxsbKy.d.cts} +1 -1
  23. package/dist/{codec-BOyrk_C9.d.cts → codec-Cfo_z5ET.d.cts} +1 -1
  24. package/dist/{codec-xeuGZY77.d.cts → codec-CgZdadPt.d.cts} +2 -2
  25. package/dist/{codec-WwwUipaU.d.ts → codec-Cl73EfRW.d.ts} +1 -1
  26. package/dist/{codec-BOtv3R5j.d.cts → codec-CtvFK4aV.d.cts} +1 -1
  27. package/dist/{codec-DAePx2NL.d.ts → codec-CviAa4lp.d.ts} +2 -2
  28. package/dist/{codec-DJmV04i0.d.ts → codec-D4RvFQmu.d.ts} +1 -1
  29. package/dist/{codec-cou9XBMF.d.cts → codec-DAyiF5sq.d.cts} +1 -1
  30. package/dist/{codec-CyApDKwf.d.cts → codec-DJmqoIsp.d.cts} +1 -1
  31. package/dist/{codec-NMsTK0GX.d.cts → codec-DchsPfr3.d.cts} +1 -1
  32. package/dist/{codec-DplRgIKq.d.ts → codec-Dl_QIXMz.d.ts} +1 -1
  33. package/dist/{codec-N3g1lxQQ.d.cts → codec-DzLvG8Wb.d.cts} +1 -1
  34. package/dist/{codec-D95gT98n.d.cts → codec-DzkdW4kO.d.cts} +1 -1
  35. package/dist/{codec-CWj5iTxA.d.ts → codec-O-1Js7h4.d.ts} +1 -1
  36. package/dist/{codec-BqqoYdh_.d.cts → codec-vddqy0KY.d.cts} +1 -1
  37. package/dist/draft07-session.d.cts +3 -3
  38. package/dist/draft07-session.d.ts +3 -3
  39. package/dist/draft07.d.cts +3 -3
  40. package/dist/draft07.d.ts +3 -3
  41. package/dist/draft08-session.d.cts +3 -3
  42. package/dist/draft08-session.d.ts +3 -3
  43. package/dist/draft08.d.cts +4 -4
  44. package/dist/draft08.d.ts +4 -4
  45. package/dist/draft09-session.d.cts +3 -3
  46. package/dist/draft09-session.d.ts +3 -3
  47. package/dist/draft09.d.cts +4 -4
  48. package/dist/draft09.d.ts +4 -4
  49. package/dist/draft10-session.d.cts +3 -3
  50. package/dist/draft10-session.d.ts +3 -3
  51. package/dist/draft10.d.cts +4 -4
  52. package/dist/draft10.d.ts +4 -4
  53. package/dist/draft11-session.d.cts +3 -3
  54. package/dist/draft11-session.d.ts +3 -3
  55. package/dist/draft11.d.cts +4 -4
  56. package/dist/draft11.d.ts +4 -4
  57. package/dist/draft12-session.d.cts +3 -3
  58. package/dist/draft12-session.d.ts +3 -3
  59. package/dist/draft12.d.cts +4 -4
  60. package/dist/draft12.d.ts +4 -4
  61. package/dist/draft13-session.d.cts +3 -3
  62. package/dist/draft13-session.d.ts +3 -3
  63. package/dist/draft13.d.cts +4 -4
  64. package/dist/draft13.d.ts +4 -4
  65. package/dist/draft14-session.d.cts +3 -3
  66. package/dist/draft14-session.d.ts +3 -3
  67. package/dist/draft14.d.cts +4 -4
  68. package/dist/draft14.d.ts +4 -4
  69. package/dist/draft15-session.d.cts +3 -3
  70. package/dist/draft15-session.d.ts +3 -3
  71. package/dist/draft15.d.cts +4 -4
  72. package/dist/draft15.d.ts +4 -4
  73. package/dist/draft16-session.d.cts +3 -3
  74. package/dist/draft16-session.d.ts +3 -3
  75. package/dist/draft16.d.cts +4 -4
  76. package/dist/draft16.d.ts +4 -4
  77. package/dist/draft17-session.d.cts +3 -3
  78. package/dist/draft17-session.d.ts +3 -3
  79. package/dist/draft17.d.cts +3 -3
  80. package/dist/draft17.d.ts +3 -3
  81. package/dist/draft18-session.d.cts +3 -3
  82. package/dist/draft18-session.d.ts +3 -3
  83. package/dist/draft18.d.cts +3 -3
  84. package/dist/draft18.d.ts +3 -3
  85. package/dist/draft19-session.d.cts +3 -3
  86. package/dist/draft19-session.d.ts +3 -3
  87. package/dist/draft19.cjs +2 -2
  88. package/dist/draft19.d.cts +3 -3
  89. package/dist/draft19.d.ts +3 -3
  90. package/dist/draft19.js +1 -1
  91. package/dist/draft20-session.cjs +8 -0
  92. package/dist/draft20-session.d.cts +195 -0
  93. package/dist/draft20-session.d.ts +195 -0
  94. package/dist/draft20-session.js +8 -0
  95. package/dist/draft20.cjs +217 -0
  96. package/dist/draft20.d.cts +175 -0
  97. package/dist/draft20.d.ts +175 -0
  98. package/dist/draft20.js +217 -0
  99. package/dist/index.cjs +9 -3
  100. package/dist/index.d.cts +32 -17
  101. package/dist/index.d.ts +32 -17
  102. package/dist/index.js +8 -2
  103. package/dist/{session-types-KsqyVp6y.d.cts → session-types-B9idsih-.d.cts} +13 -1
  104. package/dist/{session-types-C0l_-Ups.d.ts → session-types-Dn7b3RZM.d.ts} +13 -1
  105. package/dist/session.cjs +5 -1
  106. package/dist/session.d.cts +5 -3
  107. package/dist/session.d.ts +5 -3
  108. package/dist/session.js +5 -1
  109. package/dist/types-CWCQwuvy.d.cts +359 -0
  110. package/dist/types-CWCQwuvy.d.ts +359 -0
  111. package/dist/{types-CdCSIfvI.d.cts → types-DG5_iKuv.d.cts} +1 -1
  112. package/dist/{types-CdCSIfvI.d.ts → types-DG5_iKuv.d.ts} +1 -1
  113. package/package.json +16 -3
  114. package/src/core/session-types.ts +7 -0
  115. package/src/core/types.ts +1 -0
  116. package/src/drafts/draft19/codec.ts +4 -2
  117. package/src/drafts/draft20/codec.ts +1737 -0
  118. package/src/drafts/draft20/data-streams.ts +1291 -0
  119. package/src/drafts/draft20/error-codes.ts +135 -0
  120. package/src/drafts/draft20/index.ts +165 -0
  121. package/src/drafts/draft20/messages.ts +68 -0
  122. package/src/drafts/draft20/rules.ts +107 -0
  123. package/src/drafts/draft20/session-fsm.ts +1063 -0
  124. package/src/drafts/draft20/session.ts +21 -0
  125. package/src/drafts/draft20/types.ts +455 -0
  126. package/src/index.ts +20 -2
  127. package/src/session.ts +4 -0
@@ -0,0 +1,1063 @@
1
+ import type {
2
+ AnnounceState,
3
+ FetchState,
4
+ ProtocolViolation,
5
+ PublishState,
6
+ SessionPhase,
7
+ SideEffect,
8
+ SubscriptionState,
9
+ TransitionResult,
10
+ ValidationResult,
11
+ } from '../../core/session-types.js'
12
+ import { getLegalIncoming, getLegalOutgoing } from './rules.js'
13
+ import type {
14
+ Draft20FillParameters,
15
+ Draft20Message,
16
+ Draft20MessageType,
17
+ Draft20Params,
18
+ } from './types.js'
19
+
20
+ /**
21
+ * What kind of request a bidirectional request stream carries.
22
+ *
23
+ * draft-20 keeps the draft-17 shape where a response is tied to its request by
24
+ * the stream it arrives on rather than by a Request ID field. Several draft-20
25
+ * rules are stated per request *kind* — most sharply
26
+ * "PUBLISH_STATE_NOTIFY applies only to subscriptions ... An endpoint that
27
+ * receives a PUBLISH_STATE_NOTIFY for any other request type ... MUST close
28
+ * the session with a PROTOCOL_VIOLATION" (Section 10.10) — so the FSM has to
29
+ * know which stream a message arrived on to enforce them. That is what
30
+ * {@link Draft20SessionFSM.receiveOn} and {@link Draft20SessionFSM.sendOn} are
31
+ * for.
32
+ */
33
+ export type RequestKind =
34
+ | 'subscription'
35
+ | 'fetch'
36
+ | 'track_status'
37
+ | 'publish_namespace'
38
+ | 'subscribe_namespace'
39
+ | 'subscribe_tracks'
40
+
41
+ /** Whether the local endpoint or the peer is the publisher on a request stream. */
42
+ export type PublisherSide = 'local' | 'remote'
43
+
44
+ export type FillFetchStreamPhase = 'open' | 'complete' | 'reset' | 'cancelled'
45
+
46
+ /**
47
+ * One fill fetch stream — draft-20 Section 5.1.3, new in draft-20.
48
+ *
49
+ * A unidirectional stream that begins with FETCH_HEADER and is delivered
50
+ * exactly as a FETCH response, opened because a SUBSCRIBE or REQUEST_UPDATE
51
+ * carried FILL_PARAMETERS. It is NOT a FETCH: there is no FETCH request, no
52
+ * FETCH_OK and no REQUEST_ERROR for it, so it never appears in
53
+ * {@link Draft20SessionFSM.fetches}.
54
+ */
55
+ export interface FillFetchStreamState {
56
+ /**
57
+ * The Request ID of the message that opened it — the SUBSCRIBE's for the
58
+ * initial fill, the REQUEST_UPDATE's for a later one (Section 5.1.3). This
59
+ * is what the stream's FETCH_HEADER carries, and it is how a subscription's
60
+ * concurrent fills are told apart.
61
+ */
62
+ readonly requestId: bigint
63
+ /** The Request ID of the subscription the fill belongs to. */
64
+ readonly subscribeId: bigint
65
+ readonly phase: FillFetchStreamPhase
66
+ }
67
+
68
+ /** Draft-20 subscription bookkeeping beyond the draft-agnostic SubscriptionState. */
69
+ interface SubscriptionMeta {
70
+ publisherSide: PublisherSide
71
+ /**
72
+ * Forward State, Section 5.1 / Section 10.2.18. Defaults to 1; FORWARD=0
73
+ * turns delivery off. It gates fill fetch streams — see
74
+ * {@link Draft20SessionFSM.applyFillParameters}.
75
+ */
76
+ forwardState: 0 | 1
77
+ /**
78
+ * Every fill fetch stream this subscription has, keyed by the Request ID
79
+ * that opened it. Plural on purpose: Section 5.1.3 says "a subscription can
80
+ * have multiple fill fetch streams open at once, each identified by its
81
+ * Request ID; opening a new fill fetch stream does not implicitly cancel any
82
+ * previously opened fill fetch streams." Any model that keeps one fetch
83
+ * stream per subscription is wrong for draft-20.
84
+ */
85
+ fills: Map<bigint, FillFetchStreamState>
86
+ }
87
+
88
+ /** Per-request-stream state that is not specific to subscriptions. */
89
+ interface RequestMeta {
90
+ readonly kind: RequestKind
91
+ readonly publisherSide: PublisherSide
92
+ /**
93
+ * REQUEST_UPDATEs sent on this stream and not yet answered by a REQUEST_OK
94
+ * or REQUEST_ERROR (Section 10.3.1.7). PUBLISH_STATE_NOTIFY deliberately
95
+ * does not touch this counter.
96
+ */
97
+ outstandingUpdates: number
98
+ }
99
+
100
+ function violation(
101
+ code: ProtocolViolation<Draft20MessageType>['code'],
102
+ message: string,
103
+ currentPhase: SessionPhase,
104
+ offendingMessage: Draft20MessageType,
105
+ ): ProtocolViolation<Draft20MessageType> {
106
+ return { code, message, currentPhase, offendingMessage }
107
+ }
108
+
109
+ function hasFillParameters(params: Draft20Params | undefined): Draft20FillParameters | undefined {
110
+ return params?.fill_parameters
111
+ }
112
+
113
+ export class Draft20SessionFSM {
114
+ private _phase: SessionPhase = 'idle'
115
+ private _role: 'client' | 'server'
116
+ private _setupDirection: 'inbound' | 'outbound' | null = null
117
+ private _subscriptions = new Map<bigint, SubscriptionState>()
118
+ private _subscriptionMeta = new Map<bigint, SubscriptionMeta>()
119
+ private _publishes = new Map<bigint, PublishState>()
120
+ private _fetches = new Map<bigint, FetchState>()
121
+ private _requests = new Map<bigint, RequestMeta>()
122
+ private _requestIds = new Set<bigint>()
123
+ private _pendingSubscribes: bigint[] = []
124
+ private _pendingPublishes: bigint[] = []
125
+ private _pendingFetches: bigint[] = []
126
+ /**
127
+ * The peer's MAX_REQUEST_UPDATES (Setup Option 0x08), learned from its SETUP.
128
+ * 0 means the peer does not limit REQUEST_UPDATE concurrency, and 0 is also
129
+ * the default when the option is absent.
130
+ */
131
+ private _peerMaxRequestUpdates = 0n
132
+ /** The local endpoint's own advertised MAX_REQUEST_UPDATES. */
133
+ private _localMaxRequestUpdates = 0n
134
+
135
+ constructor(role: 'client' | 'server') {
136
+ this._role = role
137
+ }
138
+
139
+ get phase(): SessionPhase {
140
+ return this._phase
141
+ }
142
+ get role(): 'client' | 'server' {
143
+ return this._role
144
+ }
145
+ get subscriptions(): ReadonlyMap<bigint, SubscriptionState> {
146
+ return this._subscriptions
147
+ }
148
+ get announces(): ReadonlyMap<string, AnnounceState> {
149
+ return new Map()
150
+ }
151
+ get publishes(): ReadonlyMap<bigint, PublishState> {
152
+ return this._publishes
153
+ }
154
+ get fetches(): ReadonlyMap<bigint, FetchState> {
155
+ return this._fetches
156
+ }
157
+
158
+ get legalOutgoing(): ReadonlySet<Draft20MessageType> {
159
+ return getLegalOutgoing(this._phase, this._role)
160
+ }
161
+
162
+ get legalIncoming(): ReadonlySet<Draft20MessageType> {
163
+ return getLegalIncoming(this._phase, this._role)
164
+ }
165
+
166
+ /**
167
+ * Every fill fetch stream currently tracked, across all subscriptions, keyed
168
+ * by the Request ID that opened it.
169
+ *
170
+ * Fill fetch streams are unidirectional and carry no control messages, so
171
+ * nothing here is inferred from a message alone; the transport events that
172
+ * end one are reported with {@link closeFillFetchStream}.
173
+ */
174
+ get fillFetchStreams(): ReadonlyMap<bigint, FillFetchStreamState> {
175
+ const all = new Map<bigint, FillFetchStreamState>()
176
+ for (const meta of this._subscriptionMeta.values()) {
177
+ for (const [requestId, fill] of meta.fills) all.set(requestId, fill)
178
+ }
179
+ return all
180
+ }
181
+
182
+ /** The fill fetch streams belonging to one subscription. */
183
+ fillFetchStreamsFor(subscribeId: bigint): ReadonlyMap<bigint, FillFetchStreamState> {
184
+ return this._subscriptionMeta.get(subscribeId)?.fills ?? new Map()
185
+ }
186
+
187
+ /** A subscription's current Forward State, or undefined if it is unknown here. */
188
+ forwardStateOf(subscribeId: bigint): 0 | 1 | undefined {
189
+ return this._subscriptionMeta.get(subscribeId)?.forwardState
190
+ }
191
+
192
+ /** What kind of request a Request ID names, or undefined if it names none. */
193
+ requestKindOf(requestId: bigint): RequestKind | undefined {
194
+ return this._requests.get(requestId)?.kind
195
+ }
196
+
197
+ private checkDuplicateRequestId(
198
+ requestId: bigint,
199
+ msgType: Draft20MessageType,
200
+ ): ProtocolViolation<Draft20MessageType> | null {
201
+ if (this._requestIds.has(requestId)) {
202
+ return violation(
203
+ 'DUPLICATE_REQUEST_ID',
204
+ `Request ID ${requestId} already in use`,
205
+ this._phase,
206
+ msgType,
207
+ )
208
+ }
209
+ return null
210
+ }
211
+
212
+ validateOutgoing(message: Draft20Message): ValidationResult<Draft20MessageType> {
213
+ if (!this.legalOutgoing.has(message.type)) {
214
+ return {
215
+ ok: false,
216
+ violation: violation(
217
+ this._phase === 'idle' || this._phase === 'setup'
218
+ ? 'MESSAGE_BEFORE_SETUP'
219
+ : 'UNEXPECTED_MESSAGE',
220
+ `Cannot send ${message.type} in phase ${this._phase}`,
221
+ this._phase,
222
+ message.type,
223
+ ),
224
+ }
225
+ }
226
+ return { ok: true }
227
+ }
228
+
229
+ receive(message: Draft20Message): TransitionResult<Draft20MessageType> {
230
+ return this.applyTransition(message, 'inbound', undefined)
231
+ }
232
+
233
+ send(message: Draft20Message): TransitionResult<Draft20MessageType> {
234
+ return this.applyTransition(message, 'outbound', undefined)
235
+ }
236
+
237
+ /**
238
+ * Receive a message that arrived on a known request stream.
239
+ *
240
+ * Prefer this over {@link receive} for anything that carries no Request ID of
241
+ * its own — SUBSCRIBE_OK, REQUEST_OK, REQUEST_ERROR, PUBLISH_DONE, FETCH_OK
242
+ * and PUBLISH_STATE_NOTIFY. Without the stream, the request-kind and
243
+ * direction rules of Section 10.10 cannot be checked at all, and the
244
+ * response-matching below falls back to FIFO guessing.
245
+ */
246
+ receiveOn(requestId: bigint, message: Draft20Message): TransitionResult<Draft20MessageType> {
247
+ return this.applyTransition(message, 'inbound', requestId)
248
+ }
249
+
250
+ /** Send a message on a known request stream. See {@link receiveOn}. */
251
+ sendOn(requestId: bigint, message: Draft20Message): TransitionResult<Draft20MessageType> {
252
+ return this.applyTransition(message, 'outbound', requestId)
253
+ }
254
+
255
+ private applyTransition(
256
+ message: Draft20Message,
257
+ direction: 'inbound' | 'outbound',
258
+ streamRequestId: bigint | undefined,
259
+ ): TransitionResult<Draft20MessageType> {
260
+ const sideEffects: SideEffect[] = []
261
+
262
+ switch (message.type) {
263
+ case 'setup':
264
+ return this.handleSetup(message, direction)
265
+ case 'goaway':
266
+ return this.handleGoAway(message, sideEffects)
267
+
268
+ case 'subscribe':
269
+ return this.handleSubscribe(message, direction, sideEffects)
270
+ case 'subscribe_ok':
271
+ return this.handleSubscribeOk(streamRequestId, sideEffects)
272
+ case 'request_update':
273
+ return this.handleRequestUpdate(message, direction, streamRequestId, sideEffects)
274
+ case 'publish_state_notify':
275
+ return this.handlePublishStateNotify(direction, streamRequestId, sideEffects)
276
+
277
+ case 'publish':
278
+ return this.handlePublish(message, direction, sideEffects)
279
+ case 'publish_done':
280
+ return this.handlePublishDone(streamRequestId, sideEffects)
281
+
282
+ case 'fetch':
283
+ return this.handleFetch(message, direction, sideEffects)
284
+ case 'fetch_ok':
285
+ return this.handleFetchOk(streamRequestId, sideEffects)
286
+
287
+ case 'request_ok':
288
+ return this.handleRequestOk(streamRequestId, sideEffects)
289
+ case 'request_error':
290
+ return this.handleRequestError(streamRequestId, sideEffects)
291
+
292
+ case 'track_status':
293
+ return this.handleGenericRequest(message, 'track_status', direction, sideEffects)
294
+ case 'publish_namespace':
295
+ return this.handleGenericRequest(message, 'publish_namespace', direction, sideEffects)
296
+ case 'subscribe_namespace':
297
+ return this.handleGenericRequest(message, 'subscribe_namespace', direction, sideEffects)
298
+ case 'subscribe_tracks':
299
+ return this.handleGenericRequest(message, 'subscribe_tracks', direction, sideEffects)
300
+
301
+ default:
302
+ return this.handleReadyPhaseMessage(message)
303
+ }
304
+ }
305
+
306
+ private handleSetup(
307
+ message: Draft20Message,
308
+ direction: 'inbound' | 'outbound',
309
+ ): TransitionResult<Draft20MessageType> {
310
+ const setup = message as import('./types.js').Draft20Setup
311
+ // Section 10.3.1.7: absent, MAX_REQUEST_UPDATES defaults to 0, which means
312
+ // the endpoint does not limit REQUEST_UPDATE concurrency.
313
+ const advertised = setup.options.max_request_updates ?? 0n
314
+ if (direction === 'inbound') this._peerMaxRequestUpdates = advertised
315
+ else this._localMaxRequestUpdates = advertised
316
+
317
+ if (this._phase === 'idle') {
318
+ this._setupDirection = direction
319
+ this._phase = 'setup'
320
+ return { ok: true, phase: this._phase, sideEffects: [] }
321
+ }
322
+ if (this._phase === 'setup') {
323
+ if (direction === this._setupDirection) {
324
+ return {
325
+ ok: false,
326
+ violation: violation(
327
+ 'SETUP_VIOLATION',
328
+ `Second SETUP must be ${this._setupDirection === 'inbound' ? 'outbound' : 'inbound'}, got ${direction}`,
329
+ this._phase,
330
+ 'setup',
331
+ ),
332
+ }
333
+ }
334
+ this._phase = 'ready'
335
+ return {
336
+ ok: true,
337
+ phase: this._phase,
338
+ sideEffects: [{ type: 'session-ready' }],
339
+ }
340
+ }
341
+ return {
342
+ ok: false,
343
+ violation: violation(
344
+ 'SETUP_VIOLATION',
345
+ 'SETUP not valid in current phase',
346
+ this._phase,
347
+ 'setup',
348
+ ),
349
+ }
350
+ }
351
+
352
+ private handleGoAway(
353
+ message: Draft20Message,
354
+ sideEffects: SideEffect[],
355
+ ): TransitionResult<Draft20MessageType> {
356
+ if (this._phase !== 'ready' && this._phase !== 'draining') {
357
+ return {
358
+ ok: false,
359
+ violation: violation(
360
+ 'UNEXPECTED_MESSAGE',
361
+ `GOAWAY not valid in phase ${this._phase}`,
362
+ this._phase,
363
+ 'goaway',
364
+ ),
365
+ }
366
+ }
367
+ this._phase = 'draining'
368
+ const goaway = message as import('./types.js').Draft20GoAway
369
+ sideEffects.push({
370
+ type: 'session-draining',
371
+ goAwayUri: goaway.new_session_uri,
372
+ })
373
+ return { ok: true, phase: this._phase, sideEffects }
374
+ }
375
+
376
+ private requireReady(msgType: Draft20MessageType): ProtocolViolation<Draft20MessageType> | null {
377
+ if (this._phase !== 'ready' && this._phase !== 'draining') {
378
+ return violation(
379
+ this._phase === 'idle' || this._phase === 'setup'
380
+ ? 'MESSAGE_BEFORE_SETUP'
381
+ : 'UNEXPECTED_MESSAGE',
382
+ `${msgType} requires ready phase, current: ${this._phase}`,
383
+ this._phase,
384
+ msgType,
385
+ )
386
+ }
387
+ return null
388
+ }
389
+
390
+ private handleSubscribe(
391
+ message: Draft20Message,
392
+ direction: 'inbound' | 'outbound',
393
+ sideEffects: SideEffect[],
394
+ ): TransitionResult<Draft20MessageType> {
395
+ const err = this.requireReady(message.type)
396
+ if (err) return { ok: false, violation: err }
397
+ const sub = message as import('./types.js').Draft20Subscribe
398
+ const dupErr = this.checkDuplicateRequestId(sub.request_id, message.type)
399
+ if (dupErr) return { ok: false, violation: dupErr }
400
+ this._requestIds.add(sub.request_id)
401
+ // The sender of SUBSCRIBE is the subscriber, so the peer is the publisher.
402
+ const publisherSide: PublisherSide = direction === 'outbound' ? 'remote' : 'local'
403
+ this._subscriptions.set(sub.request_id, {
404
+ subscribeId: sub.request_id,
405
+ phase: 'pending',
406
+ trackNamespace: sub.track_namespace,
407
+ trackName: sub.track_name,
408
+ })
409
+ // FORWARD defaults to 1 (Section 10.2.18).
410
+ const forwardState: 0 | 1 = sub.parameters.forward === 0n ? 0 : 1
411
+ this._subscriptionMeta.set(sub.request_id, {
412
+ publisherSide,
413
+ forwardState,
414
+ fills: new Map(),
415
+ })
416
+ this._requests.set(sub.request_id, {
417
+ kind: 'subscription',
418
+ publisherSide,
419
+ outstandingUpdates: 0,
420
+ })
421
+ this._pendingSubscribes.push(sub.request_id)
422
+
423
+ this.applyFillParameters(sub.request_id, sub.request_id, sub.parameters, sideEffects)
424
+ return { ok: true, phase: this._phase, sideEffects }
425
+ }
426
+
427
+ /**
428
+ * Open a fill fetch stream if this message asked for one — draft-20 Sections
429
+ * 5.1.3 and 5.1.3.1, both new in draft-20.
430
+ *
431
+ * Three rules, all of which the draft states as bullets and all of which are
432
+ * easy to get wrong by analogy with draft-19's Joining FETCH:
433
+ *
434
+ * 1. Presence of FILL_PARAMETERS is the request. There is no separate
435
+ * message and no Fetch Type; an empty FILL_PARAMETERS still opens a
436
+ * stream, with every setting inherited from the subscription.
437
+ * 2. It only opens one "while Forward State is 1". FILL_PARAMETERS carried
438
+ * while Forward State is 0 opens nothing, and later transitioning to
439
+ * Forward State 1 without re-sending FILL_PARAMETERS opens nothing
440
+ * either — so this is checked at the moment the message is processed and
441
+ * never retried.
442
+ * 3. Streams accumulate. The new one is keyed by the opening message's
443
+ * Request ID and does not disturb any stream already open on the same
444
+ * subscription.
445
+ *
446
+ * The opened stream has no FETCH_OK: per DECISIONS.md D7 (SPEC-DELTA Section
447
+ * 11 Q9) there is no End Location and no End Of Track for a fill, and stream
448
+ * FIN is the only completion signal. Nothing here synthesizes one, and the
449
+ * stream is deliberately absent from {@link fetches}.
450
+ */
451
+ private applyFillParameters(
452
+ subscribeId: bigint,
453
+ openingRequestId: bigint,
454
+ params: Draft20Params,
455
+ sideEffects: SideEffect[],
456
+ ): void {
457
+ if (hasFillParameters(params) === undefined) return
458
+ const meta = this._subscriptionMeta.get(subscribeId)
459
+ if (meta === undefined) return
460
+ if (meta.forwardState !== 1) return
461
+ meta.fills.set(openingRequestId, {
462
+ requestId: openingRequestId,
463
+ subscribeId,
464
+ phase: 'open',
465
+ })
466
+ sideEffects.push({
467
+ type: 'fill-fetch-stream-opened',
468
+ subscribeId,
469
+ requestId: openingRequestId,
470
+ })
471
+ }
472
+
473
+ /**
474
+ * Report the transport event that ended a fill fetch stream.
475
+ *
476
+ * A fill fetch stream carries no control message of its own, so its end is
477
+ * never inferable from the message sequence:
478
+ *
479
+ * - `'complete'` — the publisher FINed the stream. Section 5.1.3.1 makes
480
+ * that the ONLY completion signal; there is no FETCH_OK to read an End
481
+ * Location or an End Of Track flag from (DECISIONS.md D7 / Q9).
482
+ * - `'reset'` — the publisher reset it, which is how a fill failure is
483
+ * signalled, "because there is no REQUEST_ERROR associated with a fill
484
+ * fetch stream".
485
+ * - `'cancelled'` — the subscriber sent STOP_SENDING.
486
+ *
487
+ * Either way "Resetting or cancelling a fill fetch stream, by either
488
+ * endpoint, does not affect the subscription, which continues to deliver
489
+ * objects using subscribe subgroups and datagrams", so the subscription's
490
+ * own state is untouched here.
491
+ */
492
+ closeFillFetchStream(
493
+ requestId: bigint,
494
+ phase: Exclude<FillFetchStreamPhase, 'open'>,
495
+ ): SideEffect[] {
496
+ for (const meta of this._subscriptionMeta.values()) {
497
+ const fill = meta.fills.get(requestId)
498
+ if (fill === undefined) continue
499
+ meta.fills.set(requestId, { ...fill, phase })
500
+ return [
501
+ {
502
+ type: 'fill-fetch-stream-closed',
503
+ subscribeId: fill.subscribeId,
504
+ requestId,
505
+ reason: phase,
506
+ },
507
+ ]
508
+ }
509
+ return []
510
+ }
511
+
512
+ private handleSubscribeOk(
513
+ streamRequestId: bigint | undefined,
514
+ sideEffects: SideEffect[],
515
+ ): TransitionResult<Draft20MessageType> {
516
+ const err = this.requireReady('subscribe_ok')
517
+ if (err) return { ok: false, violation: err }
518
+ const requestId = streamRequestId ?? this._pendingSubscribes.shift()
519
+ if (requestId === undefined) {
520
+ return {
521
+ ok: false,
522
+ violation: violation(
523
+ 'UNEXPECTED_MESSAGE',
524
+ 'SUBSCRIBE_OK with no pending subscribe',
525
+ this._phase,
526
+ 'subscribe_ok',
527
+ ),
528
+ }
529
+ }
530
+ if (streamRequestId !== undefined) {
531
+ this.dropPending(this._pendingSubscribes, streamRequestId)
532
+ }
533
+ const existing = this._subscriptions.get(requestId)
534
+ if (existing && existing.phase === 'pending') {
535
+ this._subscriptions.set(requestId, { ...existing, phase: 'active' })
536
+ sideEffects.push({
537
+ type: 'subscription-activated',
538
+ subscribeId: requestId,
539
+ })
540
+ }
541
+ return { ok: true, phase: this._phase, sideEffects }
542
+ }
543
+
544
+ private handleRequestUpdate(
545
+ message: Draft20Message,
546
+ direction: 'inbound' | 'outbound',
547
+ streamRequestId: bigint | undefined,
548
+ sideEffects: SideEffect[],
549
+ ): TransitionResult<Draft20MessageType> {
550
+ const err = this.requireReady(message.type)
551
+ if (err) return { ok: false, violation: err }
552
+ const update = message as import('./types.js').Draft20RequestUpdate
553
+ const dupErr = this.checkDuplicateRequestId(update.request_id, message.type)
554
+ if (dupErr) return { ok: false, violation: dupErr }
555
+ this._requestIds.add(update.request_id)
556
+
557
+ // The stream the REQUEST_UPDATE travels on is the request it updates. It
558
+ // carries its own Request ID too, but that ID names the update, not the
559
+ // request being updated (Section 10.1), so a caller that does not pass the
560
+ // stream cannot be told which subscription this touches.
561
+ const targetId = streamRequestId
562
+ const target = targetId !== undefined ? this._requests.get(targetId) : undefined
563
+
564
+ if (target !== undefined) {
565
+ // Section 10.3.1.7: "If an endpoint receives a REQUEST_UPDATE on a stream
566
+ // that already has MAX_REQUEST_UPDATES outstanding REQUEST_UPDATEs, it
567
+ // MUST close the session with TOO_MANY_REQUEST_UPDATES." The limit that
568
+ // applies is the receiver's advertised one.
569
+ const limit =
570
+ direction === 'inbound' ? this._localMaxRequestUpdates : this._peerMaxRequestUpdates
571
+ if (limit > 0n && BigInt(target.outstandingUpdates) >= limit) {
572
+ return {
573
+ ok: false,
574
+ violation: violation(
575
+ 'STATE_VIOLATION',
576
+ `REQUEST_UPDATE on request ${targetId} exceeds MAX_REQUEST_UPDATES (${limit})`,
577
+ this._phase,
578
+ 'request_update',
579
+ ),
580
+ }
581
+ }
582
+ target.outstandingUpdates += 1
583
+ }
584
+
585
+ if (targetId !== undefined && target !== undefined) {
586
+ if (target.kind !== 'subscription') {
587
+ // Section 10.2.15: FILL_PARAMETERS "MAY appear in a SUBSCRIBE or
588
+ // REQUEST_UPDATE (for a subscription) message". The codec cannot check
589
+ // the parenthetical — a REQUEST_UPDATE looks the same whichever request
590
+ // it updates — so the scope rule of Section 10.2.1 lands here, where
591
+ // the request stream says what kind of request this is.
592
+ if (hasFillParameters(update.parameters) !== undefined) {
593
+ return {
594
+ ok: false,
595
+ violation: violation(
596
+ 'STATE_VIOLATION',
597
+ `FILL_PARAMETERS may only appear in a REQUEST_UPDATE for a subscription, but request ${targetId} is a ${target.kind}`,
598
+ this._phase,
599
+ 'request_update',
600
+ ),
601
+ }
602
+ }
603
+ } else {
604
+ const meta = this._subscriptionMeta.get(targetId)
605
+ if (meta !== undefined) {
606
+ // Sticky parameters (Section 10.9): an omitted parameter keeps its
607
+ // value, so Forward State only moves when FORWARD is actually present.
608
+ if (update.parameters.forward !== undefined) {
609
+ meta.forwardState = update.parameters.forward === 0n ? 0 : 1
610
+ }
611
+ // Section 5.1.3.1: "A REQUEST_UPDATE that does not carry
612
+ // FILL_PARAMETERS does not open a new fill fetch stream." When it does
613
+ // carry them, the new stream is keyed by the REQUEST_UPDATE's own
614
+ // Request ID and joins whatever is already open on this subscription.
615
+ this.applyFillParameters(targetId, update.request_id, update.parameters, sideEffects)
616
+ }
617
+ }
618
+ }
619
+
620
+ return { ok: true, phase: this._phase, sideEffects }
621
+ }
622
+
623
+ /**
624
+ * PUBLISH_STATE_NOTIFY — draft-20 Section 10.10, new in draft-20.
625
+ *
626
+ * Three properties set it apart from every other control message, and all
627
+ * three are enforced here:
628
+ *
629
+ * - **Unilateral.** "The receiver does not respond with REQUEST_OK or
630
+ * REQUEST_ERROR", so nothing is queued as pending and nothing is
631
+ * dequeued. A REQUEST_OK arriving later belongs to some other request.
632
+ * - **Not rate-limited.** "The message is not subject to the
633
+ * MAX_REQUEST_UPDATES limit", so `outstandingUpdates` is deliberately not
634
+ * touched — that counter exists only for REQUEST_UPDATE.
635
+ * - **Publisher to subscriber, subscriptions only.** "An endpoint that
636
+ * receives a PUBLISH_STATE_NOTIFY for any other request type, or from the
637
+ * subscriber, MUST close the session with a PROTOCOL_VIOLATION."
638
+ *
639
+ * The last one needs the request stream, because the message has no Request
640
+ * ID field. Called through {@link receive}/{@link send} without one, the
641
+ * kind and direction rules cannot be evaluated and the message is allowed
642
+ * through unchecked rather than guessed at.
643
+ */
644
+ private handlePublishStateNotify(
645
+ direction: 'inbound' | 'outbound',
646
+ streamRequestId: bigint | undefined,
647
+ sideEffects: SideEffect[],
648
+ ): TransitionResult<Draft20MessageType> {
649
+ const err = this.requireReady('publish_state_notify')
650
+ if (err) return { ok: false, violation: err }
651
+
652
+ if (streamRequestId === undefined) {
653
+ // No stream, no verdict. See receiveOn().
654
+ return { ok: true, phase: this._phase, sideEffects }
655
+ }
656
+
657
+ const request = this._requests.get(streamRequestId)
658
+ if (request === undefined) {
659
+ return {
660
+ ok: false,
661
+ violation: violation(
662
+ 'UNKNOWN_REQUEST_ID',
663
+ `PUBLISH_STATE_NOTIFY on unknown request ${streamRequestId}`,
664
+ this._phase,
665
+ 'publish_state_notify',
666
+ ),
667
+ }
668
+ }
669
+ if (request.kind !== 'subscription') {
670
+ return {
671
+ ok: false,
672
+ violation: violation(
673
+ 'STATE_VIOLATION',
674
+ `PUBLISH_STATE_NOTIFY applies only to subscriptions, but request ${streamRequestId} is a ${request.kind}`,
675
+ this._phase,
676
+ 'publish_state_notify',
677
+ ),
678
+ }
679
+ }
680
+ const fromPublisher =
681
+ direction === 'outbound'
682
+ ? request.publisherSide === 'local'
683
+ : request.publisherSide === 'remote'
684
+ if (!fromPublisher) {
685
+ return {
686
+ ok: false,
687
+ violation: violation(
688
+ 'ROLE_VIOLATION',
689
+ `PUBLISH_STATE_NOTIFY is sent only by the publisher; request ${streamRequestId} has the publisher on the other side`,
690
+ this._phase,
691
+ 'publish_state_notify',
692
+ ),
693
+ }
694
+ }
695
+
696
+ sideEffects.push({ type: 'subscription-state-notified', subscribeId: streamRequestId })
697
+ return { ok: true, phase: this._phase, sideEffects }
698
+ }
699
+
700
+ private handlePublish(
701
+ message: Draft20Message,
702
+ direction: 'inbound' | 'outbound',
703
+ sideEffects: SideEffect[],
704
+ ): TransitionResult<Draft20MessageType> {
705
+ const err = this.requireReady(message.type)
706
+ if (err) return { ok: false, violation: err }
707
+ const pub = message as import('./types.js').Draft20Publish
708
+ const dupErr = this.checkDuplicateRequestId(pub.request_id, message.type)
709
+ if (dupErr) return { ok: false, violation: dupErr }
710
+ this._requestIds.add(pub.request_id)
711
+ // The sender of PUBLISH is the publisher.
712
+ const publisherSide: PublisherSide = direction === 'outbound' ? 'local' : 'remote'
713
+ this._publishes.set(pub.request_id, {
714
+ requestId: pub.request_id,
715
+ phase: 'pending',
716
+ })
717
+ // A PUBLISH initiates a subscription (Section 10.11), so it is a
718
+ // subscription request stream for the purposes of Section 10.10, and it
719
+ // now carries the initial Subscription Parameters that draft-19 put on
720
+ // PUBLISH_OK.
721
+ this._subscriptions.set(pub.request_id, {
722
+ subscribeId: pub.request_id,
723
+ phase: 'pending',
724
+ trackNamespace: pub.track_namespace,
725
+ trackName: pub.track_name,
726
+ })
727
+ this._subscriptionMeta.set(pub.request_id, {
728
+ publisherSide,
729
+ forwardState: pub.parameters.forward === 0n ? 0 : 1,
730
+ fills: new Map(),
731
+ })
732
+ this._requests.set(pub.request_id, {
733
+ kind: 'subscription',
734
+ publisherSide,
735
+ outstandingUpdates: 0,
736
+ })
737
+ this._pendingPublishes.push(pub.request_id)
738
+ return { ok: true, phase: this._phase, sideEffects }
739
+ }
740
+
741
+ private handlePublishDone(
742
+ streamRequestId: bigint | undefined,
743
+ sideEffects: SideEffect[],
744
+ ): TransitionResult<Draft20MessageType> {
745
+ const err = this.requireReady('publish_done')
746
+ if (err) return { ok: false, violation: err }
747
+
748
+ // PUBLISH_DONE has no Request ID; the stream it arrives on identifies the
749
+ // subscription. Without one, fall back to the oldest active publish.
750
+ let requestId = streamRequestId
751
+ if (requestId === undefined) {
752
+ for (const [reqId, pub] of this._publishes) {
753
+ if (pub.phase === 'active') {
754
+ requestId = reqId
755
+ break
756
+ }
757
+ }
758
+ }
759
+ if (requestId === undefined) return { ok: true, phase: this._phase, sideEffects }
760
+
761
+ const pub = this._publishes.get(requestId)
762
+ if (pub !== undefined && pub.phase !== 'done') {
763
+ this._publishes.set(requestId, { ...pub, phase: 'done' })
764
+ sideEffects.push({ type: 'publish-ended', requestId, reason: 'done' })
765
+ }
766
+ const sub = this._subscriptions.get(requestId)
767
+ if (sub !== undefined && sub.phase !== 'done') {
768
+ this._subscriptions.set(requestId, { ...sub, phase: 'done' })
769
+ sideEffects.push({
770
+ type: 'subscription-ended',
771
+ subscribeId: requestId,
772
+ reason: 'publish_done',
773
+ })
774
+ }
775
+ // Section 5.1.3.1: "When the subscription is cancelled, the publisher MUST
776
+ // reset any open fill fetch streams." They also count toward
777
+ // PUBLISH_DONE.Stream Count (Section 10.12), so a receiver reconciling that
778
+ // count has to have them in view.
779
+ const meta = this._subscriptionMeta.get(requestId)
780
+ if (meta !== undefined) {
781
+ for (const [fillId, fill] of meta.fills) {
782
+ if (fill.phase !== 'open') continue
783
+ meta.fills.set(fillId, { ...fill, phase: 'reset' })
784
+ sideEffects.push({
785
+ type: 'fill-fetch-stream-closed',
786
+ subscribeId: requestId,
787
+ requestId: fillId,
788
+ reason: 'subscription-ended',
789
+ })
790
+ }
791
+ }
792
+ return { ok: true, phase: this._phase, sideEffects }
793
+ }
794
+
795
+ private handleFetch(
796
+ message: Draft20Message,
797
+ direction: 'inbound' | 'outbound',
798
+ sideEffects: SideEffect[],
799
+ ): TransitionResult<Draft20MessageType> {
800
+ const err = this.requireReady(message.type)
801
+ if (err) return { ok: false, violation: err }
802
+ const fetch = message as import('./types.js').Draft20Fetch
803
+ const dupErr = this.checkDuplicateRequestId(fetch.request_id, message.type)
804
+ if (dupErr) return { ok: false, violation: dupErr }
805
+ this._requestIds.add(fetch.request_id)
806
+ // draft-20 has no Joining FETCH: a FETCH names its own track inline and
807
+ // never references another request (Section 10.13). Nothing to resolve
808
+ // against an existing subscription, and no INVALID_JOINING_REQUEST_ID to
809
+ // raise — the error code went with the feature.
810
+ this._fetches.set(fetch.request_id, {
811
+ requestId: fetch.request_id,
812
+ phase: 'pending',
813
+ })
814
+ this._requests.set(fetch.request_id, {
815
+ kind: 'fetch',
816
+ // The receiver of a FETCH serves it, so the peer publishes the objects.
817
+ publisherSide: direction === 'outbound' ? 'remote' : 'local',
818
+ outstandingUpdates: 0,
819
+ })
820
+ this._pendingFetches.push(fetch.request_id)
821
+ return { ok: true, phase: this._phase, sideEffects }
822
+ }
823
+
824
+ private handleFetchOk(
825
+ streamRequestId: bigint | undefined,
826
+ sideEffects: SideEffect[],
827
+ ): TransitionResult<Draft20MessageType> {
828
+ const err = this.requireReady('fetch_ok')
829
+ if (err) return { ok: false, violation: err }
830
+ const requestId = streamRequestId ?? this._pendingFetches.shift()
831
+ if (requestId === undefined) {
832
+ return {
833
+ ok: false,
834
+ violation: violation(
835
+ 'UNEXPECTED_MESSAGE',
836
+ 'FETCH_OK with no pending fetch',
837
+ this._phase,
838
+ 'fetch_ok',
839
+ ),
840
+ }
841
+ }
842
+ if (streamRequestId !== undefined) {
843
+ // A FETCH_OK can only answer a FETCH. A fill fetch stream is a FETCH
844
+ // response without a FETCH request, so it has no FETCH_OK at all
845
+ // (DECISIONS.md D7 / SPEC-DELTA Section 11 Q9) — its Request ID names a
846
+ // SUBSCRIBE or REQUEST_UPDATE, and one turning up here means the peer
847
+ // synthesized a FETCH_OK for a fill.
848
+ const kind = this._requests.get(requestId)?.kind
849
+ if (kind !== undefined && kind !== 'fetch') {
850
+ return {
851
+ ok: false,
852
+ violation: violation(
853
+ 'STATE_VIOLATION',
854
+ `FETCH_OK on request ${requestId}, which is a ${kind}; a fill fetch stream has no FETCH_OK`,
855
+ this._phase,
856
+ 'fetch_ok',
857
+ ),
858
+ }
859
+ }
860
+ this.dropPending(this._pendingFetches, requestId)
861
+ }
862
+ const existing = this._fetches.get(requestId)
863
+ if (existing && existing.phase === 'pending') {
864
+ this._fetches.set(requestId, { ...existing, phase: 'active' })
865
+ sideEffects.push({ type: 'fetch-activated', requestId })
866
+ }
867
+ return { ok: true, phase: this._phase, sideEffects }
868
+ }
869
+
870
+ private handleRequestError(
871
+ streamRequestId: bigint | undefined,
872
+ sideEffects: SideEffect[],
873
+ ): TransitionResult<Draft20MessageType> {
874
+ const err = this.requireReady('request_error')
875
+ if (err) return { ok: false, violation: err }
876
+
877
+ if (streamRequestId !== undefined) {
878
+ this.restoreUpdateCredit(streamRequestId)
879
+ const sub = this._subscriptions.get(streamRequestId)
880
+ if (sub !== undefined && sub.phase === 'pending') {
881
+ this._subscriptions.set(streamRequestId, { ...sub, phase: 'error' })
882
+ this.dropPending(this._pendingSubscribes, streamRequestId)
883
+ sideEffects.push({
884
+ type: 'subscription-ended',
885
+ subscribeId: streamRequestId,
886
+ reason: 'request_error',
887
+ })
888
+ }
889
+ const pub = this._publishes.get(streamRequestId)
890
+ if (pub !== undefined && pub.phase === 'pending') {
891
+ this._publishes.set(streamRequestId, { ...pub, phase: 'error' })
892
+ this.dropPending(this._pendingPublishes, streamRequestId)
893
+ sideEffects.push({
894
+ type: 'publish-ended',
895
+ requestId: streamRequestId,
896
+ reason: 'request_error',
897
+ })
898
+ }
899
+ const f = this._fetches.get(streamRequestId)
900
+ if (f !== undefined && f.phase === 'pending') {
901
+ this._fetches.set(streamRequestId, { ...f, phase: 'error' })
902
+ this.dropPending(this._pendingFetches, streamRequestId)
903
+ sideEffects.push({
904
+ type: 'fetch-ended',
905
+ requestId: streamRequestId,
906
+ reason: 'request_error',
907
+ })
908
+ }
909
+ return { ok: true, phase: this._phase, sideEffects }
910
+ }
911
+
912
+ // No stream: REQUEST_ERROR can target any pending request — FIFO dequeue.
913
+ const subId = this.dequeuePending(this._pendingSubscribes, this._subscriptions)
914
+ if (subId !== undefined) {
915
+ const sub = this._subscriptions.get(subId)!
916
+ this._subscriptions.set(subId, { ...sub, phase: 'error' })
917
+ sideEffects.push({
918
+ type: 'subscription-ended',
919
+ subscribeId: subId,
920
+ reason: 'request_error',
921
+ })
922
+ return { ok: true, phase: this._phase, sideEffects }
923
+ }
924
+ const pubId = this.dequeuePending(this._pendingPublishes, this._publishes)
925
+ if (pubId !== undefined) {
926
+ const pub = this._publishes.get(pubId)!
927
+ this._publishes.set(pubId, { ...pub, phase: 'error' })
928
+ sideEffects.push({
929
+ type: 'publish-ended',
930
+ requestId: pubId,
931
+ reason: 'request_error',
932
+ })
933
+ return { ok: true, phase: this._phase, sideEffects }
934
+ }
935
+ const fetchId = this.dequeuePending(this._pendingFetches, this._fetches)
936
+ if (fetchId !== undefined) {
937
+ const f = this._fetches.get(fetchId)!
938
+ this._fetches.set(fetchId, { ...f, phase: 'error' })
939
+ sideEffects.push({
940
+ type: 'fetch-ended',
941
+ requestId: fetchId,
942
+ reason: 'request_error',
943
+ })
944
+ return { ok: true, phase: this._phase, sideEffects }
945
+ }
946
+ // Could be for subscribe_namespace, subscribe_tracks, publish_namespace or
947
+ // track_status — allow through.
948
+ return { ok: true, phase: this._phase, sideEffects }
949
+ }
950
+
951
+ private handleRequestOk(
952
+ streamRequestId: bigint | undefined,
953
+ sideEffects: SideEffect[],
954
+ ): TransitionResult<Draft20MessageType> {
955
+ const err = this.requireReady('request_ok')
956
+ if (err) return { ok: false, violation: err }
957
+
958
+ if (streamRequestId !== undefined) {
959
+ // Each REQUEST_OK restores one MAX_REQUEST_UPDATES credit on the stream
960
+ // (Section 10.3.1.7).
961
+ this.restoreUpdateCredit(streamRequestId)
962
+ const pub = this._publishes.get(streamRequestId)
963
+ if (pub !== undefined && pub.phase === 'pending') {
964
+ // PUBLISH_OK. In draft-20 it can no longer carry subscription
965
+ // parameters; a subscriber that wants to change one sends a
966
+ // REQUEST_UPDATE after this. Only EXPIRES still names PUBLISH_OK.
967
+ this._publishes.set(streamRequestId, { ...pub, phase: 'active' })
968
+ this.dropPending(this._pendingPublishes, streamRequestId)
969
+ sideEffects.push({ type: 'publish-activated', requestId: streamRequestId })
970
+ const sub = this._subscriptions.get(streamRequestId)
971
+ if (sub !== undefined && sub.phase === 'pending') {
972
+ this._subscriptions.set(streamRequestId, { ...sub, phase: 'active' })
973
+ sideEffects.push({ type: 'subscription-activated', subscribeId: streamRequestId })
974
+ }
975
+ }
976
+ return { ok: true, phase: this._phase, sideEffects }
977
+ }
978
+
979
+ // REQUEST_OK is the unified response (also the PUBLISH_OK alias) — try FIFO
980
+ // dequeue. Falls through for namespace-scoped requests.
981
+ const pubId = this.dequeuePending(this._pendingPublishes, this._publishes)
982
+ if (pubId !== undefined) {
983
+ const pub = this._publishes.get(pubId)!
984
+ this._publishes.set(pubId, { ...pub, phase: 'active' })
985
+ sideEffects.push({ type: 'publish-activated', requestId: pubId })
986
+ }
987
+ return { ok: true, phase: this._phase, sideEffects }
988
+ }
989
+
990
+ private handleGenericRequest(
991
+ message: Draft20Message,
992
+ kind: RequestKind,
993
+ direction: 'inbound' | 'outbound',
994
+ sideEffects: SideEffect[],
995
+ ): TransitionResult<Draft20MessageType> {
996
+ const err = this.requireReady(message.type)
997
+ if (err) return { ok: false, violation: err }
998
+ const requestId = (message as { request_id?: bigint }).request_id
999
+ if (requestId === undefined) return { ok: true, phase: this._phase, sideEffects }
1000
+ const dupErr = this.checkDuplicateRequestId(requestId, message.type)
1001
+ if (dupErr) return { ok: false, violation: dupErr }
1002
+ this._requestIds.add(requestId)
1003
+ this._requests.set(requestId, {
1004
+ kind,
1005
+ // Only subscriptions have a publisher in the Section 10.10 sense; the
1006
+ // value is recorded for symmetry and never consulted for these kinds.
1007
+ publisherSide: direction === 'outbound' ? 'remote' : 'local',
1008
+ outstandingUpdates: 0,
1009
+ })
1010
+ return { ok: true, phase: this._phase, sideEffects }
1011
+ }
1012
+
1013
+ private restoreUpdateCredit(requestId: bigint): void {
1014
+ const request = this._requests.get(requestId)
1015
+ if (request !== undefined && request.outstandingUpdates > 0) {
1016
+ request.outstandingUpdates -= 1
1017
+ }
1018
+ }
1019
+
1020
+ private dropPending(queue: bigint[], requestId: bigint): void {
1021
+ const index = queue.indexOf(requestId)
1022
+ if (index >= 0) queue.splice(index, 1)
1023
+ }
1024
+
1025
+ private dequeuePending(
1026
+ queue: bigint[],
1027
+ stateMap: ReadonlyMap<bigint, { phase: string }>,
1028
+ ): bigint | undefined {
1029
+ while (queue.length > 0) {
1030
+ const id = queue[0]!
1031
+ const state = stateMap.get(id)
1032
+ if (state && state.phase === 'pending') {
1033
+ queue.shift()
1034
+ return id
1035
+ }
1036
+ // Skip non-pending entries (already resolved by type-specific handler)
1037
+ queue.shift()
1038
+ }
1039
+ return undefined
1040
+ }
1041
+
1042
+ private handleReadyPhaseMessage(message: Draft20Message): TransitionResult<Draft20MessageType> {
1043
+ const err = this.requireReady(message.type)
1044
+ if (err) return { ok: false, violation: err }
1045
+ return { ok: true, phase: this._phase, sideEffects: [] }
1046
+ }
1047
+
1048
+ reset(): void {
1049
+ this._phase = 'idle'
1050
+ this._setupDirection = null
1051
+ this._subscriptions.clear()
1052
+ this._subscriptionMeta.clear()
1053
+ this._publishes.clear()
1054
+ this._fetches.clear()
1055
+ this._requests.clear()
1056
+ this._requestIds.clear()
1057
+ this._pendingSubscribes.length = 0
1058
+ this._pendingPublishes.length = 0
1059
+ this._pendingFetches.length = 0
1060
+ this._peerMaxRequestUpdates = 0n
1061
+ this._localMaxRequestUpdates = 0n
1062
+ }
1063
+ }