@agatx/serenada-core 0.6.10

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 (156) hide show
  1. package/dist/ConsoleLogger.d.ts +6 -0
  2. package/dist/ConsoleLogger.d.ts.map +1 -0
  3. package/dist/ConsoleLogger.js +21 -0
  4. package/dist/ConsoleLogger.js.map +1 -0
  5. package/dist/RoomWatcher.d.ts +34 -0
  6. package/dist/RoomWatcher.d.ts.map +1 -0
  7. package/dist/RoomWatcher.js +103 -0
  8. package/dist/RoomWatcher.js.map +1 -0
  9. package/dist/SerenadaCore.d.ts +47 -0
  10. package/dist/SerenadaCore.d.ts.map +1 -0
  11. package/dist/SerenadaCore.js +141 -0
  12. package/dist/SerenadaCore.js.map +1 -0
  13. package/dist/SerenadaDiagnostics.d.ts +49 -0
  14. package/dist/SerenadaDiagnostics.d.ts.map +1 -0
  15. package/dist/SerenadaDiagnostics.js +421 -0
  16. package/dist/SerenadaDiagnostics.js.map +1 -0
  17. package/dist/SerenadaServerProvider.d.ts +48 -0
  18. package/dist/SerenadaServerProvider.d.ts.map +1 -0
  19. package/dist/SerenadaServerProvider.js +296 -0
  20. package/dist/SerenadaServerProvider.js.map +1 -0
  21. package/dist/SerenadaSession.d.ts +180 -0
  22. package/dist/SerenadaSession.d.ts.map +1 -0
  23. package/dist/SerenadaSession.js +1082 -0
  24. package/dist/SerenadaSession.js.map +1 -0
  25. package/dist/SignalingProvider.d.ts +132 -0
  26. package/dist/SignalingProvider.d.ts.map +1 -0
  27. package/dist/SignalingProvider.js +50 -0
  28. package/dist/SignalingProvider.js.map +1 -0
  29. package/dist/api/roomApi.d.ts +2 -0
  30. package/dist/api/roomApi.d.ts.map +1 -0
  31. package/dist/api/roomApi.js +14 -0
  32. package/dist/api/roomApi.js.map +1 -0
  33. package/dist/cameraModes.d.ts +13 -0
  34. package/dist/cameraModes.d.ts.map +1 -0
  35. package/dist/cameraModes.js +35 -0
  36. package/dist/cameraModes.js.map +1 -0
  37. package/dist/configValidation.d.ts +10 -0
  38. package/dist/configValidation.d.ts.map +1 -0
  39. package/dist/configValidation.js +24 -0
  40. package/dist/configValidation.js.map +1 -0
  41. package/dist/constants.d.ts +33 -0
  42. package/dist/constants.d.ts.map +1 -0
  43. package/dist/constants.js +65 -0
  44. package/dist/constants.js.map +1 -0
  45. package/dist/formatError.d.ts +3 -0
  46. package/dist/formatError.d.ts.map +1 -0
  47. package/dist/formatError.js +7 -0
  48. package/dist/formatError.js.map +1 -0
  49. package/dist/iceServers.d.ts +2 -0
  50. package/dist/iceServers.d.ts.map +1 -0
  51. package/dist/iceServers.js +21 -0
  52. package/dist/iceServers.js.map +1 -0
  53. package/dist/index.d.ts +55 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +44 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/layout/computeLayout.d.ts +81 -0
  58. package/dist/layout/computeLayout.d.ts.map +1 -0
  59. package/dist/layout/computeLayout.js +380 -0
  60. package/dist/layout/computeLayout.js.map +1 -0
  61. package/dist/media/AudioLevelMonitor.d.ts +51 -0
  62. package/dist/media/AudioLevelMonitor.d.ts.map +1 -0
  63. package/dist/media/AudioLevelMonitor.js +179 -0
  64. package/dist/media/AudioLevelMonitor.js.map +1 -0
  65. package/dist/media/MediaEngine.d.ts +137 -0
  66. package/dist/media/MediaEngine.d.ts.map +1 -0
  67. package/dist/media/MediaEngine.js +1224 -0
  68. package/dist/media/MediaEngine.js.map +1 -0
  69. package/dist/media/callStats.d.ts +16 -0
  70. package/dist/media/callStats.d.ts.map +1 -0
  71. package/dist/media/callStats.js +214 -0
  72. package/dist/media/callStats.js.map +1 -0
  73. package/dist/media/localVideoRecovery.d.ts +16 -0
  74. package/dist/media/localVideoRecovery.d.ts.map +1 -0
  75. package/dist/media/localVideoRecovery.js +14 -0
  76. package/dist/media/localVideoRecovery.js.map +1 -0
  77. package/dist/recoveryStorage.d.ts +33 -0
  78. package/dist/recoveryStorage.d.ts.map +1 -0
  79. package/dist/recoveryStorage.js +88 -0
  80. package/dist/recoveryStorage.js.map +1 -0
  81. package/dist/serverUrls.d.ts +8 -0
  82. package/dist/serverUrls.d.ts.map +1 -0
  83. package/dist/serverUrls.js +65 -0
  84. package/dist/serverUrls.js.map +1 -0
  85. package/dist/signaling/SignalingEngine.d.ts +126 -0
  86. package/dist/signaling/SignalingEngine.d.ts.map +1 -0
  87. package/dist/signaling/SignalingEngine.js +720 -0
  88. package/dist/signaling/SignalingEngine.js.map +1 -0
  89. package/dist/signaling/payloads.d.ts +76 -0
  90. package/dist/signaling/payloads.d.ts.map +1 -0
  91. package/dist/signaling/payloads.js +160 -0
  92. package/dist/signaling/payloads.js.map +1 -0
  93. package/dist/signaling/roomStatuses.d.ts +9 -0
  94. package/dist/signaling/roomStatuses.d.ts.map +1 -0
  95. package/dist/signaling/roomStatuses.js +71 -0
  96. package/dist/signaling/roomStatuses.js.map +1 -0
  97. package/dist/signaling/transportConfig.d.ts +3 -0
  98. package/dist/signaling/transportConfig.d.ts.map +1 -0
  99. package/dist/signaling/transportConfig.js +27 -0
  100. package/dist/signaling/transportConfig.js.map +1 -0
  101. package/dist/signaling/transports/index.d.ts +13 -0
  102. package/dist/signaling/transports/index.d.ts.map +1 -0
  103. package/dist/signaling/transports/index.js +11 -0
  104. package/dist/signaling/transports/index.js.map +1 -0
  105. package/dist/signaling/transports/sse.d.ts +26 -0
  106. package/dist/signaling/transports/sse.d.ts.map +1 -0
  107. package/dist/signaling/transports/sse.js +131 -0
  108. package/dist/signaling/transports/sse.js.map +1 -0
  109. package/dist/signaling/transports/types.d.ts +17 -0
  110. package/dist/signaling/transports/types.d.ts.map +1 -0
  111. package/dist/signaling/transports/types.js +2 -0
  112. package/dist/signaling/transports/types.js.map +1 -0
  113. package/dist/signaling/transports/ws.d.ts +21 -0
  114. package/dist/signaling/transports/ws.d.ts.map +1 -0
  115. package/dist/signaling/transports/ws.js +93 -0
  116. package/dist/signaling/transports/ws.js.map +1 -0
  117. package/dist/signaling/types.d.ts +53 -0
  118. package/dist/signaling/types.d.ts.map +1 -0
  119. package/dist/signaling/types.js +2 -0
  120. package/dist/signaling/types.js.map +1 -0
  121. package/dist/types.d.ts +279 -0
  122. package/dist/types.d.ts.map +1 -0
  123. package/dist/types.js +3 -0
  124. package/dist/types.js.map +1 -0
  125. package/package.json +43 -0
  126. package/src/ConsoleLogger.ts +14 -0
  127. package/src/RoomWatcher.ts +127 -0
  128. package/src/SerenadaCore.ts +163 -0
  129. package/src/SerenadaDiagnostics.ts +485 -0
  130. package/src/SerenadaServerProvider.ts +362 -0
  131. package/src/SerenadaSession.ts +1258 -0
  132. package/src/SignalingProvider.ts +207 -0
  133. package/src/api/roomApi.ts +16 -0
  134. package/src/cameraModes.ts +34 -0
  135. package/src/configValidation.ts +35 -0
  136. package/src/constants.ts +77 -0
  137. package/src/formatError.ts +5 -0
  138. package/src/iceServers.ts +20 -0
  139. package/src/index.ts +155 -0
  140. package/src/layout/computeLayout.ts +639 -0
  141. package/src/media/AudioLevelMonitor.ts +190 -0
  142. package/src/media/MediaEngine.ts +1183 -0
  143. package/src/media/callStats.ts +260 -0
  144. package/src/media/localVideoRecovery.ts +39 -0
  145. package/src/recoveryStorage.ts +101 -0
  146. package/src/serverUrls.ts +69 -0
  147. package/src/signaling/SignalingEngine.ts +762 -0
  148. package/src/signaling/payloads.ts +215 -0
  149. package/src/signaling/roomStatuses.ts +89 -0
  150. package/src/signaling/transportConfig.ts +30 -0
  151. package/src/signaling/transports/index.ts +26 -0
  152. package/src/signaling/transports/sse.ts +146 -0
  153. package/src/signaling/transports/types.ts +19 -0
  154. package/src/signaling/transports/ws.ts +108 -0
  155. package/src/signaling/types.ts +68 -0
  156. package/src/types.ts +299 -0
@@ -0,0 +1,215 @@
1
+ import type { ParticipantContentState, ReconnectOutcome, RoomParticipant, RoomState } from './types.js';
2
+
3
+ export interface JoinedPayload {
4
+ hostCid: string | null;
5
+ participants: RoomParticipant[];
6
+ turnToken?: string;
7
+ turnTokenTTLMs?: number;
8
+ reconnectToken?: string;
9
+ /**
10
+ * How long (ms) the server is willing to honor `reconnectToken`. SDKs
11
+ * that persist the token across launches should clear it once this
12
+ * window has elapsed.
13
+ */
14
+ reconnectTokenTTLMs?: number;
15
+ maxParticipants?: number;
16
+ /** Server-reported room state epoch. Monotonic. */
17
+ epoch?: number;
18
+ /**
19
+ * Disposition of this join. SDKs use this to decide whether to keep
20
+ * media-active peer connections (`reattached`/`recovered`) or treat the
21
+ * call as ground-up new (`fresh`).
22
+ */
23
+ reconnect?: ReconnectOutcome;
24
+ }
25
+
26
+ export interface ErrorPayload {
27
+ code: string;
28
+ message: string;
29
+ /** Optional reason for terminal codes (e.g. ROOM_ENDED → "ended_by_host"). */
30
+ reason?: string;
31
+ }
32
+
33
+ /**
34
+ * Server tells the sender that an offer/answer/ice could not be delivered
35
+ * because the target was suspended. The SDK should suppress further
36
+ * negotiation toward those CIDs and wait for a `negotiation_dirty` message
37
+ * after the peer reattaches.
38
+ */
39
+ export interface RelayFailedPayload {
40
+ reason: 'target_suspended' | (string & {});
41
+ targets: string[];
42
+ of?: string;
43
+ }
44
+
45
+ /**
46
+ * Server tells the sender that a previously-suspended peer has reattached
47
+ * AND that the sender had pending negotiation traffic to it during the
48
+ * suspension. The SDK should perform glare-safe fresh negotiation /
49
+ * ICE restart for the named CID, NOT replay the original SDP.
50
+ */
51
+ export interface NegotiationDirtyPayload {
52
+ with: string;
53
+ }
54
+
55
+ export interface TurnRefreshedPayload {
56
+ turnToken: string;
57
+ turnTokenTTLMs?: number;
58
+ }
59
+
60
+ export interface OfferPayload {
61
+ from: string;
62
+ sdp: string;
63
+ timestamp?: number;
64
+ }
65
+
66
+ export interface AnswerPayload {
67
+ from: string;
68
+ sdp: string;
69
+ }
70
+
71
+ export interface IceCandidatePayload {
72
+ from: string;
73
+ candidate: RTCIceCandidateInit;
74
+ }
75
+
76
+ function parseContentState(raw: unknown): ParticipantContentState | undefined {
77
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
78
+ const rec = raw as Record<string, unknown>;
79
+ if (typeof rec.active !== 'boolean') return undefined;
80
+ return {
81
+ active: rec.active,
82
+ contentType: typeof rec.contentType === 'string' && rec.contentType !== '' ? rec.contentType : undefined,
83
+ updatedAtMs: typeof rec.updatedAtMs === 'number' ? rec.updatedAtMs : undefined,
84
+ epoch: typeof rec.epoch === 'number' ? rec.epoch : undefined,
85
+ };
86
+ }
87
+
88
+ function parseParticipants(raw: unknown): RoomParticipant[] | null {
89
+ if (!Array.isArray(raw)) return null;
90
+ const result: RoomParticipant[] = [];
91
+ for (const p of raw) {
92
+ if (!p || typeof p !== 'object' || Array.isArray(p)) continue;
93
+ const rec = p as Record<string, unknown>;
94
+ if (typeof rec.cid !== 'string' || rec.cid.trim() === '') continue;
95
+ result.push({
96
+ cid: rec.cid,
97
+ joinedAt: typeof rec.joinedAt === 'number' ? rec.joinedAt : undefined,
98
+ displayName: typeof rec.displayName === 'string' && rec.displayName.trim() !== '' ? rec.displayName : undefined,
99
+ peerId: typeof rec.peerId === 'string' && rec.peerId.trim() !== '' ? rec.peerId : undefined,
100
+ audioEnabled: typeof rec.audioEnabled === 'boolean' ? rec.audioEnabled : undefined,
101
+ videoEnabled: typeof rec.videoEnabled === 'boolean' ? rec.videoEnabled : undefined,
102
+ // Only the recognized status value is forwarded; absent/unknown
103
+ // is left undefined and treated as active downstream.
104
+ connectionStatus: rec.connectionStatus === 'suspended' ? 'suspended' : undefined,
105
+ contentState: parseContentState(rec.contentState),
106
+ });
107
+ }
108
+ return result;
109
+ }
110
+
111
+ function parseReconnectOutcome(raw: unknown): ReconnectOutcome | undefined {
112
+ if (raw === 'fresh' || raw === 'reattached' || raw === 'recovered') return raw;
113
+ return undefined;
114
+ }
115
+
116
+ export function parseJoinedPayload(raw: Record<string, unknown> | undefined): JoinedPayload | null {
117
+ if (!raw) return null;
118
+ const participants = parseParticipants(raw.participants);
119
+ if (!participants) return null;
120
+ return {
121
+ hostCid: typeof raw.hostCid === 'string' ? raw.hostCid : null,
122
+ participants,
123
+ turnToken: typeof raw.turnToken === 'string' ? raw.turnToken : undefined,
124
+ turnTokenTTLMs: typeof raw.turnTokenTTLMs === 'number' ? raw.turnTokenTTLMs : undefined,
125
+ reconnectToken: typeof raw.reconnectToken === 'string' ? raw.reconnectToken : undefined,
126
+ reconnectTokenTTLMs: typeof raw.reconnectTokenTTLMs === 'number' ? raw.reconnectTokenTTLMs : undefined,
127
+ maxParticipants: typeof raw.maxParticipants === 'number' ? raw.maxParticipants : undefined,
128
+ epoch: typeof raw.epoch === 'number' ? raw.epoch : undefined,
129
+ reconnect: parseReconnectOutcome(raw.reconnect),
130
+ };
131
+ }
132
+
133
+ export function parseRoomStatePayload(raw: Record<string, unknown> | undefined): RoomState | null {
134
+ if (!raw) return null;
135
+ const participants = parseParticipants(raw.participants);
136
+ if (!participants) return null;
137
+ return {
138
+ hostCid: typeof raw.hostCid === 'string' ? raw.hostCid : null,
139
+ participants,
140
+ maxParticipants: typeof raw.maxParticipants === 'number' ? raw.maxParticipants : undefined,
141
+ epoch: typeof raw.epoch === 'number' ? raw.epoch : undefined,
142
+ };
143
+ }
144
+
145
+ export function parseErrorPayload(raw: Record<string, unknown> | undefined): ErrorPayload | null {
146
+ if (!raw) return null;
147
+ if (typeof raw.message !== 'string') return null;
148
+ return {
149
+ code: typeof raw.code === 'string' ? raw.code : 'UNKNOWN',
150
+ message: raw.message,
151
+ reason: typeof raw.reason === 'string' && raw.reason !== '' ? raw.reason : undefined,
152
+ };
153
+ }
154
+
155
+ export function parseRelayFailedPayload(raw: Record<string, unknown> | undefined): RelayFailedPayload | null {
156
+ if (!raw) return null;
157
+ if (typeof raw.reason !== 'string') return null;
158
+ if (!Array.isArray(raw.targets)) return null;
159
+ const targets = raw.targets.filter((t): t is string => typeof t === 'string' && t !== '');
160
+ if (targets.length === 0) return null;
161
+ return {
162
+ reason: raw.reason as RelayFailedPayload['reason'],
163
+ targets,
164
+ of: typeof raw.of === 'string' && raw.of !== '' ? raw.of : undefined,
165
+ };
166
+ }
167
+
168
+ export function parseNegotiationDirtyPayload(raw: Record<string, unknown> | undefined): NegotiationDirtyPayload | null {
169
+ if (!raw) return null;
170
+ if (typeof raw.with !== 'string' || raw.with === '') return null;
171
+ return { with: raw.with };
172
+ }
173
+
174
+ export function parseTurnRefreshedPayload(raw: Record<string, unknown> | undefined): TurnRefreshedPayload | null {
175
+ if (!raw) return null;
176
+ if (typeof raw.turnToken !== 'string') return null;
177
+ return {
178
+ turnToken: raw.turnToken,
179
+ turnTokenTTLMs: typeof raw.turnTokenTTLMs === 'number' ? raw.turnTokenTTLMs : undefined,
180
+ };
181
+ }
182
+
183
+ export function parseOfferPayload(raw: Record<string, unknown> | undefined): OfferPayload | null {
184
+ if (!raw) return null;
185
+ if (typeof raw.from !== 'string' || raw.from === '') return null;
186
+ if (typeof raw.sdp !== 'string' || raw.sdp === '') return null;
187
+ return {
188
+ from: raw.from,
189
+ sdp: raw.sdp,
190
+ timestamp: typeof raw.timestamp === 'number' ? raw.timestamp : undefined,
191
+ };
192
+ }
193
+
194
+ export function parseAnswerPayload(raw: Record<string, unknown> | undefined): AnswerPayload | null {
195
+ if (!raw) return null;
196
+ if (typeof raw.from !== 'string' || raw.from === '') return null;
197
+ if (typeof raw.sdp !== 'string' || raw.sdp === '') return null;
198
+ return {
199
+ from: raw.from,
200
+ sdp: raw.sdp,
201
+ };
202
+ }
203
+
204
+ export function parseIceCandidatePayload(raw: Record<string, unknown> | undefined): IceCandidatePayload | null {
205
+ if (!raw) return null;
206
+ if (typeof raw.from !== 'string' || raw.from === '') return null;
207
+ const candidate = raw.candidate;
208
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return null;
209
+ const candObj = candidate as Record<string, unknown>;
210
+ if (typeof candObj.candidate !== 'string') return null;
211
+ return {
212
+ from: raw.from,
213
+ candidate: candidate as RTCIceCandidateInit,
214
+ };
215
+ }
@@ -0,0 +1,89 @@
1
+ export type RoomStatus = {
2
+ count: number;
3
+ maxParticipants?: number;
4
+ };
5
+
6
+ export type RoomStatuses = Record<string, RoomStatus>;
7
+
8
+ function isRecord(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
10
+ }
11
+
12
+ function normalizeMaxParticipants(value: unknown, fallback?: number): number | undefined {
13
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 2) {
14
+ return value;
15
+ }
16
+ if (typeof fallback === 'number' && Number.isFinite(fallback) && fallback >= 2) {
17
+ return fallback;
18
+ }
19
+ return undefined;
20
+ }
21
+
22
+ function parseRoomStatus(value: unknown, fallbackMaxParticipants?: number): RoomStatus | null {
23
+ if (typeof value === 'number' && Number.isFinite(value)) {
24
+ return {
25
+ count: value,
26
+ maxParticipants: normalizeMaxParticipants(fallbackMaxParticipants, 2)
27
+ };
28
+ }
29
+ if (!isRecord(value)) {
30
+ return null;
31
+ }
32
+
33
+ const count = value.count;
34
+ if (typeof count !== 'number' || !Number.isFinite(count)) {
35
+ return null;
36
+ }
37
+
38
+ return {
39
+ count,
40
+ maxParticipants: normalizeMaxParticipants(value.maxParticipants, fallbackMaxParticipants)
41
+ };
42
+ }
43
+
44
+ export function mergeRoomStatusesPayload(previous: RoomStatuses, payload: unknown): RoomStatuses {
45
+ if (!isRecord(payload)) {
46
+ return previous;
47
+ }
48
+
49
+ const next: RoomStatuses = { ...previous };
50
+ for (const [rid, value] of Object.entries(payload)) {
51
+ const status = parseRoomStatus(value, previous[rid]?.maxParticipants);
52
+ if (typeof rid === 'string' && status) {
53
+ next[rid] = status;
54
+ }
55
+ }
56
+ return next;
57
+ }
58
+
59
+ export function mergeRoomStatusUpdatePayload(previous: RoomStatuses, payload: unknown): RoomStatuses {
60
+ if (!isRecord(payload)) {
61
+ return previous;
62
+ }
63
+
64
+ const rid = payload.rid;
65
+ const count = payload.count;
66
+ if (typeof rid !== 'string' || typeof count !== 'number' || !Number.isFinite(count)) {
67
+ return previous;
68
+ }
69
+
70
+ const maxParticipants = normalizeMaxParticipants(payload.maxParticipants, previous[rid]?.maxParticipants);
71
+
72
+ return {
73
+ ...previous,
74
+ [rid]: {
75
+ count,
76
+ maxParticipants
77
+ }
78
+ };
79
+ }
80
+
81
+ export function getRoomStatusState(status?: RoomStatus | null): 'hidden' | 'waiting' | 'full' {
82
+ const count = status?.count ?? 0;
83
+ if (count <= 0) {
84
+ return 'hidden';
85
+ }
86
+
87
+ const maxParticipants = normalizeMaxParticipants(status?.maxParticipants, 2) ?? 2;
88
+ return count >= maxParticipants ? 'full' : 'waiting';
89
+ }
@@ -0,0 +1,30 @@
1
+ import type { TransportKind } from './transports/types.js';
2
+
3
+ const DEFAULT_TRANSPORTS: TransportKind[] = ['ws', 'sse'];
4
+
5
+ const normalizeTransport = (value: string): TransportKind | null => {
6
+ const normalized = value.trim().toLowerCase();
7
+ if (normalized === 'ws' || normalized === 'wss') return 'ws';
8
+ if (normalized === 'sse') return 'sse';
9
+ return null;
10
+ };
11
+
12
+ export const parseTransportOrder = (raw?: string | null): TransportKind[] => {
13
+ if (!raw) return DEFAULT_TRANSPORTS;
14
+
15
+ const parsed = raw
16
+ .split(',')
17
+ .map(normalizeTransport)
18
+ .filter((kind): kind is TransportKind => !!kind);
19
+
20
+ if (parsed.length === 0) return DEFAULT_TRANSPORTS;
21
+
22
+ const unique: TransportKind[] = [];
23
+ for (const kind of parsed) {
24
+ if (!unique.includes(kind)) {
25
+ unique.push(kind);
26
+ }
27
+ }
28
+
29
+ return unique;
30
+ };
@@ -0,0 +1,26 @@
1
+ import type { SerenadaLogger } from '../../types.js';
2
+ import type { SignalingTransport, TransportHandlers, TransportKind } from './types.js';
3
+ import { WebSocketTransport } from './ws.js';
4
+ import { SseTransport } from './sse.js';
5
+
6
+ export type { SignalingTransport, TransportHandlers, TransportKind } from './types.js';
7
+ export { WebSocketTransport } from './ws.js';
8
+ export { SseTransport } from './sse.js';
9
+
10
+ export interface CreateTransportOptions {
11
+ wsUrl: string;
12
+ httpBaseUrl: string;
13
+ sseSid?: string;
14
+ logger?: SerenadaLogger;
15
+ }
16
+
17
+ export const createSignalingTransport = (
18
+ kind: TransportKind,
19
+ handlers: TransportHandlers,
20
+ options: CreateTransportOptions,
21
+ ): SignalingTransport => {
22
+ if (kind === 'sse') {
23
+ return new SseTransport(handlers, options.httpBaseUrl, { sid: options.sseSid, logger: options.logger });
24
+ }
25
+ return new WebSocketTransport(handlers, options.wsUrl, options.logger);
26
+ };
@@ -0,0 +1,146 @@
1
+ import type { SerenadaLogger } from '../../types.js';
2
+ import type { SignalingMessage } from '../types.js';
3
+ import type { SignalingTransport, TransportHandlers, TransportKind } from './types.js';
4
+ import { CONNECT_TIMEOUT_MS } from '../../constants.js';
5
+ import { formatError } from '../../formatError.js';
6
+
7
+ const createSid = () => {
8
+ if (window.crypto && window.crypto.getRandomValues) {
9
+ const bytes = new Uint8Array(8);
10
+ window.crypto.getRandomValues(bytes);
11
+ return `S-${Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')}`;
12
+ }
13
+ return `S-${Math.random().toString(16).slice(2, 10)}${Math.random().toString(16).slice(2, 10)}`;
14
+ };
15
+
16
+ export class SseTransport implements SignalingTransport {
17
+ kind: TransportKind = 'sse';
18
+ private es: EventSource | null = null;
19
+ private handlers: TransportHandlers;
20
+ private open = false;
21
+ private sid: string;
22
+ private sseUrl: string;
23
+ private connectTimeout: number | null = null;
24
+ private logger?: SerenadaLogger;
25
+
26
+ constructor(handlers: TransportHandlers, baseUrl: string, options?: { sid?: string; logger?: SerenadaLogger }) {
27
+ this.handlers = handlers;
28
+ this.sid = options?.sid || createSid();
29
+ this.sseUrl = `${baseUrl}/sse`;
30
+ this.logger = options?.logger;
31
+ }
32
+
33
+ getSessionId(): string {
34
+ return this.sid;
35
+ }
36
+
37
+ private clearConnectTimeout() {
38
+ if (this.connectTimeout) {
39
+ window.clearTimeout(this.connectTimeout);
40
+ this.connectTimeout = null;
41
+ }
42
+ }
43
+
44
+ private detachEventSourceHandlers(es: EventSource) {
45
+ es.onopen = null;
46
+ es.onerror = null;
47
+ es.onmessage = null;
48
+ }
49
+
50
+ connect() {
51
+ if (typeof EventSource === 'undefined') {
52
+ this.open = false;
53
+ this.handlers.onClose('unsupported');
54
+ return;
55
+ }
56
+ const url = new URL(this.sseUrl);
57
+ url.searchParams.set('sid', this.sid);
58
+ this.es = new EventSource(url.toString());
59
+
60
+ this.connectTimeout = window.setTimeout(() => {
61
+ if (this.es && this.es.readyState !== EventSource.OPEN) {
62
+ this.logger?.log('warning', 'Transport', `SSE connection timeout after ${CONNECT_TIMEOUT_MS}ms`);
63
+ this.es.close();
64
+ this.es = null;
65
+ this.open = false;
66
+ this.handlers.onClose('timeout');
67
+ }
68
+ }, CONNECT_TIMEOUT_MS);
69
+
70
+ this.es.onopen = () => {
71
+ this.clearConnectTimeout();
72
+ this.open = true;
73
+ this.handlers.onOpen();
74
+ };
75
+
76
+ this.es.onerror = (err) => {
77
+ if (!this.es) return;
78
+ if (this.es.readyState === EventSource.CLOSED) {
79
+ this.clearConnectTimeout();
80
+ this.open = false;
81
+ this.handlers.onClose('close', err);
82
+ }
83
+ };
84
+
85
+ this.es.onmessage = (event) => {
86
+ try {
87
+ const msg: SignalingMessage = JSON.parse(event.data);
88
+ this.handlers.onMessage(msg);
89
+ } catch (e) {
90
+ this.logger?.log('error', 'Transport', `Failed to parse SSE message: ${formatError(e)}`);
91
+ }
92
+ };
93
+ }
94
+
95
+ close() {
96
+ this.clearConnectTimeout();
97
+ const es = this.es;
98
+ this.es = null;
99
+ if (es) {
100
+ this.detachEventSourceHandlers(es);
101
+ es.close();
102
+ }
103
+ this.open = false;
104
+ }
105
+
106
+ forceClose(reason: string) {
107
+ this.clearConnectTimeout();
108
+ const es = this.es;
109
+ this.es = null;
110
+ if (es) {
111
+ this.detachEventSourceHandlers(es);
112
+ es.close();
113
+ }
114
+ this.open = false;
115
+ this.handlers.onClose(reason);
116
+ }
117
+
118
+ isOpen() {
119
+ return this.open;
120
+ }
121
+
122
+ send(msg: SignalingMessage) {
123
+ const url = new URL(this.sseUrl);
124
+ url.searchParams.set('sid', this.sid);
125
+ fetch(url.toString(), {
126
+ method: 'POST',
127
+ headers: {
128
+ 'Content-Type': 'application/json',
129
+ },
130
+ body: JSON.stringify(msg)
131
+ })
132
+ .then(res => {
133
+ if (res.status === 410) {
134
+ this.open = false;
135
+ if (this.es) {
136
+ this.es.close();
137
+ this.es = null;
138
+ }
139
+ this.handlers.onClose('gone');
140
+ }
141
+ })
142
+ .catch(err => {
143
+ this.logger?.log('error', 'Transport', `Failed to send SSE message: ${formatError(err)}`);
144
+ });
145
+ }
146
+ }
@@ -0,0 +1,19 @@
1
+ import type { SignalingMessage } from '../types.js';
2
+
3
+ export type TransportKind = 'ws' | 'sse';
4
+
5
+ export type TransportHandlers = {
6
+ onOpen: () => void;
7
+ onClose: (reason: string, err?: unknown) => void;
8
+ onMessage: (msg: SignalingMessage) => void;
9
+ };
10
+
11
+ export interface SignalingTransport {
12
+ kind: TransportKind;
13
+ connect: () => void;
14
+ close: () => void;
15
+ forceClose?: (reason: string) => void;
16
+ send: (msg: SignalingMessage) => void;
17
+ isOpen: () => boolean;
18
+ getSessionId?: () => string;
19
+ }
@@ -0,0 +1,108 @@
1
+ import type { SerenadaLogger } from '../../types.js';
2
+ import type { SignalingMessage } from '../types.js';
3
+ import type { SignalingTransport, TransportHandlers, TransportKind } from './types.js';
4
+ import { CONNECT_TIMEOUT_MS } from '../../constants.js';
5
+ import { formatError } from '../../formatError.js';
6
+
7
+ export class WebSocketTransport implements SignalingTransport {
8
+ kind: TransportKind = 'ws';
9
+ private ws: WebSocket | null = null;
10
+ private handlers: TransportHandlers;
11
+ private open = false;
12
+ private connectTimeout: number | null = null;
13
+ private wsUrl: string;
14
+ private logger?: SerenadaLogger;
15
+
16
+ constructor(handlers: TransportHandlers, wsUrl: string, logger?: SerenadaLogger) {
17
+ this.handlers = handlers;
18
+ this.wsUrl = wsUrl;
19
+ this.logger = logger;
20
+ }
21
+
22
+ private clearConnectTimeout() {
23
+ if (this.connectTimeout) {
24
+ window.clearTimeout(this.connectTimeout);
25
+ this.connectTimeout = null;
26
+ }
27
+ }
28
+
29
+ private detachSocketHandlers(ws: WebSocket) {
30
+ ws.onopen = null;
31
+ ws.onclose = null;
32
+ ws.onerror = null;
33
+ ws.onmessage = null;
34
+ }
35
+
36
+ connect() {
37
+ this.ws = new WebSocket(this.wsUrl);
38
+
39
+ this.connectTimeout = window.setTimeout(() => {
40
+ if (this.ws && this.ws.readyState !== WebSocket.OPEN) {
41
+ this.logger?.log('warning', 'Transport', `WS connection timeout after ${CONNECT_TIMEOUT_MS}ms`);
42
+ this.ws.close();
43
+ this.open = false;
44
+ this.handlers.onClose('timeout');
45
+ }
46
+ }, CONNECT_TIMEOUT_MS);
47
+
48
+ this.ws.onopen = () => {
49
+ this.clearConnectTimeout();
50
+ this.open = true;
51
+ this.handlers.onOpen();
52
+ };
53
+
54
+ this.ws.onclose = (evt) => {
55
+ this.clearConnectTimeout();
56
+ this.open = false;
57
+ this.handlers.onClose('close', evt);
58
+ };
59
+
60
+ this.ws.onerror = (err) => {
61
+ this.clearConnectTimeout();
62
+ this.open = false;
63
+ this.handlers.onClose('error', err);
64
+ };
65
+
66
+ this.ws.onmessage = (event) => {
67
+ try {
68
+ const msg: SignalingMessage = JSON.parse(event.data);
69
+ this.handlers.onMessage(msg);
70
+ } catch (e) {
71
+ this.logger?.log('error', 'Transport', `Failed to parse WS message: ${formatError(e)}`);
72
+ }
73
+ };
74
+ }
75
+
76
+ close() {
77
+ this.clearConnectTimeout();
78
+ const ws = this.ws;
79
+ this.ws = null;
80
+ if (ws) {
81
+ this.detachSocketHandlers(ws);
82
+ ws.close();
83
+ }
84
+ this.open = false;
85
+ }
86
+
87
+ forceClose(reason: string) {
88
+ this.clearConnectTimeout();
89
+ const ws = this.ws;
90
+ this.ws = null;
91
+ if (ws) {
92
+ this.detachSocketHandlers(ws);
93
+ ws.close();
94
+ }
95
+ this.open = false;
96
+ this.handlers.onClose(reason);
97
+ }
98
+
99
+ isOpen() {
100
+ return !!this.ws && this.open && this.ws.readyState === WebSocket.OPEN;
101
+ }
102
+
103
+ send(msg: SignalingMessage) {
104
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
105
+ this.ws.send(JSON.stringify(msg));
106
+ }
107
+ }
108
+ }