@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.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/dist/chunk-23YG7F46.js +764 -0
- package/dist/chunk-2NARXGVA.cjs +194 -0
- package/dist/chunk-3BSZ55L3.cjs +307 -0
- package/dist/chunk-5WFXFLL4.cjs +1185 -0
- package/dist/chunk-DC4L6ZIT.js +307 -0
- package/dist/chunk-GDRGWFEK.cjs +498 -0
- package/dist/chunk-IQPDRQVC.js +1185 -0
- package/dist/chunk-QYG6KGOV.cjs +101 -0
- package/dist/chunk-UOBWHJA5.js +101 -0
- package/dist/chunk-WNTXF3DE.cjs +764 -0
- package/dist/chunk-YBSEOSSP.js +194 -0
- package/dist/chunk-YPXLV5YK.js +498 -0
- package/dist/codec-CTvFtQQI.d.cts +86 -0
- package/dist/codec-qPzfmLNu.d.ts +86 -0
- package/dist/draft14-session.cjs +6 -0
- package/dist/draft14-session.d.cts +8 -0
- package/dist/draft14-session.d.ts +8 -0
- package/dist/draft14-session.js +6 -0
- package/dist/draft14.cjs +121 -0
- package/dist/draft14.d.cts +96 -0
- package/dist/draft14.d.ts +96 -0
- package/dist/draft14.js +121 -0
- package/dist/draft7-session.cjs +7 -0
- package/dist/draft7-session.d.cts +7 -0
- package/dist/draft7-session.d.ts +7 -0
- package/dist/draft7-session.js +7 -0
- package/dist/draft7.cjs +60 -0
- package/dist/draft7.d.cts +72 -0
- package/dist/draft7.d.ts +72 -0
- package/dist/draft7.js +60 -0
- package/dist/index.cjs +40 -0
- package/dist/index.d.cts +40 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +40 -0
- package/dist/session-types-B9NIf7_F.d.ts +101 -0
- package/dist/session-types-CCo-oA-d.d.cts +101 -0
- package/dist/session.cjs +27 -0
- package/dist/session.d.cts +24 -0
- package/dist/session.d.ts +24 -0
- package/dist/session.js +27 -0
- package/dist/types-CIk5W10V.d.cts +249 -0
- package/dist/types-CIk5W10V.d.ts +249 -0
- package/dist/types-ClXELFGN.d.cts +241 -0
- package/dist/types-ClXELFGN.d.ts +241 -0
- package/package.json +84 -0
- package/src/core/buffer-reader.ts +107 -0
- package/src/core/buffer-writer.ts +91 -0
- package/src/core/errors.ts +1 -0
- package/src/core/session-types.ts +103 -0
- package/src/core/types.ts +363 -0
- package/src/drafts/draft07/announce-fsm.ts +2 -0
- package/src/drafts/draft07/codec.ts +874 -0
- package/src/drafts/draft07/index.ts +70 -0
- package/src/drafts/draft07/messages.ts +44 -0
- package/src/drafts/draft07/parameters.ts +12 -0
- package/src/drafts/draft07/rules.ts +75 -0
- package/src/drafts/draft07/session-fsm.ts +353 -0
- package/src/drafts/draft07/session.ts +21 -0
- package/src/drafts/draft07/subscription-fsm.ts +3 -0
- package/src/drafts/draft07/varint.ts +23 -0
- package/src/drafts/draft14/codec.ts +1330 -0
- package/src/drafts/draft14/index.ts +132 -0
- package/src/drafts/draft14/messages.ts +76 -0
- package/src/drafts/draft14/rules.ts +70 -0
- package/src/drafts/draft14/session-fsm.ts +480 -0
- package/src/drafts/draft14/session.ts +26 -0
- package/src/drafts/draft14/types.ts +365 -0
- package/src/index.ts +85 -0
- 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,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
|
+
}
|