@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,70 @@
1
+ export { encodeVarInt, decodeVarInt } from './varint.js';
2
+ export { createDraft07Codec } from './codec.js';
3
+ export { encodeParameters, decodeParameters } from './parameters.js';
4
+ export { MESSAGE_TYPE_IDS, MESSAGE_ID_TO_TYPE } from './messages.js';
5
+ export {
6
+ CONTROL_MESSAGES,
7
+ DATA_MESSAGES,
8
+ CLIENT_ONLY_MESSAGES,
9
+ SERVER_ONLY_MESSAGES,
10
+ getLegalOutgoing,
11
+ getLegalIncoming,
12
+ } from './rules.js';
13
+
14
+ // Re-export types consumers need
15
+ export type {
16
+ MoqtMessage,
17
+ DecodeResult,
18
+ DecodeErrorCode,
19
+ ClientSetup,
20
+ ServerSetup,
21
+ Subscribe,
22
+ SubscribeOk,
23
+ SubscribeError,
24
+ SubscribeDone,
25
+ Unsubscribe,
26
+ Announce,
27
+ AnnounceOk,
28
+ AnnounceError,
29
+ AnnounceCancel,
30
+ Unannounce,
31
+ TrackStatusRequest,
32
+ TrackStatus,
33
+ ObjectStream,
34
+ ObjectDatagram,
35
+ StreamHeaderTrack,
36
+ StreamHeaderGroup,
37
+ StreamHeaderSubgroup,
38
+ GoAway,
39
+ SubscribeAnnounces,
40
+ SubscribeAnnouncesOk,
41
+ SubscribeAnnouncesError,
42
+ Fetch,
43
+ FetchOk,
44
+ FetchError,
45
+ FetchCancel,
46
+ SubscribeUpdate,
47
+ UnsubscribeAnnounces,
48
+ MaxSubscribeId,
49
+ FilterType,
50
+ GroupOrderValue,
51
+ MoqtMessageType,
52
+ } from '../../core/types.js';
53
+ export { DecodeError } from '../../core/types.js';
54
+
55
+ import { createDraft07Codec } from './codec.js';
56
+ import type { DecodeResult, MoqtMessage } from '../../core/types.js';
57
+
58
+ const defaultCodec = createDraft07Codec();
59
+
60
+ export function encodeMessage(message: MoqtMessage): Uint8Array {
61
+ return defaultCodec.encodeMessage(message);
62
+ }
63
+
64
+ export function decodeMessage(bytes: Uint8Array): DecodeResult<MoqtMessage> {
65
+ return defaultCodec.decodeMessage(bytes);
66
+ }
67
+
68
+ export function createStreamDecoder(): TransformStream<Uint8Array, MoqtMessage> {
69
+ return defaultCodec.createStreamDecoder();
70
+ }
@@ -0,0 +1,44 @@
1
+ // MoQT draft-ietf-moq-transport-07 message type IDs (wire format)
2
+ export const MESSAGE_TYPE_IDS = {
3
+ // Control messages (on control stream)
4
+ subscribe_update: 0x02n,
5
+ subscribe: 0x03n,
6
+ subscribe_ok: 0x04n,
7
+ subscribe_error: 0x05n,
8
+ announce: 0x06n,
9
+ announce_ok: 0x07n,
10
+ announce_error: 0x08n,
11
+ unannounce: 0x09n,
12
+ unsubscribe: 0x0an,
13
+ subscribe_done: 0x0bn,
14
+ announce_cancel: 0x0cn,
15
+ track_status_request: 0x0dn,
16
+ track_status: 0x0en,
17
+ goaway: 0x10n,
18
+ subscribe_announces: 0x11n,
19
+ subscribe_announces_ok: 0x12n,
20
+ subscribe_announces_error: 0x13n,
21
+ unsubscribe_announces: 0x14n,
22
+ max_subscribe_id: 0x15n,
23
+ fetch: 0x16n,
24
+ fetch_cancel: 0x17n,
25
+ fetch_ok: 0x18n,
26
+ fetch_error: 0x19n,
27
+ client_setup: 0x40n,
28
+ server_setup: 0x41n,
29
+ // Data stream messages
30
+ object_stream: 0x00n,
31
+ object_datagram: 0x01n,
32
+ stream_header_track: 0x50n,
33
+ stream_header_group: 0x51n,
34
+ stream_header_subgroup: 0x04n, // Note: same ID as subscribe_ok but used on data streams, context disambiguates
35
+ } as const;
36
+
37
+ // Reverse map: wire ID -> message type name
38
+ export const MESSAGE_ID_TO_TYPE = new Map<bigint, string>();
39
+ for (const [name, id] of Object.entries(MESSAGE_TYPE_IDS)) {
40
+ // For duplicate IDs, control stream messages take priority in the reverse map
41
+ if (!MESSAGE_ID_TO_TYPE.has(id) || name !== 'stream_header_subgroup') {
42
+ MESSAGE_ID_TO_TYPE.set(id, name);
43
+ }
44
+ }
@@ -0,0 +1,12 @@
1
+ import { BufferReader } from '../../core/buffer-reader.js';
2
+ import { BufferWriter } from '../../core/buffer-writer.js';
3
+
4
+ export function encodeParameters(params: Map<bigint, Uint8Array>): Uint8Array {
5
+ const writer = new BufferWriter();
6
+ writer.writeParameters(params);
7
+ return writer.finish();
8
+ }
9
+
10
+ export function decodeParameters(reader: BufferReader): Map<bigint, Uint8Array> {
11
+ return reader.readParameters();
12
+ }
@@ -0,0 +1,75 @@
1
+ import type { MoqtMessageType } from '../../core/types.js';
2
+
3
+ export const CONTROL_MESSAGES: ReadonlySet<MoqtMessageType> = new Set([
4
+ 'client_setup', 'server_setup',
5
+ 'subscribe', 'subscribe_ok', 'subscribe_error', 'subscribe_done', 'subscribe_update', 'unsubscribe',
6
+ 'announce', 'announce_ok', 'announce_error', 'announce_cancel', 'unannounce',
7
+ 'track_status_request', 'track_status',
8
+ 'goaway',
9
+ 'subscribe_announces', 'subscribe_announces_ok', 'subscribe_announces_error', 'unsubscribe_announces',
10
+ 'max_subscribe_id',
11
+ 'fetch', 'fetch_ok', 'fetch_error', 'fetch_cancel',
12
+ ]);
13
+
14
+ export const DATA_MESSAGES: ReadonlySet<MoqtMessageType> = new Set([
15
+ 'object_stream', 'object_datagram',
16
+ 'stream_header_track', 'stream_header_group', 'stream_header_subgroup',
17
+ ]);
18
+
19
+ // Messages that only a client can send
20
+ export const CLIENT_ONLY_MESSAGES: ReadonlySet<MoqtMessageType> = new Set([
21
+ 'client_setup',
22
+ 'subscribe', 'subscribe_update', 'unsubscribe',
23
+ 'announce', 'unannounce',
24
+ 'subscribe_announces', 'unsubscribe_announces',
25
+ 'max_subscribe_id',
26
+ 'fetch', 'fetch_cancel',
27
+ 'track_status_request',
28
+ ]);
29
+
30
+ // Messages that only a server can send
31
+ export const SERVER_ONLY_MESSAGES: ReadonlySet<MoqtMessageType> = new Set([
32
+ 'server_setup',
33
+ 'subscribe_ok', 'subscribe_error', 'subscribe_done',
34
+ 'announce_ok', 'announce_error', 'announce_cancel',
35
+ 'subscribe_announces_ok', 'subscribe_announces_error',
36
+ 'max_subscribe_id',
37
+ 'fetch_ok', 'fetch_error',
38
+ 'track_status',
39
+ ]);
40
+
41
+ // Messages legal in each session phase -- for outbound validation
42
+ export function getLegalOutgoing(phase: string, role: 'client' | 'server'): Set<MoqtMessageType> {
43
+ const legal = new Set<MoqtMessageType>();
44
+
45
+ switch (phase) {
46
+ case 'idle':
47
+ if (role === 'client') legal.add('client_setup');
48
+ break;
49
+ case 'setup':
50
+ if (role === 'server') legal.add('server_setup');
51
+ break;
52
+ case 'ready': {
53
+ // Both roles can send goaway
54
+ legal.add('goaway');
55
+ const roleMessages = role === 'client' ? CLIENT_ONLY_MESSAGES : SERVER_ONLY_MESSAGES;
56
+ for (const msg of roleMessages) {
57
+ if (msg !== 'client_setup' && msg !== 'server_setup') {
58
+ legal.add(msg);
59
+ }
60
+ }
61
+ break;
62
+ }
63
+ case 'draining':
64
+ // Limited set during draining - can still finish active operations
65
+ break;
66
+ }
67
+
68
+ return legal;
69
+ }
70
+
71
+ export function getLegalIncoming(phase: string, role: 'client' | 'server'): Set<MoqtMessageType> {
72
+ // Incoming from remote = the other role's outgoing
73
+ const remoteRole = role === 'client' ? 'server' : 'client';
74
+ return getLegalOutgoing(phase, remoteRole);
75
+ }
@@ -0,0 +1,353 @@
1
+ import type { MoqtMessage, MoqtMessageType } from '../../core/types.js';
2
+ import type {
3
+ SessionPhase,
4
+ TransitionResult,
5
+ ValidationResult,
6
+ ProtocolViolation,
7
+ SideEffect,
8
+ SubscriptionState,
9
+ AnnounceState,
10
+ } from '../../core/session-types.js';
11
+ import { getLegalOutgoing, getLegalIncoming, CLIENT_ONLY_MESSAGES, SERVER_ONLY_MESSAGES } from './rules.js';
12
+
13
+ function violation(code: ProtocolViolation['code'], message: string, currentPhase: SessionPhase, offendingMessage: MoqtMessageType): ProtocolViolation {
14
+ return { code, message, currentPhase, offendingMessage };
15
+ }
16
+
17
+ export class SessionFSM {
18
+ private _phase: SessionPhase = 'idle';
19
+ private _role: 'client' | 'server';
20
+ private _subscriptions = new Map<bigint, SubscriptionState>();
21
+ private _announces = new Map<string, AnnounceState>();
22
+
23
+ constructor(role: 'client' | 'server') {
24
+ this._role = role;
25
+ }
26
+
27
+ get phase(): SessionPhase { return this._phase; }
28
+ get role(): 'client' | 'server' { return this._role; }
29
+ get subscriptions(): ReadonlyMap<bigint, SubscriptionState> { return this._subscriptions; }
30
+ get announces(): ReadonlyMap<string, AnnounceState> { return this._announces; }
31
+
32
+ get legalOutgoing(): ReadonlySet<MoqtMessageType> {
33
+ return getLegalOutgoing(this._phase, this._role);
34
+ }
35
+
36
+ get legalIncoming(): ReadonlySet<MoqtMessageType> {
37
+ return getLegalIncoming(this._phase, this._role);
38
+ }
39
+
40
+ // Validate role constraints
41
+ private checkRole(message: MoqtMessage, direction: 'inbound' | 'outbound'): ProtocolViolation | null {
42
+ const senderRole = direction === 'outbound' ? this._role : (this._role === 'client' ? 'server' : 'client');
43
+
44
+ if (CLIENT_ONLY_MESSAGES.has(message.type) && senderRole !== 'client') {
45
+ return violation('ROLE_VIOLATION', `${message.type} can only be sent by client`, this._phase, message.type);
46
+ }
47
+ if (SERVER_ONLY_MESSAGES.has(message.type) && senderRole !== 'server') {
48
+ return violation('ROLE_VIOLATION', `${message.type} can only be sent by server`, this._phase, message.type);
49
+ }
50
+ return null;
51
+ }
52
+
53
+ validateOutgoing(message: MoqtMessage): ValidationResult {
54
+ const roleViolation = this.checkRole(message, 'outbound');
55
+ if (roleViolation) return { ok: false, violation: roleViolation };
56
+
57
+ if (!this.legalOutgoing.has(message.type)) {
58
+ return {
59
+ ok: false,
60
+ violation: violation(
61
+ this._phase === 'idle' || this._phase === 'setup' ? 'MESSAGE_BEFORE_SETUP' : 'UNEXPECTED_MESSAGE',
62
+ `Cannot send ${message.type} in phase ${this._phase}`,
63
+ this._phase,
64
+ message.type,
65
+ ),
66
+ };
67
+ }
68
+ return { ok: true };
69
+ }
70
+
71
+ receive(message: MoqtMessage): TransitionResult {
72
+ const roleViolation = this.checkRole(message, 'inbound');
73
+ if (roleViolation) return { ok: false, violation: roleViolation };
74
+
75
+ return this.applyTransition(message, 'inbound');
76
+ }
77
+
78
+ send(message: MoqtMessage): TransitionResult {
79
+ const roleViolation = this.checkRole(message, 'outbound');
80
+ if (roleViolation) return { ok: false, violation: roleViolation };
81
+
82
+ return this.applyTransition(message, 'outbound');
83
+ }
84
+
85
+ private applyTransition(message: MoqtMessage, direction: 'inbound' | 'outbound'): TransitionResult {
86
+ const sideEffects: SideEffect[] = [];
87
+
88
+ switch (message.type) {
89
+ case 'client_setup':
90
+ return this.handleClientSetup(message, direction);
91
+ case 'server_setup':
92
+ return this.handleServerSetup(message, direction);
93
+ case 'goaway':
94
+ return this.handleGoAway(message, direction, sideEffects);
95
+
96
+ // Subscription lifecycle
97
+ case 'subscribe':
98
+ return this.handleSubscribe(message, direction, sideEffects);
99
+ case 'subscribe_ok':
100
+ return this.handleSubscribeOk(message, direction, sideEffects);
101
+ case 'subscribe_error':
102
+ return this.handleSubscribeError(message, direction, sideEffects);
103
+ case 'subscribe_done':
104
+ return this.handleSubscribeDone(message, direction, sideEffects);
105
+ case 'unsubscribe':
106
+ return this.handleUnsubscribe(message, direction, sideEffects);
107
+
108
+ // Announce lifecycle
109
+ case 'announce':
110
+ return this.handleAnnounce(message, direction, sideEffects);
111
+ case 'announce_ok':
112
+ return this.handleAnnounceOk(message, direction, sideEffects);
113
+ case 'announce_error':
114
+ return this.handleAnnounceError(message, direction, sideEffects);
115
+ case 'announce_cancel':
116
+ return this.handleAnnounceCancel(message, direction, sideEffects);
117
+ case 'unannounce':
118
+ return this.handleUnannounce(message, direction, sideEffects);
119
+
120
+ // Fetch lifecycle
121
+ case 'fetch':
122
+ case 'fetch_ok':
123
+ case 'fetch_error':
124
+ case 'fetch_cancel':
125
+ return this.handleReadyPhaseMessage(message);
126
+
127
+ // Other ready-phase messages
128
+ default:
129
+ return this.handleReadyPhaseMessage(message);
130
+ }
131
+ }
132
+
133
+ private handleClientSetup(_message: MoqtMessage, direction: 'inbound' | 'outbound'): TransitionResult {
134
+ if (this._phase !== 'idle') {
135
+ return { ok: false, violation: violation('SETUP_VIOLATION', 'CLIENT_SETUP already sent/received', this._phase, 'client_setup') };
136
+ }
137
+
138
+ if (direction === 'outbound' && this._role !== 'client') {
139
+ return { ok: false, violation: violation('ROLE_VIOLATION', 'Only client can send CLIENT_SETUP', this._phase, 'client_setup') };
140
+ }
141
+
142
+ this._phase = 'setup';
143
+ return { ok: true, phase: this._phase, sideEffects: [] };
144
+ }
145
+
146
+ private handleServerSetup(_message: MoqtMessage, direction: 'inbound' | 'outbound'): TransitionResult {
147
+ if (this._phase !== 'setup') {
148
+ return { ok: false, violation: violation('SETUP_VIOLATION', 'SERVER_SETUP before CLIENT_SETUP', this._phase, 'server_setup') };
149
+ }
150
+
151
+ if (direction === 'outbound' && this._role !== 'server') {
152
+ return { ok: false, violation: violation('ROLE_VIOLATION', 'Only server can send SERVER_SETUP', this._phase, 'server_setup') };
153
+ }
154
+
155
+ this._phase = 'ready';
156
+ return { ok: true, phase: this._phase, sideEffects: [{ type: 'session-ready' }] };
157
+ }
158
+
159
+ private handleGoAway(message: MoqtMessage, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult {
160
+ if (this._phase !== 'ready' && this._phase !== 'draining') {
161
+ return { ok: false, violation: violation('UNEXPECTED_MESSAGE', `GOAWAY not valid in phase ${this._phase}`, this._phase, 'goaway') };
162
+ }
163
+ this._phase = 'draining';
164
+ const goaway = message as import('../../core/types.js').GoAway;
165
+ sideEffects.push({ type: 'session-draining', goAwayUri: goaway.newSessionUri });
166
+ return { ok: true, phase: this._phase, sideEffects };
167
+ }
168
+
169
+ private requireReady(msgType: MoqtMessageType): ProtocolViolation | null {
170
+ if (this._phase !== 'ready' && this._phase !== 'draining') {
171
+ return violation(
172
+ this._phase === 'idle' || this._phase === 'setup' ? 'MESSAGE_BEFORE_SETUP' : 'UNEXPECTED_MESSAGE',
173
+ `${msgType} requires ready phase, current: ${this._phase}`,
174
+ this._phase,
175
+ msgType,
176
+ );
177
+ }
178
+ return null;
179
+ }
180
+
181
+ private handleSubscribe(message: MoqtMessage, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult {
182
+ const err = this.requireReady(message.type);
183
+ if (err) return { ok: false, violation: err };
184
+
185
+ const sub = message as import('../../core/types.js').Subscribe;
186
+ if (this._subscriptions.has(sub.subscribeId)) {
187
+ return { ok: false, violation: violation('DUPLICATE_SUBSCRIBE_ID', `Subscribe ID ${sub.subscribeId} already exists`, this._phase, message.type) };
188
+ }
189
+
190
+ this._subscriptions.set(sub.subscribeId, {
191
+ subscribeId: sub.subscribeId,
192
+ phase: 'pending',
193
+ trackNamespace: sub.trackNamespace,
194
+ trackName: sub.trackName,
195
+ });
196
+
197
+ return { ok: true, phase: this._phase, sideEffects };
198
+ }
199
+
200
+ private handleSubscribeOk(message: MoqtMessage, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult {
201
+ const err = this.requireReady(message.type);
202
+ if (err) return { ok: false, violation: err };
203
+
204
+ const ok = message as import('../../core/types.js').SubscribeOk;
205
+ const existing = this._subscriptions.get(ok.subscribeId);
206
+ if (!existing) {
207
+ return { ok: false, violation: violation('UNKNOWN_SUBSCRIBE_ID', `No subscription with ID ${ok.subscribeId}`, this._phase, message.type) };
208
+ }
209
+ if (existing.phase !== 'pending') {
210
+ return { ok: false, violation: violation('STATE_VIOLATION', `Subscription ${ok.subscribeId} is ${existing.phase}, not pending`, this._phase, message.type) };
211
+ }
212
+
213
+ this._subscriptions.set(ok.subscribeId, { ...existing, phase: 'active' });
214
+ sideEffects.push({ type: 'subscription-activated', subscribeId: ok.subscribeId });
215
+ return { ok: true, phase: this._phase, sideEffects };
216
+ }
217
+
218
+ private handleSubscribeError(message: MoqtMessage, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult {
219
+ const err = this.requireReady(message.type);
220
+ if (err) return { ok: false, violation: err };
221
+
222
+ const subErr = message as import('../../core/types.js').SubscribeError;
223
+ const existing = this._subscriptions.get(subErr.subscribeId);
224
+ if (!existing) {
225
+ return { ok: false, violation: violation('UNKNOWN_SUBSCRIBE_ID', `No subscription with ID ${subErr.subscribeId}`, this._phase, message.type) };
226
+ }
227
+ if (existing.phase !== 'pending') {
228
+ return { ok: false, violation: violation('STATE_VIOLATION', `Subscription ${subErr.subscribeId} is ${existing.phase}, not pending`, this._phase, message.type) };
229
+ }
230
+
231
+ this._subscriptions.set(subErr.subscribeId, { ...existing, phase: 'error' });
232
+ sideEffects.push({ type: 'subscription-ended', subscribeId: subErr.subscribeId, reason: subErr.reasonPhrase });
233
+ return { ok: true, phase: this._phase, sideEffects };
234
+ }
235
+
236
+ private handleSubscribeDone(message: MoqtMessage, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult {
237
+ const err = this.requireReady(message.type);
238
+ if (err) return { ok: false, violation: err };
239
+
240
+ const done = message as import('../../core/types.js').SubscribeDone;
241
+ const existing = this._subscriptions.get(done.subscribeId);
242
+ if (!existing) {
243
+ return { ok: false, violation: violation('UNKNOWN_SUBSCRIBE_ID', `No subscription with ID ${done.subscribeId}`, this._phase, message.type) };
244
+ }
245
+
246
+ this._subscriptions.set(done.subscribeId, { ...existing, phase: 'done' });
247
+ sideEffects.push({ type: 'subscription-ended', subscribeId: done.subscribeId, reason: done.reasonPhrase });
248
+ return { ok: true, phase: this._phase, sideEffects };
249
+ }
250
+
251
+ private handleUnsubscribe(message: MoqtMessage, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult {
252
+ const err = this.requireReady(message.type);
253
+ if (err) return { ok: false, violation: err };
254
+
255
+ const unsub = message as import('../../core/types.js').Unsubscribe;
256
+ const existing = this._subscriptions.get(unsub.subscribeId);
257
+ if (!existing) {
258
+ return { ok: false, violation: violation('UNKNOWN_SUBSCRIBE_ID', `No subscription with ID ${unsub.subscribeId}`, this._phase, message.type) };
259
+ }
260
+
261
+ this._subscriptions.set(unsub.subscribeId, { ...existing, phase: 'done' });
262
+ sideEffects.push({ type: 'subscription-ended', subscribeId: unsub.subscribeId, reason: 'unsubscribed' });
263
+ return { ok: true, phase: this._phase, sideEffects };
264
+ }
265
+
266
+ // Announce handlers
267
+ private namespaceKey(ns: string[]): string {
268
+ return ns.join('/');
269
+ }
270
+
271
+ private handleAnnounce(message: MoqtMessage, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult {
272
+ const err = this.requireReady(message.type);
273
+ if (err) return { ok: false, violation: err };
274
+
275
+ const ann = message as import('../../core/types.js').Announce;
276
+ const key = this.namespaceKey(ann.trackNamespace);
277
+
278
+ this._announces.set(key, { namespace: ann.trackNamespace, phase: 'pending' });
279
+ return { ok: true, phase: this._phase, sideEffects };
280
+ }
281
+
282
+ private handleAnnounceOk(message: MoqtMessage, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult {
283
+ const err = this.requireReady(message.type);
284
+ if (err) return { ok: false, violation: err };
285
+
286
+ const ok = message as import('../../core/types.js').AnnounceOk;
287
+ const key = this.namespaceKey(ok.trackNamespace);
288
+ const existing = this._announces.get(key);
289
+ if (!existing) {
290
+ return { ok: false, violation: violation('UNEXPECTED_MESSAGE', `No announce for namespace ${key}`, this._phase, message.type) };
291
+ }
292
+
293
+ this._announces.set(key, { ...existing, phase: 'active' });
294
+ sideEffects.push({ type: 'announce-activated', namespace: ok.trackNamespace });
295
+ return { ok: true, phase: this._phase, sideEffects };
296
+ }
297
+
298
+ private handleAnnounceError(message: MoqtMessage, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult {
299
+ const err = this.requireReady(message.type);
300
+ if (err) return { ok: false, violation: err };
301
+
302
+ const annErr = message as import('../../core/types.js').AnnounceError;
303
+ const key = this.namespaceKey(annErr.trackNamespace);
304
+ const existing = this._announces.get(key);
305
+ if (!existing) {
306
+ return { ok: false, violation: violation('UNEXPECTED_MESSAGE', `No announce for namespace ${key}`, this._phase, message.type) };
307
+ }
308
+
309
+ this._announces.set(key, { ...existing, phase: 'error' });
310
+ sideEffects.push({ type: 'announce-ended', namespace: annErr.trackNamespace });
311
+ return { ok: true, phase: this._phase, sideEffects };
312
+ }
313
+
314
+ private handleAnnounceCancel(message: MoqtMessage, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult {
315
+ const err = this.requireReady(message.type);
316
+ if (err) return { ok: false, violation: err };
317
+
318
+ const cancel = message as import('../../core/types.js').AnnounceCancel;
319
+ const key = this.namespaceKey(cancel.trackNamespace);
320
+ const existing = this._announces.get(key);
321
+ if (existing) {
322
+ this._announces.delete(key);
323
+ sideEffects.push({ type: 'announce-ended', namespace: cancel.trackNamespace });
324
+ }
325
+ return { ok: true, phase: this._phase, sideEffects };
326
+ }
327
+
328
+ private handleUnannounce(message: MoqtMessage, _direction: 'inbound' | 'outbound', sideEffects: SideEffect[]): TransitionResult {
329
+ const err = this.requireReady(message.type);
330
+ if (err) return { ok: false, violation: err };
331
+
332
+ const unann = message as import('../../core/types.js').Unannounce;
333
+ const key = this.namespaceKey(unann.trackNamespace);
334
+ const existing = this._announces.get(key);
335
+ if (existing) {
336
+ this._announces.delete(key);
337
+ sideEffects.push({ type: 'announce-ended', namespace: unann.trackNamespace });
338
+ }
339
+ return { ok: true, phase: this._phase, sideEffects };
340
+ }
341
+
342
+ private handleReadyPhaseMessage(message: MoqtMessage): TransitionResult {
343
+ const err = this.requireReady(message.type);
344
+ if (err) return { ok: false, violation: err };
345
+ return { ok: true, phase: this._phase, sideEffects: [] };
346
+ }
347
+
348
+ reset(): void {
349
+ this._phase = 'idle';
350
+ this._subscriptions.clear();
351
+ this._announces.clear();
352
+ }
353
+ }
@@ -0,0 +1,21 @@
1
+ import type { SessionState, SessionStateOptions } from '../../core/session-types.js';
2
+ import { SessionFSM } from './session-fsm.js';
3
+
4
+ export function createDraft07SessionState(options: SessionStateOptions): SessionState {
5
+ return new SessionFSM(options.role);
6
+ }
7
+
8
+ export type {
9
+ SessionState,
10
+ SessionStateOptions,
11
+ SessionPhase,
12
+ SubscriptionState,
13
+ SubscriptionPhase,
14
+ AnnounceState,
15
+ AnnouncePhase,
16
+ TransitionResult,
17
+ ValidationResult,
18
+ ProtocolViolation,
19
+ ProtocolViolationCode,
20
+ SideEffect,
21
+ } from '../../core/session-types.js';
@@ -0,0 +1,3 @@
1
+ // Subscription state tracking is integrated into SessionFSM.
2
+ // This file exists for the directory structure; re-exports relevant types.
3
+ export type { SubscriptionState, SubscriptionPhase } from '../../core/session-types.js';
@@ -0,0 +1,23 @@
1
+ import { BufferReader } from '../../core/buffer-reader.js';
2
+ import { BufferWriter } from '../../core/buffer-writer.js';
3
+ import { DecodeError } from '../../core/types.js';
4
+ import type { DecodeResult } from '../../core/types.js';
5
+
6
+ export function encodeVarInt(value: number | bigint): Uint8Array {
7
+ const writer = new BufferWriter(8);
8
+ writer.writeVarInt(value);
9
+ return writer.finish();
10
+ }
11
+
12
+ export function decodeVarInt(bytes: Uint8Array, offset = 0): DecodeResult<bigint> {
13
+ try {
14
+ const reader = new BufferReader(bytes, offset);
15
+ const value = reader.readVarInt();
16
+ return { ok: true, value, bytesRead: reader.offset - offset };
17
+ } catch (e) {
18
+ if (e instanceof DecodeError) {
19
+ return { ok: false, error: e };
20
+ }
21
+ throw e;
22
+ }
23
+ }