@signalling/sdk 1.0.1
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/README.md +202 -0
- package/dist/index.d.mts +1568 -0
- package/dist/index.d.ts +1568 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +53 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,1568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for `@signalling/sdk`.
|
|
3
|
+
*
|
|
4
|
+
* Every type in this file maps to a wire-protocol artefact on the
|
|
5
|
+
* signalling HTTP / WebSocket API. Keep field names aligned with the
|
|
6
|
+
* server’s JSON contracts. See `docs/` in this package for integration
|
|
7
|
+
* context.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Authentication mode the SDK will use.
|
|
11
|
+
*
|
|
12
|
+
* - `bff` — Browser-for-frontend cookie flow. The backend holds Keycloak
|
|
13
|
+
* tokens; the browser only carries an opaque `session_id` cookie + a
|
|
14
|
+
* per-request DPoP proof. This is the default and only fully supported
|
|
15
|
+
* mode in v1.
|
|
16
|
+
* - `bearer` — Native client style: the integrator already owns an OIDC
|
|
17
|
+
* access token and provides it via the `getAccessToken` callback. The
|
|
18
|
+
* SDK adds the `Authorization: DPoP <jwt>` header and binds the proof
|
|
19
|
+
* via the `ath` claim. Reserved for a later milestone.
|
|
20
|
+
*/
|
|
21
|
+
type AuthMode = 'bff' | 'bearer';
|
|
22
|
+
/**
|
|
23
|
+
* Severity passed to a logger. Order matches RFC-style log levels.
|
|
24
|
+
*/
|
|
25
|
+
type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug';
|
|
26
|
+
/**
|
|
27
|
+
* Optional structured logger hook. The SDK never writes to `console`
|
|
28
|
+
* directly; it always goes through this interface so integrators can
|
|
29
|
+
* pipe logs into their own observability stack.
|
|
30
|
+
*/
|
|
31
|
+
interface Logger {
|
|
32
|
+
error: (...args: unknown[]) => void;
|
|
33
|
+
warn: (...args: unknown[]) => void;
|
|
34
|
+
info: (...args: unknown[]) => void;
|
|
35
|
+
debug: (...args: unknown[]) => void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Configuration object passed to `SignallingSDK.create()`.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* const sdk = await SignallingSDK.create({
|
|
43
|
+
* baseUrl: "https://api.example.com",
|
|
44
|
+
* authMode: "bff",
|
|
45
|
+
* appId: "customer-app-123",
|
|
46
|
+
* logLevel: "info",
|
|
47
|
+
* });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
interface SignallingSDKConfig {
|
|
51
|
+
/**
|
|
52
|
+
* Fully-qualified scheme + host of the backend (e.g. `https://api.confrnce.io`).
|
|
53
|
+
* Trailing slash is normalised away. The SDK derives WebSocket URLs by
|
|
54
|
+
* swapping `http` → `ws` / `https` → `wss` on this value.
|
|
55
|
+
*/
|
|
56
|
+
baseUrl: string;
|
|
57
|
+
/**
|
|
58
|
+
* Optional tenant / application identifier. Sent to the backend on
|
|
59
|
+
* select endpoints when present; reserved for the multi-tenant rollout.
|
|
60
|
+
*/
|
|
61
|
+
appId?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Auth mode. Defaults to `bff`.
|
|
64
|
+
*/
|
|
65
|
+
authMode?: AuthMode;
|
|
66
|
+
/**
|
|
67
|
+
* Bearer-mode hook. Required when `authMode === 'bearer'`; ignored
|
|
68
|
+
* otherwise. Must return a fresh OIDC access token (refresh-rotated
|
|
69
|
+
* by the integrator).
|
|
70
|
+
*/
|
|
71
|
+
getAccessToken?: () => Promise<string>;
|
|
72
|
+
/**
|
|
73
|
+
* Override the global `fetch`. Useful for tests / SSR / polyfilled
|
|
74
|
+
* runtimes. Defaults to `globalThis.fetch`.
|
|
75
|
+
*/
|
|
76
|
+
fetch?: typeof fetch;
|
|
77
|
+
/**
|
|
78
|
+
* Custom logger. Defaults to a console-backed logger gated by `logLevel`.
|
|
79
|
+
*/
|
|
80
|
+
logger?: Logger;
|
|
81
|
+
/**
|
|
82
|
+
* Minimum log level emitted by the default logger. Ignored when a
|
|
83
|
+
* custom `logger` is provided. Defaults to `'warn'`.
|
|
84
|
+
*/
|
|
85
|
+
logLevel?: LogLevel;
|
|
86
|
+
/**
|
|
87
|
+
* Convenience flag: `true` is shorthand for `logLevel: 'debug'`.
|
|
88
|
+
* Setting both — `logLevel` wins.
|
|
89
|
+
*/
|
|
90
|
+
debug?: boolean;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Lifecycle status of a user account. Mirrors `models.UserStatus` in the
|
|
94
|
+
* backend (`backend/models/user.go`) — values are emitted UPPERCASE on the
|
|
95
|
+
* wire. Keycloak owns the authentication state; this enum is the app-level
|
|
96
|
+
* status used for bans / suspensions / soft-delete that shouldn't round-trip
|
|
97
|
+
* through Keycloak.
|
|
98
|
+
*/
|
|
99
|
+
type UserStatus = 'ACTIVE' | 'PENDING_VERIFICATION' | 'SUSPENDED' | 'BANNED' | 'DELETED';
|
|
100
|
+
/**
|
|
101
|
+
* A user record as returned by `GET /me` and `GET /users/search`.
|
|
102
|
+
* Mirrors the JSON projection of `backend/models/user.go::User` — fields
|
|
103
|
+
* marked `json:"-"` on the Go struct (`ID`, `KeycloakSubjectID`) are
|
|
104
|
+
* intentionally NOT present on the wire and are NOT modeled here.
|
|
105
|
+
*/
|
|
106
|
+
interface UserProfile {
|
|
107
|
+
username: string;
|
|
108
|
+
firstName: string;
|
|
109
|
+
lastName?: string | null;
|
|
110
|
+
email?: string | null;
|
|
111
|
+
emailVerified?: boolean;
|
|
112
|
+
phone?: string | null;
|
|
113
|
+
phoneVerified?: boolean;
|
|
114
|
+
avatarUrl?: string | null;
|
|
115
|
+
status?: UserStatus;
|
|
116
|
+
statusReason?: string | null;
|
|
117
|
+
isStaff?: boolean;
|
|
118
|
+
lastLoginAt?: string | null;
|
|
119
|
+
createdAt?: string;
|
|
120
|
+
updatedAt?: string;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* A connected client (one per browser tab / device) as embedded inside
|
|
124
|
+
* `models.Room.Clients`. Mirrors the JSON projection of
|
|
125
|
+
* `backend/models/client.go::Client` — `UserID` is `json:"-"` on the Go
|
|
126
|
+
* struct and is NOT exposed on the wire (look up via `user.username`
|
|
127
|
+
* instead).
|
|
128
|
+
*
|
|
129
|
+
* `roomId` is the snowflake id rendered as a string at this layer (see
|
|
130
|
+
* note on `Room.id`).
|
|
131
|
+
*/
|
|
132
|
+
interface RoomClient {
|
|
133
|
+
clientId: string;
|
|
134
|
+
roomId: string;
|
|
135
|
+
user?: UserProfile;
|
|
136
|
+
joinedAt?: string;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Room as returned by `GET /viewroom/{roomId}`.
|
|
140
|
+
* Mirrors the JSON projection of `backend/models/room.go::Room`.
|
|
141
|
+
*
|
|
142
|
+
* NOTE on `id`: the backend serializes the snowflake id as a JSON number
|
|
143
|
+
* (`int64`). JavaScript's `number` cannot represent integers above 2^53
|
|
144
|
+
* losslessly, so the SDK normalises the snowflake to `string` at the API
|
|
145
|
+
* boundary. Treat the value as opaque; never do arithmetic on it. (A
|
|
146
|
+
* future revision may switch to `bigint` once we add a json-bigint
|
|
147
|
+
* dependency — until then the string normalisation matches the
|
|
148
|
+
* conventional workaround used by every JS consumer of this API.)
|
|
149
|
+
*/
|
|
150
|
+
interface Room {
|
|
151
|
+
id: string;
|
|
152
|
+
status: string;
|
|
153
|
+
host?: UserProfile;
|
|
154
|
+
currentScreenSharerClientId?: string;
|
|
155
|
+
clients: RoomClient[];
|
|
156
|
+
createdAt: string;
|
|
157
|
+
updatedAt: string;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Response of `POST /createroom` and `POST /joinroom/{roomId}`.
|
|
161
|
+
* The SDK keeps these as separate types — the backend does too — even
|
|
162
|
+
* though their shapes coincide today.
|
|
163
|
+
*
|
|
164
|
+
* `roomId` is normalised to `string` at the API boundary; see the note on
|
|
165
|
+
* `Room.id`.
|
|
166
|
+
*/
|
|
167
|
+
interface CreateRoomResponse {
|
|
168
|
+
roomId: string;
|
|
169
|
+
clientId: string;
|
|
170
|
+
}
|
|
171
|
+
interface JoinRoomResponse {
|
|
172
|
+
roomId: string;
|
|
173
|
+
clientId: string;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Response of `POST /v1/turn/credentials`.
|
|
177
|
+
* Mirrors `nat/internal/turn::TurnCredential` and the wire-protocol doc
|
|
178
|
+
* at `docs/wire-protocol-turn-credentials.md`.
|
|
179
|
+
*/
|
|
180
|
+
interface TurnCredential {
|
|
181
|
+
username: string;
|
|
182
|
+
credential: string;
|
|
183
|
+
urls: string[];
|
|
184
|
+
ttl: number;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Response of `POST /auth/ws-ticket`.
|
|
188
|
+
*/
|
|
189
|
+
interface WSTicket {
|
|
190
|
+
ticket: string;
|
|
191
|
+
nonce: string;
|
|
192
|
+
expiresIn: number;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Response of `GET /room/{roomId}/{clientId}/permissions`
|
|
196
|
+
* and `PUT` of the same path.
|
|
197
|
+
*
|
|
198
|
+
* `roomId` is normalised to `string` at the API boundary; see the note
|
|
199
|
+
* on `Room.id`.
|
|
200
|
+
*/
|
|
201
|
+
interface ClientPermissions {
|
|
202
|
+
roomId: string;
|
|
203
|
+
clientId: string;
|
|
204
|
+
canAudio: boolean;
|
|
205
|
+
canVideo: boolean;
|
|
206
|
+
canScreenShare: boolean;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Body of `PATCH /me`.
|
|
210
|
+
*/
|
|
211
|
+
interface UpdateProfileRequest {
|
|
212
|
+
firstName?: string;
|
|
213
|
+
lastName?: string;
|
|
214
|
+
avatarUrl?: string;
|
|
215
|
+
phone?: string;
|
|
216
|
+
username?: string;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Wire envelope for room WebSocket messages.
|
|
220
|
+
* Mirrors `backend/websocket/hub.go::WSMessage`.
|
|
221
|
+
*
|
|
222
|
+
* `payload` is intentionally `unknown` — message-type-specific shapes
|
|
223
|
+
* live below as discriminated unions when emitted as events.
|
|
224
|
+
*/
|
|
225
|
+
interface WSMessage<TPayload = unknown> {
|
|
226
|
+
type: string;
|
|
227
|
+
from: string;
|
|
228
|
+
to: string;
|
|
229
|
+
payload: TPayload;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Server-originated signal payload (`type: 'signal'`, `from: 'server'`).
|
|
233
|
+
* The `action` field discriminates between concrete events the backend
|
|
234
|
+
* emits today. The taxonomy of "server signals" is kept distinct from
|
|
235
|
+
* "peer signals" because the trust model differs — server signals are
|
|
236
|
+
* authoritative; peer signals are anything one client decides to send
|
|
237
|
+
* through the relay.
|
|
238
|
+
*/
|
|
239
|
+
type ServerSignalPayload = {
|
|
240
|
+
action: 'join';
|
|
241
|
+
newClientId: string;
|
|
242
|
+
newClientName?: string;
|
|
243
|
+
newClientAvatar?: string;
|
|
244
|
+
} | {
|
|
245
|
+
action: 'leave';
|
|
246
|
+
leftClientId: string;
|
|
247
|
+
leftClientName?: string;
|
|
248
|
+
} | {
|
|
249
|
+
action: 'hostChanged';
|
|
250
|
+
newHostId: string;
|
|
251
|
+
} | {
|
|
252
|
+
action: 'stopScreenShare';
|
|
253
|
+
} | {
|
|
254
|
+
action: 'permissionsUpdated';
|
|
255
|
+
canAudio: boolean;
|
|
256
|
+
canVideo: boolean;
|
|
257
|
+
canScreenShare: boolean;
|
|
258
|
+
};
|
|
259
|
+
/**
|
|
260
|
+
* Peer-originated signal payload (`type: 'signal'`, `from: <clientId>`).
|
|
261
|
+
* These ride the same `signal` envelope as server signals but are emitted
|
|
262
|
+
* by other clients in the room, NOT the backend. Receivers should validate
|
|
263
|
+
* fields and never assume authority — a malicious peer could craft any of
|
|
264
|
+
* these.
|
|
265
|
+
*
|
|
266
|
+
* - `mediaState` is broadcast on local mic/cam toggle and tracked per
|
|
267
|
+
* peer so other UIs can render mute/camera-off indicators.
|
|
268
|
+
* - `mediaStopped` is broadcast when a peer ends all local media (camera
|
|
269
|
+
* and screen share) — the receiver tears down both streams.
|
|
270
|
+
* - `screenShareStream` announces the `MediaStream.id` the sender is about
|
|
271
|
+
* to send so the receiver can route the resulting track to a separate
|
|
272
|
+
* "screen share" surface instead of treating it as camera.
|
|
273
|
+
* - `screenShareStopped` mirrors `screenShareStream` on the teardown side.
|
|
274
|
+
* - `chat` is the room-WS chat message (auto-broadcast by `Call.sendChat`).
|
|
275
|
+
*/
|
|
276
|
+
type PeerSignalPayload = {
|
|
277
|
+
action: 'mediaState';
|
|
278
|
+
video: boolean;
|
|
279
|
+
audio: boolean;
|
|
280
|
+
} | {
|
|
281
|
+
action: 'mediaStopped';
|
|
282
|
+
} | {
|
|
283
|
+
action: 'screenShareStream';
|
|
284
|
+
streamId: string;
|
|
285
|
+
} | {
|
|
286
|
+
action: 'screenShareStopped';
|
|
287
|
+
streamId: string;
|
|
288
|
+
} | {
|
|
289
|
+
action: 'chat';
|
|
290
|
+
id: string;
|
|
291
|
+
fromName: string;
|
|
292
|
+
text: string;
|
|
293
|
+
timestamp: string;
|
|
294
|
+
};
|
|
295
|
+
/**
|
|
296
|
+
* SDP payload — used in `type: 'sdp'` and SFU-mode `sfu-offer` / `sfu-answer`
|
|
297
|
+
* messages. Mirrors `backend/sfu::SDPPayload`.
|
|
298
|
+
*/
|
|
299
|
+
interface SdpPayload {
|
|
300
|
+
type: 'offer' | 'answer';
|
|
301
|
+
sdp: string;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* ICE candidate payload — used in `type: 'ice'` and SFU-mode `sfu-ice`
|
|
305
|
+
* messages. Matches the JSON serialisation produced by
|
|
306
|
+
* `RTCPeerConnection.onicecandidate`.
|
|
307
|
+
*/
|
|
308
|
+
interface IceCandidatePayload {
|
|
309
|
+
candidate: string;
|
|
310
|
+
sdpMid?: string | null;
|
|
311
|
+
sdpMLineIndex?: number | null;
|
|
312
|
+
usernameFragment?: string | null;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Constraints accepted by `SignallingSDK.calls.joinRoom`. The SDK
|
|
316
|
+
* forwards them to `getUserMedia` after acquiring the local stream.
|
|
317
|
+
*/
|
|
318
|
+
interface MediaConstraints {
|
|
319
|
+
audio?: boolean | MediaTrackConstraints;
|
|
320
|
+
video?: boolean | MediaTrackConstraints;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Configuration accepted by `sdk.calls.joinRoom`.
|
|
324
|
+
*/
|
|
325
|
+
interface CallConfig {
|
|
326
|
+
/** Snowflake room id returned by `sdk.rooms.create()` or shared out-of-band. */
|
|
327
|
+
roomId: number | string;
|
|
328
|
+
/** Local media constraints. Omit to skip `getUserMedia` (e.g. listen-only). */
|
|
329
|
+
media?: MediaConstraints;
|
|
330
|
+
/**
|
|
331
|
+
* If true the SDK will attempt to join the room's SFU pipeline by
|
|
332
|
+
* sending `sfu-join` after the WS handshake. Defaults to `false`
|
|
333
|
+
* (peer-to-peer / mesh mode).
|
|
334
|
+
*/
|
|
335
|
+
useSfu?: boolean;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* A single chat message delivered over the room WebSocket. Created by
|
|
339
|
+
* `Call.sendChat` on the sender and emitted as the `'chat'` event on
|
|
340
|
+
* each receiver. `id` is generated by the sender; `fromName` is filled
|
|
341
|
+
* in from the cached user profile when present (falls back to clientId).
|
|
342
|
+
*/
|
|
343
|
+
interface ChatMessage {
|
|
344
|
+
id: string;
|
|
345
|
+
from: string;
|
|
346
|
+
fromName: string;
|
|
347
|
+
text: string;
|
|
348
|
+
timestamp: string;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Per-peer media on/off state. Reflects the most recent `mediaState`
|
|
352
|
+
* signal seen from that peer; an absent entry means the peer has not
|
|
353
|
+
* yet announced its state and SHOULD be treated as `{ video: true,
|
|
354
|
+
* audio: true }` (the implicit default for newly-joined clients).
|
|
355
|
+
*/
|
|
356
|
+
interface PeerMediaState {
|
|
357
|
+
video: boolean;
|
|
358
|
+
audio: boolean;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Strongly-typed event map emitted by the room `Call` object returned
|
|
362
|
+
* by `sdk.calls.joinRoom`.
|
|
363
|
+
*/
|
|
364
|
+
interface CallEventMap {
|
|
365
|
+
/** A new peer joined the room. */
|
|
366
|
+
'peer.joined': {
|
|
367
|
+
clientId: string;
|
|
368
|
+
name?: string;
|
|
369
|
+
avatarUrl?: string;
|
|
370
|
+
};
|
|
371
|
+
/** A peer left the room (intentionally or via disconnect). */
|
|
372
|
+
'peer.left': {
|
|
373
|
+
clientId: string;
|
|
374
|
+
name?: string;
|
|
375
|
+
};
|
|
376
|
+
/** The room host changed (the original host left). */
|
|
377
|
+
'host.changed': {
|
|
378
|
+
newHostClientId: string;
|
|
379
|
+
};
|
|
380
|
+
/** The server told us we are no longer the active screen-sharer. */
|
|
381
|
+
'screen-share.stop': void;
|
|
382
|
+
/** The host updated our media permissions. */
|
|
383
|
+
'permissions.updated': Omit<ClientPermissions, 'roomId' | 'clientId'>;
|
|
384
|
+
/** Remote camera `MediaStream` arrived from a peer. Fired once per peer. */
|
|
385
|
+
'stream.added': {
|
|
386
|
+
clientId: string;
|
|
387
|
+
stream: MediaStream;
|
|
388
|
+
};
|
|
389
|
+
/** Remote peer's camera `MediaStream` is gone. */
|
|
390
|
+
'stream.removed': {
|
|
391
|
+
clientId: string;
|
|
392
|
+
};
|
|
393
|
+
/** Remote peer started screen sharing — emitted distinct from `stream.added`. */
|
|
394
|
+
'screen-share.added': {
|
|
395
|
+
clientId: string;
|
|
396
|
+
stream: MediaStream;
|
|
397
|
+
};
|
|
398
|
+
/** Remote peer stopped screen sharing. */
|
|
399
|
+
'screen-share.removed': {
|
|
400
|
+
clientId: string;
|
|
401
|
+
};
|
|
402
|
+
/** Peer's mic/camera enable state changed (via their `mediaState` broadcast). */
|
|
403
|
+
'peer.media-state-changed': {
|
|
404
|
+
clientId: string;
|
|
405
|
+
state: PeerMediaState;
|
|
406
|
+
};
|
|
407
|
+
/** Peer ended all media (camera + screen share) but is still in the room. */
|
|
408
|
+
'peer.media-stopped': {
|
|
409
|
+
clientId: string;
|
|
410
|
+
};
|
|
411
|
+
/** A chat message arrived (room WS signal with `action: 'chat'`). */
|
|
412
|
+
'chat': ChatMessage;
|
|
413
|
+
/**
|
|
414
|
+
* Underlying WebSocket closed. `fatal === true` when the close code is
|
|
415
|
+
* one the SDK will NOT auto-retry (auth failed, room/client missing,
|
|
416
|
+
* DPoP replay, etc.) — UIs should render an end-of-session state
|
|
417
|
+
* rather than a spinner. See `WSCloseCode` and the `NO_RETRY_CODES`
|
|
418
|
+
* set in `WebSocketClient`.
|
|
419
|
+
*/
|
|
420
|
+
'connection.closed': {
|
|
421
|
+
code: number;
|
|
422
|
+
reason: string;
|
|
423
|
+
fatal: boolean;
|
|
424
|
+
};
|
|
425
|
+
/** Underlying WebSocket reconnected after a transient drop. */
|
|
426
|
+
'connection.reopened': void;
|
|
427
|
+
/** Catch-all for anything we don't model — useful for debugging. */
|
|
428
|
+
'raw': WSMessage;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* A confirmed friend, as returned by `sdk.friends.list()` and embedded
|
|
432
|
+
* in `'friend.added'` events. Mirrors the JSON projection emitted by
|
|
433
|
+
* the global WS `friend_list` response — `{friendshipId, username,
|
|
434
|
+
* isOnline}`.
|
|
435
|
+
*
|
|
436
|
+
* The backend addresses peers by **username** (not user id) on the
|
|
437
|
+
* global WS — internal numeric ids are never on the wire. Pass
|
|
438
|
+
* `username` back as the `to` field of any outbound frame.
|
|
439
|
+
*/
|
|
440
|
+
interface Friend {
|
|
441
|
+
friendshipId: string;
|
|
442
|
+
/** Peer's public username — the addressing handle for global-WS frames. */
|
|
443
|
+
username: string;
|
|
444
|
+
isOnline: boolean;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* A pending incoming friend request. Returned by `sdk.friends.pending()`
|
|
448
|
+
* and emitted as the payload of `'friend.request-received'` events.
|
|
449
|
+
* `requester` is the requesting peer's username.
|
|
450
|
+
*/
|
|
451
|
+
interface PendingFriendRequest {
|
|
452
|
+
friendshipId: string;
|
|
453
|
+
requester: string;
|
|
454
|
+
createdAt: string;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Strongly-typed event map emitted by `sdk.friends` (and the call-invite
|
|
458
|
+
* surface on `sdk.calls`). Friends maintain a stateful cache inside the
|
|
459
|
+
* SDK; events fire whenever that cache changes.
|
|
460
|
+
*/
|
|
461
|
+
interface FriendsEventMap {
|
|
462
|
+
/** Cached friends list changed (after refresh, accept, remove, etc.). */
|
|
463
|
+
'friends.changed': {
|
|
464
|
+
friends: Friend[];
|
|
465
|
+
};
|
|
466
|
+
/** A new incoming friend request landed in the pending bucket. */
|
|
467
|
+
'friend.request-received': PendingFriendRequest;
|
|
468
|
+
/** Pending requests cache changed (after refresh, accept, reject, etc.). */
|
|
469
|
+
'friends.pending-changed': {
|
|
470
|
+
pending: PendingFriendRequest[];
|
|
471
|
+
};
|
|
472
|
+
/** A friend we previously requested has accepted us — they're now in `friends`. */
|
|
473
|
+
'friend.added': Friend;
|
|
474
|
+
/** A friend was removed (either side). */
|
|
475
|
+
'friend.removed': {
|
|
476
|
+
friendshipId: string;
|
|
477
|
+
};
|
|
478
|
+
/** Backend reported an error processing one of our frames. */
|
|
479
|
+
'friends.error': {
|
|
480
|
+
originalType: string;
|
|
481
|
+
error: string;
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* An incoming room invitation from a friend. Emitted by `sdk.calls` when
|
|
486
|
+
* the global WS receives a `call_invite` frame addressed to us.
|
|
487
|
+
*/
|
|
488
|
+
interface IncomingCallInvite {
|
|
489
|
+
/** Username of the inviter (the public handle the backend addresses peers by). */
|
|
490
|
+
callerUsername: string;
|
|
491
|
+
/** The room id the inviter wants us to join (string-normalised snowflake). */
|
|
492
|
+
roomId: string;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Event map for the call-invite surface exposed on `sdk.calls`.
|
|
496
|
+
*/
|
|
497
|
+
interface CallInviteEventMap {
|
|
498
|
+
'call.invite-received': IncomingCallInvite;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Thrown by `ApiClient` when the backend returns a non-2xx status.
|
|
502
|
+
* The original `Response` is preserved for callers who need to inspect
|
|
503
|
+
* headers (e.g. to read the new `DPoP-Nonce`).
|
|
504
|
+
*/
|
|
505
|
+
declare class SignallingApiError extends Error {
|
|
506
|
+
/** HTTP status code returned by the backend. */
|
|
507
|
+
readonly status: number;
|
|
508
|
+
/** Stable, machine-readable error code (matches `ErrorResponse.code`). */
|
|
509
|
+
readonly code?: string;
|
|
510
|
+
/** Field name when this is a validation error. */
|
|
511
|
+
readonly field?: string;
|
|
512
|
+
/** Raw response body, parsed as JSON when possible. */
|
|
513
|
+
readonly body: unknown;
|
|
514
|
+
/** Original `Response` for advanced callers. */
|
|
515
|
+
readonly response: Response;
|
|
516
|
+
constructor(message: string, response: Response, body: unknown);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Thrown by `WebSocketClient` when the WS upgrade or DPoP first-frame
|
|
520
|
+
* handshake fails.
|
|
521
|
+
*/
|
|
522
|
+
declare class SignallingWebSocketError extends Error {
|
|
523
|
+
/** Application close code (4400-4409 for DPoP, 4xxx-domain for room checks). */
|
|
524
|
+
readonly code?: number;
|
|
525
|
+
constructor(message: string, code?: number);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* `DPoPManager` owns the lifetime of the device's signing key plus a
|
|
530
|
+
* single nonce cache. It is meant to be instantiated exactly once per
|
|
531
|
+
* `SignallingSDK` instance.
|
|
532
|
+
*/
|
|
533
|
+
declare class DPoPManager {
|
|
534
|
+
private keypair;
|
|
535
|
+
private cachedJWK;
|
|
536
|
+
private readonly nonces;
|
|
537
|
+
private initPromise;
|
|
538
|
+
/**
|
|
539
|
+
* Eagerly load (or create) the keypair. Safe to call multiple times —
|
|
540
|
+
* concurrent callers all wait on the same `Promise`. Recommended at
|
|
541
|
+
* SDK boot so the first authed request doesn't pay the keygen cost.
|
|
542
|
+
*/
|
|
543
|
+
init(): Promise<void>;
|
|
544
|
+
private loadOrGenerate;
|
|
545
|
+
/**
|
|
546
|
+
* Build a DPoP proof JWS for the given request.
|
|
547
|
+
*
|
|
548
|
+
* @param method HTTP verb in upper-case (`GET`, `POST`, ...).
|
|
549
|
+
* For WS upgrades pass `GET`.
|
|
550
|
+
* @param url Full URL **including** query for HTTP requests, or
|
|
551
|
+
* with the query stripped for WebSocket upgrades.
|
|
552
|
+
* The caller should pre-canonicalise — `htu` must
|
|
553
|
+
* byte-match the URL the server reconstructs.
|
|
554
|
+
* @param accessTokenHash Optional `ath` claim — `b64url(SHA256(token))`.
|
|
555
|
+
* Required in bearer (mobile) flows; omitted in BFF.
|
|
556
|
+
*/
|
|
557
|
+
generateProof(method: string, url: string, accessTokenHash?: string): Promise<string>;
|
|
558
|
+
/**
|
|
559
|
+
* Update the cached nonce after every backend response. Pass the
|
|
560
|
+
* `Headers` object (or anything with `get`) — `null` is a no-op.
|
|
561
|
+
*/
|
|
562
|
+
updateNonceFromResponse(headers: Headers | {
|
|
563
|
+
get: (n: string) => string | null;
|
|
564
|
+
}): void;
|
|
565
|
+
/**
|
|
566
|
+
* Manually set the cached nonce. Used by `WebSocketClient` after it
|
|
567
|
+
* extracts the WS-ticket-embedded nonce.
|
|
568
|
+
*/
|
|
569
|
+
setNonce(nonce: string | null | undefined): void;
|
|
570
|
+
/**
|
|
571
|
+
* Drop both the cached nonce and the persisted keypair. The integrator
|
|
572
|
+
* should call this on logout to ensure the next login binds a fresh key.
|
|
573
|
+
*/
|
|
574
|
+
reset(): Promise<void>;
|
|
575
|
+
/**
|
|
576
|
+
* Compute the SHA-256 base64url-encoded thumbprint of the OAuth
|
|
577
|
+
* access token, suitable for use as the `ath` claim on bearer flows.
|
|
578
|
+
*/
|
|
579
|
+
static accessTokenHash(token: string): Promise<string>;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* `ApiClient` is the SDK's REST surface over the signalling HTTP API.
|
|
584
|
+
*
|
|
585
|
+
* Every method maps 1:1 onto a documented backend route. The class only knows about
|
|
586
|
+
* HTTP — nothing in here is aware of rooms, peers, or media.
|
|
587
|
+
*
|
|
588
|
+
* Three things differ from a vanilla `fetch`:
|
|
589
|
+
*
|
|
590
|
+
* 1. **DPoP injection.** Every authenticated request carries a fresh
|
|
591
|
+
* `DPoP: <jws>` header generated by `DPoPManager`. Bearer flows
|
|
592
|
+
* also pull an access token from the integrator's callback and add
|
|
593
|
+
* `Authorization: DPoP <token>` plus the `ath` claim.
|
|
594
|
+
*
|
|
595
|
+
* 2. **Nonce retry.** When the backend responds with `401` and the body
|
|
596
|
+
* code is `use_dpop_nonce`, the new `DPoP-Nonce` header is cached
|
|
597
|
+
* and the request is retried exactly once. Anything else is
|
|
598
|
+
* surfaced as a `SignallingApiError`.
|
|
599
|
+
*
|
|
600
|
+
* 3. **Cookie inclusion.** BFF mode sets `credentials: 'include'` so
|
|
601
|
+
* the opaque `session_id` cookie travels with every request.
|
|
602
|
+
*
|
|
603
|
+
* The auth / DPoP threat model is summarized in `docs/auth.md`.
|
|
604
|
+
*/
|
|
605
|
+
declare class ApiClient {
|
|
606
|
+
private readonly dpop;
|
|
607
|
+
private readonly baseUrl;
|
|
608
|
+
private readonly fetchImpl;
|
|
609
|
+
private readonly logger;
|
|
610
|
+
private readonly authMode;
|
|
611
|
+
private readonly getAccessToken?;
|
|
612
|
+
constructor(config: SignallingSDKConfig, dpop: DPoPManager, logger: Logger);
|
|
613
|
+
/** Absolute URL the SDK uses for a relative path. Exposed for WS/auth code. */
|
|
614
|
+
url(path: string): string;
|
|
615
|
+
/** Backend base URL (origin only, no trailing slash). */
|
|
616
|
+
get origin(): string;
|
|
617
|
+
/**
|
|
618
|
+
* `POST /auth/dpop/bind` — completes the DPoP bind step. The backend
|
|
619
|
+
* consumes the `bind_token`, materialises the session row, and sets
|
|
620
|
+
* the `session_id` cookie on the response. Returns the `return_to`
|
|
621
|
+
* URL the integrator originally requested (or empty string if none).
|
|
622
|
+
*
|
|
623
|
+
* Called by `AuthClient.completeBind`.
|
|
624
|
+
*/
|
|
625
|
+
dpopBind(bindToken: string): Promise<{
|
|
626
|
+
returnTo: string;
|
|
627
|
+
}>;
|
|
628
|
+
/** `GET /me` — returns the authenticated user's profile. */
|
|
629
|
+
getCurrentUser(): Promise<UserProfile>;
|
|
630
|
+
/** `PATCH /me` — partial profile update. */
|
|
631
|
+
updateProfile(updates: UpdateProfileRequest): Promise<UserProfile>;
|
|
632
|
+
/** `POST /auth/logout` — revokes the current session at Keycloak + locally. */
|
|
633
|
+
logout(): Promise<void>;
|
|
634
|
+
/** `POST /auth/logout-all` — revokes every session for this user. */
|
|
635
|
+
logoutAll(): Promise<void>;
|
|
636
|
+
/** `POST /auth/ws-ticket` — short-lived JWT to open a WebSocket. */
|
|
637
|
+
issueWSTicket(): Promise<WSTicket>;
|
|
638
|
+
/** `GET /users/search?email=...` — looks up a user by email. */
|
|
639
|
+
searchUserByEmail(email: string): Promise<UserProfile>;
|
|
640
|
+
/**
|
|
641
|
+
* `POST /createroom` — creates a new room and host client.
|
|
642
|
+
*
|
|
643
|
+
* The backend serializes `roomId` as a JSON number (`int64`); we
|
|
644
|
+
* stringify it at the boundary so the snowflake survives the
|
|
645
|
+
* `JSON.parse` → `number` round-trip. See the note on `Room.id` in
|
|
646
|
+
* the types file. The stringification happens AFTER `JSON.parse`,
|
|
647
|
+
* so values above 2^53 have already lost precision — matching the
|
|
648
|
+
* conventional JS workaround; a future revision will switch to a
|
|
649
|
+
* BigInt-aware parser.
|
|
650
|
+
*/
|
|
651
|
+
createRoom(): Promise<CreateRoomResponse>;
|
|
652
|
+
/** `POST /joinroom/{roomId}` — joins an existing room as a new client. */
|
|
653
|
+
joinRoom(roomId: number | string): Promise<JoinRoomResponse>;
|
|
654
|
+
/** `GET /viewroom/{roomId}` — returns full room state with participants. */
|
|
655
|
+
viewRoom(roomId: number | string): Promise<Room>;
|
|
656
|
+
/** `DELETE /leaveroom/{roomId}/{clientId}` — leaves the room. */
|
|
657
|
+
leaveRoom(roomId: number | string, clientId: string): Promise<void>;
|
|
658
|
+
/** `POST /room/{roomId}/{clientId}/screen-share/start` */
|
|
659
|
+
startScreenShare(roomId: number | string, clientId: string): Promise<{
|
|
660
|
+
status: string;
|
|
661
|
+
previousSharer?: string;
|
|
662
|
+
}>;
|
|
663
|
+
/** `POST /room/{roomId}/{clientId}/screen-share/stop` */
|
|
664
|
+
stopScreenShare(roomId: number | string, clientId: string): Promise<{
|
|
665
|
+
status: string;
|
|
666
|
+
}>;
|
|
667
|
+
/** `GET /room/{roomId}/{clientId}/permissions` */
|
|
668
|
+
getPermissions(roomId: number | string, clientId: string): Promise<ClientPermissions>;
|
|
669
|
+
/** `PUT /room/{roomId}/{clientId}/permissions` (host-only). */
|
|
670
|
+
updatePermissions(roomId: number | string, clientId: string, updates: Partial<Pick<ClientPermissions, 'canAudio' | 'canVideo' | 'canScreenShare'>>): Promise<ClientPermissions>;
|
|
671
|
+
/** `POST /v1/turn/credentials` — fresh, tenant-scoped STUN/TURN credentials. */
|
|
672
|
+
getTurnCredentials(ttlSeconds?: number): Promise<TurnCredential>;
|
|
673
|
+
private request;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Tiny strongly-typed event emitter used by `RoomManager`, `WebRTCManager`,
|
|
678
|
+
* and the `Call` object returned by `sdk.calls.joinRoom`. Kept in `internal/`
|
|
679
|
+
* so we can swap implementations without breaking the public API.
|
|
680
|
+
*
|
|
681
|
+
* `TEventMap` is intentionally typed with a loose constraint — concrete
|
|
682
|
+
* event maps (`CallEventMap`, `WebRTCEventMap`) are *closed* interfaces
|
|
683
|
+
* (no string-index signature) and TypeScript would reject them under
|
|
684
|
+
* `extends Record<string, unknown>`. Per-method generics give callers
|
|
685
|
+
* full type-safety where it matters.
|
|
686
|
+
*/
|
|
687
|
+
type Listener<T> = (payload: T) => void;
|
|
688
|
+
declare class TypedEventEmitter<TEventMap extends Record<string, any>> {
|
|
689
|
+
private listeners;
|
|
690
|
+
on<K extends keyof TEventMap>(event: K, fn: Listener<TEventMap[K]>): () => void;
|
|
691
|
+
off<K extends keyof TEventMap>(event: K, fn: Listener<TEventMap[K]>): void;
|
|
692
|
+
once<K extends keyof TEventMap>(event: K, fn: Listener<TEventMap[K]>): void;
|
|
693
|
+
/** @internal — used only by the SDK itself, not part of the public API. */
|
|
694
|
+
emit<K extends keyof TEventMap>(event: K, payload: TEventMap[K]): void;
|
|
695
|
+
/** Remove every listener (e.g. on disconnect / SDK teardown). */
|
|
696
|
+
clear(): void;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Events emitted by `AuthClient`.
|
|
701
|
+
*/
|
|
702
|
+
interface AuthEventMap {
|
|
703
|
+
/** `getCurrentUser` succeeded — payload is the resolved user. */
|
|
704
|
+
'state.authenticated': UserProfile;
|
|
705
|
+
/** `getCurrentUser` returned 401 — payload is the original error. */
|
|
706
|
+
'state.unauthenticated': SignallingApiError | null;
|
|
707
|
+
/** `logout` / `logoutAll` finished. */
|
|
708
|
+
'state.signed-out': void;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* `AuthClient` orchestrates the BFF authentication flow (see `docs/auth.md`).
|
|
712
|
+
* The flow looks like this in a
|
|
713
|
+
* browser:
|
|
714
|
+
*
|
|
715
|
+
* 1. SDK consumer calls `auth.login()` — the user is redirected to
|
|
716
|
+
* `${baseUrl}/auth/login` which handles PKCE + Keycloak.
|
|
717
|
+
* 2. Keycloak redirects back via `${baseUrl}/auth/callback`, which
|
|
718
|
+
* writes a 60-second `bind_ticket` and redirects the browser to
|
|
719
|
+
* `${frontendOrigin}/auth/complete#bind_token=…`.
|
|
720
|
+
* 3. The SPA's `/auth/complete` page calls `auth.completeBind(token)`
|
|
721
|
+
* which generates / loads the DPoP keypair and POSTs
|
|
722
|
+
* `/auth/dpop/bind` to materialise the session cookie.
|
|
723
|
+
* 4. From then on every authenticated call carries the session cookie
|
|
724
|
+
* and a fresh DPoP proof, both transparently injected by `ApiClient`.
|
|
725
|
+
*
|
|
726
|
+
* The same SDK works in the bearer / mobile flow if the integrator
|
|
727
|
+
* configures `authMode: 'bearer'` and supplies `getAccessToken`.
|
|
728
|
+
*/
|
|
729
|
+
declare class AuthClient extends TypedEventEmitter<AuthEventMap> {
|
|
730
|
+
private readonly config;
|
|
731
|
+
private readonly api;
|
|
732
|
+
private readonly dpop;
|
|
733
|
+
private readonly logger;
|
|
734
|
+
private cachedUser;
|
|
735
|
+
constructor(config: SignallingSDKConfig, api: ApiClient, dpop: DPoPManager, logger: Logger);
|
|
736
|
+
/**
|
|
737
|
+
* Synchronous accessor for the cached user profile. Returns `null`
|
|
738
|
+
* until `getCurrentUser()` has resolved at least once. Used by
|
|
739
|
+
* downstream modules (`Call.sendChat`, friends UI) that need the
|
|
740
|
+
* display name without paying the round-trip latency of a fresh
|
|
741
|
+
* fetch.
|
|
742
|
+
*/
|
|
743
|
+
get cachedCurrentUser(): UserProfile | null;
|
|
744
|
+
/**
|
|
745
|
+
* BFF login — navigates the user to the backend's OIDC entry point.
|
|
746
|
+
* Optionally accepts a `returnTo` URL the backend will append as a
|
|
747
|
+
* query parameter; the backend validates this against the configured
|
|
748
|
+
* allowed origins.
|
|
749
|
+
*
|
|
750
|
+
* Bearer mode does not use this method — the integrator runs OIDC +
|
|
751
|
+
* PKCE themselves on the native side.
|
|
752
|
+
*/
|
|
753
|
+
login(returnTo?: string): void;
|
|
754
|
+
/**
|
|
755
|
+
* Read the bind token from the current URL fragment (the format
|
|
756
|
+
* Keycloak's callback produces: `/auth/complete#bind_token=…`). Useful
|
|
757
|
+
* shortcut so SPAs can call:
|
|
758
|
+
*
|
|
759
|
+
* ```ts
|
|
760
|
+
* const token = sdk.auth.bindTokenFromFragment();
|
|
761
|
+
* if (token) await sdk.auth.completeBind(token);
|
|
762
|
+
* ```
|
|
763
|
+
*/
|
|
764
|
+
bindTokenFromFragment(fragment?: string): string | null;
|
|
765
|
+
/**
|
|
766
|
+
* Complete the DPoP bind step. Generates a keypair (if needed) and
|
|
767
|
+
* POSTs the bind ticket to `/auth/dpop/bind`. On success the backend
|
|
768
|
+
* sets a `session_id` cookie and returns the `return_to` URL the
|
|
769
|
+
* integrator originally requested.
|
|
770
|
+
*
|
|
771
|
+
* **Multi-tab safety.** The whole bind transaction (keypair lookup +
|
|
772
|
+
* POST + ticket consume) is serialised through the Web Locks API
|
|
773
|
+
* under the name `signalling-sdk-dpop-bind-flow`. Without this, two
|
|
774
|
+
* tabs that both navigate to the bind landing page race the bind
|
|
775
|
+
* ticket — only one wins, the other gets `bind_ticket_consumed`. The
|
|
776
|
+
* lock uses a distinct name from the keypair-generation lock inside
|
|
777
|
+
* `DPoPManager` because Web Locks are NOT reentrant within a single
|
|
778
|
+
* client (reusing the same name would deadlock).
|
|
779
|
+
*
|
|
780
|
+
* **Nonce churn.** The backend may respond with one of three nonce
|
|
781
|
+
* challenge codes (`use_dpop_nonce`, `invalid_nonce`, `expired_nonce`)
|
|
782
|
+
* before accepting the request. `ApiClient.request` retries
|
|
783
|
+
* automatically on all three.
|
|
784
|
+
*
|
|
785
|
+
* **Failure recovery.** On a non-recoverable bind failure the SDK
|
|
786
|
+
* resets the DPoP key state (clears the IndexedDB keypair and nonce
|
|
787
|
+
* cache) so the next attempt generates fresh material — matching the
|
|
788
|
+
* behaviour the desktop's hand-rolled `/auth/complete` page used to
|
|
789
|
+
* implement. The original error is re-thrown either way.
|
|
790
|
+
*/
|
|
791
|
+
completeBind(bindToken: string): Promise<{
|
|
792
|
+
returnTo: string;
|
|
793
|
+
}>;
|
|
794
|
+
/**
|
|
795
|
+
* Resolve the currently signed-in user. Returns `null` when the
|
|
796
|
+
* session is missing or expired. Caches the result so multiple
|
|
797
|
+
* components can call it without thrashing the backend.
|
|
798
|
+
*/
|
|
799
|
+
getCurrentUser(force?: boolean): Promise<UserProfile | null>;
|
|
800
|
+
/** Convenience boolean wrapper around `getCurrentUser`. */
|
|
801
|
+
isAuthenticated(): Promise<boolean>;
|
|
802
|
+
/**
|
|
803
|
+
* `PATCH /me` — partial profile update. The cached `currentUser` is
|
|
804
|
+
* refreshed in place and `state.authenticated` is re-emitted so any
|
|
805
|
+
* React provider listening to that event re-renders the new profile
|
|
806
|
+
* without an extra round-trip. The semantic is "the canonical user
|
|
807
|
+
* just changed; here it is" — exactly what `state.authenticated`
|
|
808
|
+
* means, so we reuse it instead of inventing a new event.
|
|
809
|
+
*/
|
|
810
|
+
updateProfile(updates: UpdateProfileRequest): Promise<UserProfile>;
|
|
811
|
+
/** `POST /auth/logout` — single-device sign-out. */
|
|
812
|
+
logout(): Promise<void>;
|
|
813
|
+
/** `POST /auth/logout-all` — sign out everywhere. */
|
|
814
|
+
logoutAll(): Promise<void>;
|
|
815
|
+
/**
|
|
816
|
+
* Navigate to Keycloak's account console (change password,
|
|
817
|
+
* manage MFA, view active sessions). BFF-only.
|
|
818
|
+
*/
|
|
819
|
+
openAccountConsole(): void;
|
|
820
|
+
private afterSignOut;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Close codes the backend uses **after** a successful WS upgrade.
|
|
825
|
+
* Close codes align with the server’s WebSocket DPoP / negotiate layers
|
|
826
|
+
* (4400–4409, 4500, etc.).
|
|
827
|
+
*/
|
|
828
|
+
declare const WSCloseCode: {
|
|
829
|
+
/** First frame wasn't valid JSON / wrong type. */
|
|
830
|
+
readonly BAD_FIRST_FRAME: 4400;
|
|
831
|
+
/** Ticket bad/expired or proof failed verification. */
|
|
832
|
+
readonly AUTH_FAILED: 4401;
|
|
833
|
+
/** Post-upgrade authorization failed (e.g. client not in room). */
|
|
834
|
+
readonly FORBIDDEN: 4403;
|
|
835
|
+
/** Post-upgrade resource lookup failed (room/client missing). */
|
|
836
|
+
readonly NOT_FOUND: 4404;
|
|
837
|
+
/** No first frame within 5 seconds. */
|
|
838
|
+
readonly HANDSHAKE_TIMEOUT: 4408;
|
|
839
|
+
/** First-frame `jti` already seen — replay detected. */
|
|
840
|
+
readonly REPLAY: 4409;
|
|
841
|
+
/** Post-upgrade internal error. */
|
|
842
|
+
readonly INTERNAL: 4500;
|
|
843
|
+
};
|
|
844
|
+
/**
|
|
845
|
+
* Returns `true` when a WebSocket close code is one the SDK refuses to
|
|
846
|
+
* auto-retry. UIs should render an end-of-session state on a fatal
|
|
847
|
+
* close instead of a "reconnecting" spinner. Mirror of the
|
|
848
|
+
* `NO_RETRY_CODES` set used internally for backoff decisions.
|
|
849
|
+
*/
|
|
850
|
+
declare function isFatalCloseCode(code: number): boolean;
|
|
851
|
+
/**
|
|
852
|
+
* Internal events emitted by `WebSocketClient`. Higher-level modules
|
|
853
|
+
* (`RoomManager`, `WebRTCManager`) subscribe to these.
|
|
854
|
+
*/
|
|
855
|
+
interface WebSocketEventMap {
|
|
856
|
+
/** WS handshake (upgrade + DPoP first-frame + ack) completed. */
|
|
857
|
+
open: void;
|
|
858
|
+
/** Underlying socket closed. `code`/`reason` are the WS close info. */
|
|
859
|
+
close: {
|
|
860
|
+
code: number;
|
|
861
|
+
reason: string;
|
|
862
|
+
};
|
|
863
|
+
/** Network or decoding error. */
|
|
864
|
+
error: Error;
|
|
865
|
+
/** Any application-level message (after the handshake). */
|
|
866
|
+
message: WSMessage;
|
|
867
|
+
/** Catch-all bucketed by message type. Listeners for, e.g., `sdp` get only SDP frames. */
|
|
868
|
+
[eventType: `type:${string}`]: WSMessage;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Two endpoint shapes — the room WS and the global notification WS.
|
|
872
|
+
* The SDK exposes only the room shape today; `connectGlobal` is reserved
|
|
873
|
+
* for a later milestone.
|
|
874
|
+
*/
|
|
875
|
+
type WebSocketTarget = {
|
|
876
|
+
kind: 'room';
|
|
877
|
+
roomId: number | string;
|
|
878
|
+
clientId: string;
|
|
879
|
+
} | {
|
|
880
|
+
kind: 'global';
|
|
881
|
+
};
|
|
882
|
+
interface WebSocketClientOptions {
|
|
883
|
+
/** Maximum reconnect attempts after a transient drop. Default: 5. */
|
|
884
|
+
maxReconnects?: number;
|
|
885
|
+
/** Base delay (ms) for exponential backoff. Default: 1000. */
|
|
886
|
+
reconnectBaseDelayMs?: number;
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Manages the lifecycle of a single authenticated WebSocket. Wraps the
|
|
890
|
+
* full handshake protocol described in `docs/auth.md` and your backend’s
|
|
891
|
+
* WebSocket upgrade spec:
|
|
892
|
+
*
|
|
893
|
+
* 1. `POST /auth/ws-ticket` — get a 10-second JWT bound to the device
|
|
894
|
+
* key plus a server-issued nonce.
|
|
895
|
+
* 2. Open the socket using `?ticket=<jwt>` (the only place the ticket
|
|
896
|
+
* can travel — browsers can't set headers on WS upgrades).
|
|
897
|
+
* 3. Within 5 seconds send the DPoP first frame
|
|
898
|
+
* `{ "type": "dpop_handshake", "proof": "..." }` whose proof's
|
|
899
|
+
* `htu` exactly matches the WS URL with the query stripped.
|
|
900
|
+
* 4. Wait for `{ "type": "dpop_handshake_ack" }` before any application
|
|
901
|
+
* frames. Any other first response = treat as a fatal error.
|
|
902
|
+
*
|
|
903
|
+
* After the handshake, the wire is plain `WSMessage` envelopes
|
|
904
|
+
* `{ type, from, to, payload }`.
|
|
905
|
+
*/
|
|
906
|
+
declare class WebSocketClient extends TypedEventEmitter<WebSocketEventMap> {
|
|
907
|
+
private readonly api;
|
|
908
|
+
private readonly dpop;
|
|
909
|
+
private readonly logger;
|
|
910
|
+
private socket;
|
|
911
|
+
private target;
|
|
912
|
+
private acked;
|
|
913
|
+
private explicitlyClosed;
|
|
914
|
+
private reconnectAttempts;
|
|
915
|
+
private readonly options;
|
|
916
|
+
constructor(api: ApiClient, dpop: DPoPManager, logger: Logger, options?: WebSocketClientOptions);
|
|
917
|
+
/** True once the DPoP first-frame handshake has been acked by the server. */
|
|
918
|
+
get isReady(): boolean;
|
|
919
|
+
/**
|
|
920
|
+
* Open and authenticate a room WebSocket. The `clientId` must be the
|
|
921
|
+
* one returned from `POST /joinroom/{roomId}` for the same session.
|
|
922
|
+
*/
|
|
923
|
+
connectRoom(roomId: number | string, clientId: string): Promise<void>;
|
|
924
|
+
/**
|
|
925
|
+
* Open and authenticate the global notification WebSocket.
|
|
926
|
+
* Reserved for the friends / presence work; no app-level frames are
|
|
927
|
+
* defined on this socket today.
|
|
928
|
+
*/
|
|
929
|
+
connectGlobal(): Promise<void>;
|
|
930
|
+
private connect;
|
|
931
|
+
/** Send an application frame. The handshake must have completed. */
|
|
932
|
+
send(message: WSMessage): void;
|
|
933
|
+
/** Close the socket and disable automatic reconnects. */
|
|
934
|
+
disconnect(code?: number, reason?: string): void;
|
|
935
|
+
private urlFor;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Snapshot of the local membership in a room. Held by `RoomManager` for
|
|
940
|
+
* the lifetime of a single `joinRoom` call so other modules
|
|
941
|
+
* (`WebRTCManager`, the `Call` event surface, screen-share helpers)
|
|
942
|
+
* have one source of truth for `roomId` + `clientId`.
|
|
943
|
+
*/
|
|
944
|
+
interface ActiveMembership {
|
|
945
|
+
roomId: number | string;
|
|
946
|
+
clientId: string;
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* `RoomManager` is the ergonomic wrapper around the room REST endpoints.
|
|
950
|
+
* It also keeps
|
|
951
|
+
* track of the **local** `clientId` so callers don't have to thread it
|
|
952
|
+
* through every method that needs it (screen share, permissions, leave).
|
|
953
|
+
*
|
|
954
|
+
* It does **not** manage WebRTC peer connections — that lives in
|
|
955
|
+
* `WebRTCManager`. It does **not** open the WebSocket — that's
|
|
956
|
+
* `WebSocketClient`. Composition happens in the top-level `SignallingSDK`.
|
|
957
|
+
*/
|
|
958
|
+
declare class RoomManager {
|
|
959
|
+
private readonly api;
|
|
960
|
+
private readonly ws;
|
|
961
|
+
private readonly logger;
|
|
962
|
+
private active;
|
|
963
|
+
constructor(api: ApiClient, ws: WebSocketClient, logger: Logger);
|
|
964
|
+
/** Currently joined room snapshot, or `null` if not in a room. */
|
|
965
|
+
get current(): ActiveMembership | null;
|
|
966
|
+
/** `POST /createroom` — creates a room and returns `{ roomId, clientId }`. */
|
|
967
|
+
create(): Promise<CreateRoomResponse>;
|
|
968
|
+
/**
|
|
969
|
+
* `POST /joinroom/{roomId}` — joins an existing room and stores the
|
|
970
|
+
* resulting `clientId` for subsequent calls. Does NOT open a WebSocket;
|
|
971
|
+
* use `connectSocket` for that.
|
|
972
|
+
*/
|
|
973
|
+
join(roomId: number | string): Promise<JoinRoomResponse>;
|
|
974
|
+
/**
|
|
975
|
+
* Open the room WebSocket for the active membership. Performs the
|
|
976
|
+
* full ticket → upgrade → DPoP first-frame handshake.
|
|
977
|
+
*
|
|
978
|
+
* Throws if the caller hasn't `create()`d / `join()`ed a room first.
|
|
979
|
+
*/
|
|
980
|
+
connectSocket(): Promise<void>;
|
|
981
|
+
/** `GET /viewroom/{roomId}` — returns the full room snapshot. */
|
|
982
|
+
view(roomId?: number | string): Promise<Room>;
|
|
983
|
+
/**
|
|
984
|
+
* `DELETE /leaveroom/{roomId}/{clientId}` + closes the WebSocket.
|
|
985
|
+
* Always tries to clean up local state even if the network call fails.
|
|
986
|
+
*/
|
|
987
|
+
leave(): Promise<void>;
|
|
988
|
+
startScreenShare(): Promise<{
|
|
989
|
+
status: string;
|
|
990
|
+
previousSharer?: string;
|
|
991
|
+
}>;
|
|
992
|
+
stopScreenShare(): Promise<{
|
|
993
|
+
status: string;
|
|
994
|
+
}>;
|
|
995
|
+
/** Permissions for the local client. */
|
|
996
|
+
myPermissions(): Promise<ClientPermissions>;
|
|
997
|
+
/**
|
|
998
|
+
* Update permissions for any client in the room. Fails with `403` if
|
|
999
|
+
* the local user is not the host.
|
|
1000
|
+
*/
|
|
1001
|
+
updatePermissions(targetClientId: string, updates: Partial<Pick<ClientPermissions, 'canAudio' | 'canVideo' | 'canScreenShare'>>): Promise<ClientPermissions>;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Events emitted by `WebRTCManager`. Surfaced through the `Call` object
|
|
1006
|
+
* returned by `sdk.calls.joinRoom`.
|
|
1007
|
+
*/
|
|
1008
|
+
interface WebRTCEventMap {
|
|
1009
|
+
/** A remote camera `MediaStream` arrived from `peerId`. */
|
|
1010
|
+
'stream.added': {
|
|
1011
|
+
clientId: string;
|
|
1012
|
+
stream: MediaStream;
|
|
1013
|
+
};
|
|
1014
|
+
/** The peer connection's camera stream is gone. */
|
|
1015
|
+
'stream.removed': {
|
|
1016
|
+
clientId: string;
|
|
1017
|
+
};
|
|
1018
|
+
/** Remote peer started a screen-share stream (separate surface from camera). */
|
|
1019
|
+
'screen-share.added': {
|
|
1020
|
+
clientId: string;
|
|
1021
|
+
stream: MediaStream;
|
|
1022
|
+
};
|
|
1023
|
+
/** Remote peer's screen-share stream ended. */
|
|
1024
|
+
'screen-share.removed': {
|
|
1025
|
+
clientId: string;
|
|
1026
|
+
};
|
|
1027
|
+
/** Peer broadcast a `mediaState` signal — their mic/cam enable toggled. */
|
|
1028
|
+
'peer.media-state-changed': {
|
|
1029
|
+
clientId: string;
|
|
1030
|
+
state: PeerMediaState;
|
|
1031
|
+
};
|
|
1032
|
+
/** Peer broadcast `mediaStopped` — they ended all local media. */
|
|
1033
|
+
'peer.media-stopped': {
|
|
1034
|
+
clientId: string;
|
|
1035
|
+
};
|
|
1036
|
+
/** Underlying RTCPeerConnection state change. */
|
|
1037
|
+
'peer.connection-state': {
|
|
1038
|
+
clientId: string;
|
|
1039
|
+
state: RTCPeerConnectionState;
|
|
1040
|
+
};
|
|
1041
|
+
/** A negotiation attempt failed irrecoverably. */
|
|
1042
|
+
'peer.failed': {
|
|
1043
|
+
clientId: string;
|
|
1044
|
+
error: Error;
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Identity of the local client — supplied by the orchestrator
|
|
1049
|
+
* (`SignallingSDK`) so the WebRTC manager can correctly address
|
|
1050
|
+
* outbound `WSMessage` envelopes.
|
|
1051
|
+
*/
|
|
1052
|
+
interface LocalIdentity {
|
|
1053
|
+
clientId: string;
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* `WebRTCManager` orchestrates `RTCPeerConnection` instances on top of
|
|
1057
|
+
* the room WebSocket. The wire format mirrors what the room WebSocket
|
|
1058
|
+
* handler expects:
|
|
1059
|
+
*
|
|
1060
|
+
* - `{type: 'sdp', from, to, payload: { type, sdp } }`
|
|
1061
|
+
* - `{type: 'ice', from, to, payload: { candidate, ... } }`
|
|
1062
|
+
* - `{type: 'signal', from: 'server' | clientId, to, payload: { action, ... } }`
|
|
1063
|
+
*
|
|
1064
|
+
* SDP messages are routed peer-to-peer by the backend; the only thing
|
|
1065
|
+
* the server inspects is the `to` field. Each remote peer gets its own
|
|
1066
|
+
* `RTCPeerConnection` keyed by `clientId`.
|
|
1067
|
+
*
|
|
1068
|
+
* **Camera vs screen-share segregation.** Both streams ride the same
|
|
1069
|
+
* `RTCPeerConnection`. Each peer broadcasts a `screenShareStream` signal
|
|
1070
|
+
* carrying the `MediaStream.id` BEFORE adding its screen tracks; the
|
|
1071
|
+
* receiver records that id and routes the matching incoming
|
|
1072
|
+
* `MediaStream` to the `screen-share.added` event rather than
|
|
1073
|
+
* `stream.added`. A matching `screenShareStopped` signal clears the
|
|
1074
|
+
* mapping when the sharer ends.
|
|
1075
|
+
*
|
|
1076
|
+
* **Peer-state side channel.** Peers also broadcast `mediaState` /
|
|
1077
|
+
* `mediaStopped` signals (purely informational — the backend does not
|
|
1078
|
+
* validate or replicate them). The manager records the most recent
|
|
1079
|
+
* state per peer so UIs can render mute / camera-off indicators
|
|
1080
|
+
* without relying on the inbound track being absent (which it usually
|
|
1081
|
+
* isn't — `setMicEnabled(false)` only disables the track locally).
|
|
1082
|
+
*/
|
|
1083
|
+
declare class WebRTCManager extends TypedEventEmitter<WebRTCEventMap> {
|
|
1084
|
+
private readonly ws;
|
|
1085
|
+
private readonly logger;
|
|
1086
|
+
private readonly peers;
|
|
1087
|
+
private readonly peerMediaStates;
|
|
1088
|
+
/** Maps each peer's announced screen `MediaStream.id` to that peer's clientId. */
|
|
1089
|
+
private readonly screenStreamIds;
|
|
1090
|
+
/** Senders we added when we started screen-sharing — used so we can drop them on stop. */
|
|
1091
|
+
private readonly screenSenders;
|
|
1092
|
+
private localStream;
|
|
1093
|
+
private localScreenStream;
|
|
1094
|
+
private iceServers;
|
|
1095
|
+
private local;
|
|
1096
|
+
private wsUnsubs;
|
|
1097
|
+
constructor(ws: WebSocketClient, logger: Logger);
|
|
1098
|
+
/**
|
|
1099
|
+
* Wire up the manager to a freshly authenticated WebSocket. Must be
|
|
1100
|
+
* called after the WS handshake completes and the local `clientId`
|
|
1101
|
+
* is known. Safe to call again with a different identity — the old
|
|
1102
|
+
* subscriptions are removed first.
|
|
1103
|
+
*/
|
|
1104
|
+
bind(local: LocalIdentity): void;
|
|
1105
|
+
/** Drop all WS subscriptions and tear down every peer connection. */
|
|
1106
|
+
unbind(): void;
|
|
1107
|
+
/**
|
|
1108
|
+
* Read-only access to the live peer-connection map. Exposed for
|
|
1109
|
+
* integrators that need to read RTCStatsReport (`pc.getStats()`),
|
|
1110
|
+
* inspect senders for adaptive bitrate, or attach analyser nodes for
|
|
1111
|
+
* active-speaker detection — operations that are out of scope for the
|
|
1112
|
+
* SDK but still need first-class peer access. The returned `Map` is
|
|
1113
|
+
* a live view; do NOT mutate it.
|
|
1114
|
+
*/
|
|
1115
|
+
getPeerConnections(): ReadonlyMap<string, RTCPeerConnection>;
|
|
1116
|
+
/** Read-only access to the current local camera `MediaStream`. */
|
|
1117
|
+
getLocalStream(): MediaStream | null;
|
|
1118
|
+
/** Read-only access to the current local screen-share `MediaStream`, if any. */
|
|
1119
|
+
getLocalScreenStream(): MediaStream | null;
|
|
1120
|
+
/**
|
|
1121
|
+
* Read-only access to the latest `mediaState` we've seen per peer. An
|
|
1122
|
+
* absent entry means the peer has not announced yet — treat as
|
|
1123
|
+
* `{ video: true, audio: true }` (the implicit default).
|
|
1124
|
+
*/
|
|
1125
|
+
getPeerMediaStates(): ReadonlyMap<string, PeerMediaState>;
|
|
1126
|
+
/** Inject TURN credentials. The next created peer connection will pick them up. */
|
|
1127
|
+
setTurnCredentials(credentials: TurnCredential | TurnCredential[] | null): void;
|
|
1128
|
+
/**
|
|
1129
|
+
* Replace the local outbound camera `MediaStream`. Tracks are added /
|
|
1130
|
+
* replaced on every existing peer connection. Screen-share tracks are
|
|
1131
|
+
* NOT touched — they're tracked separately via `addScreenStream`.
|
|
1132
|
+
*/
|
|
1133
|
+
setLocalStream(stream: MediaStream | null): Promise<void>;
|
|
1134
|
+
/**
|
|
1135
|
+
* Begin sharing a screen `MediaStream` to all current peers
|
|
1136
|
+
* **alongside** the existing camera tracks (the Zoom / Meet pattern).
|
|
1137
|
+
*
|
|
1138
|
+
* Wire protocol (matches the backend's relayed signal contract):
|
|
1139
|
+
* 1. For each peer, send `{action: 'screenShareStream', streamId}`
|
|
1140
|
+
* on the room WS so the receiver knows the next `ontrack` whose
|
|
1141
|
+
* stream id matches is screen, not camera.
|
|
1142
|
+
* 2. Add the screen tracks to that peer's `RTCPeerConnection`.
|
|
1143
|
+
* 3. Renegotiate (createOffer / setLocalDescription / send SDP).
|
|
1144
|
+
*
|
|
1145
|
+
* Idempotent — calling twice without an intervening
|
|
1146
|
+
* `removeScreenStream` is a no-op.
|
|
1147
|
+
*/
|
|
1148
|
+
addScreenStream(stream: MediaStream): Promise<void>;
|
|
1149
|
+
/**
|
|
1150
|
+
* Stop sharing the local screen `MediaStream`. Removes the screen
|
|
1151
|
+
* senders from every peer connection and broadcasts
|
|
1152
|
+
* `screenShareStopped` so receivers can tear down their screen
|
|
1153
|
+
* surface immediately. Idempotent — no-op when nothing is shared.
|
|
1154
|
+
*/
|
|
1155
|
+
removeScreenStream(): Promise<void>;
|
|
1156
|
+
/**
|
|
1157
|
+
* Broadcast our mic/cam enable state to every peer in the room.
|
|
1158
|
+
* Auto-called by `Call.setMicEnabled` / `Call.setCameraEnabled` so
|
|
1159
|
+
* peer UIs can render mute / camera-off indicators without inspecting
|
|
1160
|
+
* the inbound track (which usually keeps flowing in disabled state).
|
|
1161
|
+
*/
|
|
1162
|
+
broadcastMediaState(state: PeerMediaState): void;
|
|
1163
|
+
/**
|
|
1164
|
+
* Announce that we've ended all local media. Receivers tear down both
|
|
1165
|
+
* our camera and screen-share surfaces. Called by `Call.leave` and by
|
|
1166
|
+
* `Call.setLocalStream(null)`.
|
|
1167
|
+
*/
|
|
1168
|
+
broadcastMediaStopped(): void;
|
|
1169
|
+
/**
|
|
1170
|
+
* Initiate an offer toward the given remote client. Used by the
|
|
1171
|
+
* caller side of the mesh-mode handshake (typically when a `peer.joined`
|
|
1172
|
+
* signal arrives and the local end is the more-recently-connected peer).
|
|
1173
|
+
*/
|
|
1174
|
+
callPeer(remoteClientId: string): Promise<void>;
|
|
1175
|
+
/** Tear down the connection to a single peer. */
|
|
1176
|
+
hangupPeer(remoteClientId: string): void;
|
|
1177
|
+
private handleSdp;
|
|
1178
|
+
private handleIce;
|
|
1179
|
+
/**
|
|
1180
|
+
* Handle every inbound `type: 'signal'` frame. The server emits some
|
|
1181
|
+
* signals (`from: 'server'`) and peers emit others (`from: <clientId>`).
|
|
1182
|
+
* We discriminate by `msg.from` because the backend's relayed
|
|
1183
|
+
* envelope reuses the same `type` field.
|
|
1184
|
+
*/
|
|
1185
|
+
private handleSignal;
|
|
1186
|
+
private handleServerSignal;
|
|
1187
|
+
/**
|
|
1188
|
+
* Peer-originated signals (relayed by the server, but content is
|
|
1189
|
+
* untrusted). Currently covers media on/off state, mediaStopped, and
|
|
1190
|
+
* screen-share streamId routing. Chat signals are intentionally NOT
|
|
1191
|
+
* handled here — the `Call` event surface forwards them via the
|
|
1192
|
+
* generic `raw` event so the WebRTC layer stays focused on RTC.
|
|
1193
|
+
*/
|
|
1194
|
+
private handlePeerSignal;
|
|
1195
|
+
private getOrCreatePeer;
|
|
1196
|
+
/**
|
|
1197
|
+
* Send a directed signaling envelope onto the WebSocket. Wraps
|
|
1198
|
+
* `WSMessage` so call sites can stay short and the wire format is
|
|
1199
|
+
* enforced in one place.
|
|
1200
|
+
*/
|
|
1201
|
+
private sendSignaling;
|
|
1202
|
+
/**
|
|
1203
|
+
* Broadcast a peer-signal payload to every current peer. The relayed
|
|
1204
|
+
* envelope uses `type: 'signal'` with the local clientId as `from`.
|
|
1205
|
+
*/
|
|
1206
|
+
private broadcastSignal;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Categorised view of `MediaDeviceInfo[]` for easier consumption.
|
|
1211
|
+
*/
|
|
1212
|
+
interface DeviceInventory {
|
|
1213
|
+
cameras: MediaDeviceInfo[];
|
|
1214
|
+
microphones: MediaDeviceInfo[];
|
|
1215
|
+
speakers: MediaDeviceInfo[];
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Thin, defensive wrapper around `navigator.mediaDevices`. Centralised
|
|
1219
|
+
* so the SDK doesn't sprinkle feature-detection checks at every call
|
|
1220
|
+
* site and so unit tests have one mocking surface to stub.
|
|
1221
|
+
*
|
|
1222
|
+
* The manager intentionally does NOT cache the local `MediaStream` —
|
|
1223
|
+
* that's `WebRTCManager`'s job (it has to push tracks into existing
|
|
1224
|
+
* peer connections when a new stream arrives).
|
|
1225
|
+
*/
|
|
1226
|
+
declare class DeviceManager {
|
|
1227
|
+
/**
|
|
1228
|
+
* Acquire a stream from the user's mic / camera. Accepts either a
|
|
1229
|
+
* boolean shorthand (legacy SDK call sites) or full
|
|
1230
|
+
* `MediaTrackConstraints` for fine-grained device selection.
|
|
1231
|
+
*/
|
|
1232
|
+
getLocalStream(constraints?: MediaConstraints): Promise<MediaStream>;
|
|
1233
|
+
/** Convenience overload kept for backwards-compatibility with v0 docs. */
|
|
1234
|
+
getUserMedia(audio?: boolean, video?: boolean): Promise<MediaStream>;
|
|
1235
|
+
/**
|
|
1236
|
+
* Prompt the user for a screen-share / window-share stream.
|
|
1237
|
+
*/
|
|
1238
|
+
getScreenShare(opts?: DisplayMediaStreamOptions): Promise<MediaStream>;
|
|
1239
|
+
/** Raw enumeration of every connected media device. */
|
|
1240
|
+
listDevices(): Promise<MediaDeviceInfo[]>;
|
|
1241
|
+
/** Categorised view: cameras, microphones, speakers. */
|
|
1242
|
+
listInventory(): Promise<DeviceInventory>;
|
|
1243
|
+
/**
|
|
1244
|
+
* Subscribe to device-list changes (e.g. user plugged in a USB mic).
|
|
1245
|
+
* Returns an unsubscribe function.
|
|
1246
|
+
*/
|
|
1247
|
+
onDeviceChange(cb: () => void): () => void;
|
|
1248
|
+
/**
|
|
1249
|
+
* Stop every track on a stream — important to call when the user
|
|
1250
|
+
* unmounts the local preview, otherwise the camera light stays on.
|
|
1251
|
+
*/
|
|
1252
|
+
stopStream(stream: MediaStream | null | undefined): void;
|
|
1253
|
+
private assertSupported;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* `FriendsManager` is the SDK's stateful wrapper around the global-WS
|
|
1258
|
+
* friend protocol. It owns:
|
|
1259
|
+
*
|
|
1260
|
+
* - A cache of the local user's friends and pending incoming requests.
|
|
1261
|
+
* - The periodic refresh that keeps online-status indicators alive.
|
|
1262
|
+
* - The translation between wire-shaped frames (with backend's actual
|
|
1263
|
+
* field names) and the SDK's typed event surface.
|
|
1264
|
+
*
|
|
1265
|
+
* The manager is **lazy** — `SignallingSDK` constructs it on first
|
|
1266
|
+
* access of `sdk.friends`. Construction opens the global WS, runs the
|
|
1267
|
+
* normal DPoP-protected handshake, and begins listening for inbound
|
|
1268
|
+
* frames.
|
|
1269
|
+
*
|
|
1270
|
+
* Wire shapes (matched against `backend/global/handler.go`):
|
|
1271
|
+
*
|
|
1272
|
+
* - server → us
|
|
1273
|
+
* `friend_list` `{friends: [{friendshipId, username, isOnline}]}`
|
|
1274
|
+
* `friend_pending` `{requests: [{friendshipId, requester, createdAt}]}`
|
|
1275
|
+
* `friend_request_ack` `{friendshipId, addressee, status}`
|
|
1276
|
+
* `friend_accept_ack` `{friendshipId, friend, status}`
|
|
1277
|
+
* `friend_reject_ack` `{friendshipId, status}`
|
|
1278
|
+
* `friend_remove_ack` `{friendshipId, status}`
|
|
1279
|
+
* `error` `{originalType, error}`
|
|
1280
|
+
*
|
|
1281
|
+
* - peer push (forwarded by server)
|
|
1282
|
+
* `friend_request` `{friendshipId, requester}` from = requester username
|
|
1283
|
+
* `friend_accepted` `{friendshipId, acceptedBy}` from = acceptor username
|
|
1284
|
+
*
|
|
1285
|
+
* - us → server
|
|
1286
|
+
* `friend_list` no payload
|
|
1287
|
+
* `friend_pending` no payload
|
|
1288
|
+
* `friend_request` to = addressee username
|
|
1289
|
+
* `friend_accept` `{friendshipId}`
|
|
1290
|
+
* `friend_reject` `{friendshipId}`
|
|
1291
|
+
* `friend_remove` `{friendshipId}`
|
|
1292
|
+
*/
|
|
1293
|
+
declare class FriendsManager extends TypedEventEmitter<FriendsEventMap> {
|
|
1294
|
+
private readonly ws;
|
|
1295
|
+
private readonly logger;
|
|
1296
|
+
private friends;
|
|
1297
|
+
private pending;
|
|
1298
|
+
private wsUnsubs;
|
|
1299
|
+
private refreshTimer;
|
|
1300
|
+
private firstQueryTimer;
|
|
1301
|
+
private readonly refreshIntervalMs;
|
|
1302
|
+
constructor(ws: WebSocketClient, logger: Logger, options?: {
|
|
1303
|
+
refreshIntervalMs?: number;
|
|
1304
|
+
});
|
|
1305
|
+
/**
|
|
1306
|
+
* Bind to the global WS — subscribes to inbound frames and kicks off
|
|
1307
|
+
* the first `friend_list` / `friend_pending` query (after a small
|
|
1308
|
+
* delay so the WS handshake has time to settle). Idempotent.
|
|
1309
|
+
*/
|
|
1310
|
+
bind(): void;
|
|
1311
|
+
/**
|
|
1312
|
+
* Drop subscriptions and cancel the refresh timer. Call before
|
|
1313
|
+
* disposing the SDK so timers don't keep the process alive.
|
|
1314
|
+
*/
|
|
1315
|
+
unbind(): void;
|
|
1316
|
+
/** Current friends cache. Mutating the returned array is a no-op (it's a copy). */
|
|
1317
|
+
list(): Friend[];
|
|
1318
|
+
/** Current pending-requests cache. */
|
|
1319
|
+
pendingRequests(): PendingFriendRequest[];
|
|
1320
|
+
/** Force a refresh of both `friends` and `pending` caches. */
|
|
1321
|
+
refresh(): void;
|
|
1322
|
+
/** Send a friend request to a peer by their **username**. */
|
|
1323
|
+
sendRequest(username: string): void;
|
|
1324
|
+
/** Accept a pending friend request by friendship id. */
|
|
1325
|
+
accept(friendshipId: string): void;
|
|
1326
|
+
/** Reject a pending friend request by friendship id. */
|
|
1327
|
+
reject(friendshipId: string): void;
|
|
1328
|
+
/** Remove an existing friendship by id. */
|
|
1329
|
+
remove(friendshipId: string): void;
|
|
1330
|
+
private schedulePostConnectQueries;
|
|
1331
|
+
private startRefreshLoop;
|
|
1332
|
+
private cancelRefreshOnly;
|
|
1333
|
+
private cancelTimers;
|
|
1334
|
+
private clearLocalState;
|
|
1335
|
+
private send;
|
|
1336
|
+
private handleMessage;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* `CallInviteManager` handles `call_invite` frames on the global WS.
|
|
1341
|
+
* Used by `sdk.calls.invite()` (outbound) and the
|
|
1342
|
+
* `'call.invite-received'` event on `sdk.calls` (inbound). Lives on
|
|
1343
|
+
* the global socket — separate from `Call`, which lives on the room
|
|
1344
|
+
* socket. The two are intentionally not coupled: you can receive an
|
|
1345
|
+
* invite while not in any call, and you can ignore the invite without
|
|
1346
|
+
* ever entering a call.
|
|
1347
|
+
*
|
|
1348
|
+
* Wire shape (per `backend/global/handler.go::handleCallInvite`):
|
|
1349
|
+
*
|
|
1350
|
+
* out: `{ type: 'call_invite', to: '<recipient-username>', payload: { roomId } }`
|
|
1351
|
+
* in: `{ type: 'call_invite', from: '<caller-username>', payload: { roomId } }`
|
|
1352
|
+
*
|
|
1353
|
+
* The backend resolves `to` (username) to a hub key server-side; the
|
|
1354
|
+
* sender never sees the recipient's internal id.
|
|
1355
|
+
*/
|
|
1356
|
+
declare class CallInviteManager extends TypedEventEmitter<CallInviteEventMap> {
|
|
1357
|
+
private readonly ws;
|
|
1358
|
+
private readonly logger;
|
|
1359
|
+
private wsUnsubs;
|
|
1360
|
+
constructor(ws: WebSocketClient, logger: Logger);
|
|
1361
|
+
/** Subscribe to the global WS. Idempotent. */
|
|
1362
|
+
bind(): void;
|
|
1363
|
+
/** Drop subscriptions. */
|
|
1364
|
+
unbind(): void;
|
|
1365
|
+
/**
|
|
1366
|
+
* Invite a friend to join the given room. The recipient is addressed
|
|
1367
|
+
* by username; the SDK forwards the request over the global WS and
|
|
1368
|
+
* the backend pushes a `call_invite` frame to the recipient.
|
|
1369
|
+
*
|
|
1370
|
+
* No delivery acknowledgement today — fire-and-forget. The backend
|
|
1371
|
+
* logs a warning if the recipient is offline; future revisions may
|
|
1372
|
+
* emit a `'call.invite-undelivered'` event so consumers can show
|
|
1373
|
+
* "user is offline" UI.
|
|
1374
|
+
*/
|
|
1375
|
+
invite(toUsername: string, roomId: number | string): void;
|
|
1376
|
+
private handleMessage;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* `Call` is the high-level handle returned by `sdk.calls.joinRoom`.
|
|
1381
|
+
* It bundles the active membership, the typed event surface, and
|
|
1382
|
+
* convenience methods so integrators don't juggle managers.
|
|
1383
|
+
*
|
|
1384
|
+
* Local mic / camera enable state is owned by the Call (not the SDK or
|
|
1385
|
+
* the React layer) because the wire protocol couples toggle UX to a
|
|
1386
|
+
* peer broadcast (`mediaState`). Calling `setMicEnabled` / `setCameraEnabled`
|
|
1387
|
+
* therefore both flips the local track AND fires the peer signal —
|
|
1388
|
+
* keeping the two in lockstep removes the most common source of stale
|
|
1389
|
+
* remote mute-indicator bugs.
|
|
1390
|
+
*/
|
|
1391
|
+
declare class Call extends TypedEventEmitter<CallEventMap> {
|
|
1392
|
+
readonly roomId: number | string;
|
|
1393
|
+
readonly clientId: string;
|
|
1394
|
+
private readonly rooms;
|
|
1395
|
+
private readonly webrtc;
|
|
1396
|
+
private readonly ws;
|
|
1397
|
+
private readonly devices;
|
|
1398
|
+
private readonly auth;
|
|
1399
|
+
/** The local camera stream sent to peers — `null` if joined listen-only. */
|
|
1400
|
+
localStream: MediaStream | null;
|
|
1401
|
+
/** Tracks the latest video/audio enable state so the toggles can broadcast it. */
|
|
1402
|
+
private localMediaState;
|
|
1403
|
+
/** @internal */
|
|
1404
|
+
constructor(roomId: number | string, clientId: string, rooms: RoomManager, webrtc: WebRTCManager, ws: WebSocketClient, devices: DeviceManager, auth: AuthClient);
|
|
1405
|
+
/**
|
|
1406
|
+
* Snapshot of remote peers' announced mic/cam state, keyed by clientId.
|
|
1407
|
+
* Live view backed by `WebRTCManager.getPeerMediaStates()`.
|
|
1408
|
+
*/
|
|
1409
|
+
get peers(): ReadonlyMap<string, PeerMediaState>;
|
|
1410
|
+
/** Local mic enable state. Reflects the last call to `setMicEnabled`. */
|
|
1411
|
+
get isAudioEnabled(): boolean;
|
|
1412
|
+
/** Local camera enable state. */
|
|
1413
|
+
get isVideoEnabled(): boolean;
|
|
1414
|
+
/** Local screen-share stream, or `null` when not sharing. */
|
|
1415
|
+
get localScreenStream(): MediaStream | null;
|
|
1416
|
+
/** True iff this client is currently broadcasting a screen-share stream. */
|
|
1417
|
+
get isScreenSharing(): boolean;
|
|
1418
|
+
/**
|
|
1419
|
+
* Toggle local audio. Disables the outgoing audio track AND fires a
|
|
1420
|
+
* `mediaState` signal so peers can update their mute indicators.
|
|
1421
|
+
* The remote audio track keeps flowing on the wire — that's expected
|
|
1422
|
+
* (WebRTC pauses the track at the source, not the line).
|
|
1423
|
+
*/
|
|
1424
|
+
setMicEnabled(enabled: boolean): void;
|
|
1425
|
+
/** Toggle local video. Broadcasts a `mediaState` signal — see `setMicEnabled`. */
|
|
1426
|
+
setCameraEnabled(enabled: boolean): void;
|
|
1427
|
+
/**
|
|
1428
|
+
* Replace the local camera stream (e.g. after a device switch). Does NOT
|
|
1429
|
+
* touch the screen-share stream — call `stopScreenShare` separately if
|
|
1430
|
+
* needed. Passing `null` stops local camera media and broadcasts a
|
|
1431
|
+
* `mediaStopped` signal.
|
|
1432
|
+
*/
|
|
1433
|
+
setLocalStream(stream: MediaStream | null): Promise<void>;
|
|
1434
|
+
/**
|
|
1435
|
+
* Begin sharing the screen ALONGSIDE the existing camera tracks.
|
|
1436
|
+
* Picks a screen via `DeviceManager.getScreenShare`, announces the
|
|
1437
|
+
* stream id to every peer (`screenShareStream` signal), adds the
|
|
1438
|
+
* tracks, and renegotiates each peer connection — all owned by
|
|
1439
|
+
* `WebRTCManager.addScreenStream`. Also POSTs the screen-share-start
|
|
1440
|
+
* endpoint so the backend can track the active sharer per room.
|
|
1441
|
+
*
|
|
1442
|
+
* If a different client is already sharing, the backend will boot
|
|
1443
|
+
* them via a `stopScreenShare` server signal — we surface that to
|
|
1444
|
+
* the previous sharer via `'screen-share.stop'`.
|
|
1445
|
+
*
|
|
1446
|
+
* Browser `onended` (user clicks the system "Stop sharing" pill) is
|
|
1447
|
+
* wired up so we cleanly call `stopScreenShare` on that path too.
|
|
1448
|
+
*/
|
|
1449
|
+
startScreenShare(): Promise<MediaStream>;
|
|
1450
|
+
/** Stop the active screen share — both remotely and locally. */
|
|
1451
|
+
stopScreenShare(): Promise<void>;
|
|
1452
|
+
/**
|
|
1453
|
+
* Broadcast a chat message to every peer in the room. Fills in
|
|
1454
|
+
* `fromName` from the cached `UserProfile` if available (falls back
|
|
1455
|
+
* to the local `clientId`). Returns the constructed `ChatMessage` so
|
|
1456
|
+
* the caller can append it to their own UI without waiting for any
|
|
1457
|
+
* echo (the SDK does NOT echo chat to the sender).
|
|
1458
|
+
*/
|
|
1459
|
+
sendChat(text: string): ChatMessage;
|
|
1460
|
+
private deriveLocalDisplayName;
|
|
1461
|
+
/** Disconnect from the room — closes WS, tears down peers, hits API. */
|
|
1462
|
+
leave(): Promise<void>;
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* `SignallingSDK` — the single public entry point.
|
|
1466
|
+
*
|
|
1467
|
+
* ```ts
|
|
1468
|
+
* const sdk = await SignallingSDK.create({
|
|
1469
|
+
* baseUrl: "https://api.example.com",
|
|
1470
|
+
* authMode: "bff",
|
|
1471
|
+
* });
|
|
1472
|
+
*
|
|
1473
|
+
* await sdk.auth.login();
|
|
1474
|
+
* // ...after the BFF callback completes...
|
|
1475
|
+
* const token = sdk.auth.bindTokenFromFragment();
|
|
1476
|
+
* if (token) await sdk.auth.completeBind(token);
|
|
1477
|
+
*
|
|
1478
|
+
* const room = await sdk.rooms.create();
|
|
1479
|
+
* const call = await sdk.calls.joinRoom({
|
|
1480
|
+
* roomId: room.roomId,
|
|
1481
|
+
* media: { audio: true, video: true },
|
|
1482
|
+
* });
|
|
1483
|
+
*
|
|
1484
|
+
* call.on('peer.joined', (peer) => console.log('joined:', peer));
|
|
1485
|
+
* call.on('stream.added', ({ clientId, stream }) => attach(clientId, stream));
|
|
1486
|
+
* ```
|
|
1487
|
+
*
|
|
1488
|
+
* **Two WebSocket connections.** The SDK keeps the room WS (joined via
|
|
1489
|
+
* `calls.joinRoom`) and the global WS (used by `sdk.friends` /
|
|
1490
|
+
* `sdk.calls.invite`) on separate `WebSocketClient` instances —
|
|
1491
|
+
* concurrent, independently authenticated, independently reconnecting.
|
|
1492
|
+
* The global WS is lazily opened on first use.
|
|
1493
|
+
*/
|
|
1494
|
+
declare class SignallingSDK {
|
|
1495
|
+
readonly auth: AuthClient;
|
|
1496
|
+
readonly rooms: RoomManager;
|
|
1497
|
+
readonly devices: DeviceManager;
|
|
1498
|
+
readonly api: ApiClient;
|
|
1499
|
+
readonly dpop: DPoPManager;
|
|
1500
|
+
/** Room WebSocket — the `calls.joinRoom` machinery binds to this one. */
|
|
1501
|
+
readonly ws: WebSocketClient;
|
|
1502
|
+
readonly webrtc: WebRTCManager;
|
|
1503
|
+
readonly logger: Logger;
|
|
1504
|
+
private readonly config;
|
|
1505
|
+
/** Tracks the active call so a second `joinRoom` can clean up the previous one. */
|
|
1506
|
+
private activeCall;
|
|
1507
|
+
/** Bridge subscriptions between (WS, WebRTC) → active Call. Cleared on leave. */
|
|
1508
|
+
private callBridges;
|
|
1509
|
+
private globalWs;
|
|
1510
|
+
private friendsManager;
|
|
1511
|
+
private callInviteManager;
|
|
1512
|
+
private constructor();
|
|
1513
|
+
/**
|
|
1514
|
+
* Create and initialise a new SDK instance. Eagerly loads the DPoP
|
|
1515
|
+
* keypair from IndexedDB so the first authed request doesn't pay the
|
|
1516
|
+
* keygen cost.
|
|
1517
|
+
*/
|
|
1518
|
+
static create(config: SignallingSDKConfig): Promise<SignallingSDK>;
|
|
1519
|
+
/**
|
|
1520
|
+
* Friends + presence surface. First access opens the global WS,
|
|
1521
|
+
* runs the DPoP handshake, and starts the periodic friends-list
|
|
1522
|
+
* refresh. Subsequent accesses return the same instance.
|
|
1523
|
+
*/
|
|
1524
|
+
get friends(): FriendsManager;
|
|
1525
|
+
/**
|
|
1526
|
+
* High-level call orchestration:
|
|
1527
|
+
* - `joinRoom(config)` — combines join-over-REST, TURN fetch, local
|
|
1528
|
+
* media acquisition, WS handshake, and WebRTC wiring into a
|
|
1529
|
+
* single `Call` handle.
|
|
1530
|
+
* - `invite(toUsername, roomId)` — sends a `call_invite` frame on the
|
|
1531
|
+
* global WS so a friend gets a real-time invitation push.
|
|
1532
|
+
* - `on('call.invite-received', cb)` — subscribes to inbound
|
|
1533
|
+
* invitations from friends. Opens the global WS on first
|
|
1534
|
+
* subscribe so consumers don't need to manually start it.
|
|
1535
|
+
*/
|
|
1536
|
+
get calls(): {
|
|
1537
|
+
joinRoom: (config: CallConfig) => Promise<Call>;
|
|
1538
|
+
invite: (toUsername: string, roomId: number | string) => void;
|
|
1539
|
+
on: <K extends "call.invite-received">(event: K, listener: (payload: IncomingCallInvite) => void) => (() => void);
|
|
1540
|
+
};
|
|
1541
|
+
private ensureCallInviteManager;
|
|
1542
|
+
/**
|
|
1543
|
+
* Lazily construct and connect the global WS. The global socket lives
|
|
1544
|
+
* for the SDK instance lifetime; it carries friend protocol frames
|
|
1545
|
+
* and call invites, independently of any active room.
|
|
1546
|
+
*/
|
|
1547
|
+
private ensureGlobalConnected;
|
|
1548
|
+
/**
|
|
1549
|
+
* Implementation of `calls.joinRoom`. Kept as a method on the SDK so
|
|
1550
|
+
* arrow-functions on the `calls` getter don't trap a stale `this`.
|
|
1551
|
+
*/
|
|
1552
|
+
private joinRoom;
|
|
1553
|
+
/**
|
|
1554
|
+
* Wire WS / WebRTC event streams into the `Call` event map. Each
|
|
1555
|
+
* subscription returns its own unsubscribe; we collect them so a
|
|
1556
|
+
* subsequent `leave()` / `joinRoom()` can detach cleanly.
|
|
1557
|
+
*/
|
|
1558
|
+
private bindCallEvents;
|
|
1559
|
+
private dispatchServerSignal;
|
|
1560
|
+
private dispatchPeerSignal;
|
|
1561
|
+
/**
|
|
1562
|
+
* Placeholder hook for the SFU rollout. Returns `false` today; will
|
|
1563
|
+
* become a config-driven switch once the SFU client is exposed.
|
|
1564
|
+
*/
|
|
1565
|
+
private shouldUseSfu;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
export { type ActiveMembership, ApiClient, AuthClient, type AuthEventMap, type AuthMode, Call, type CallConfig, type CallEventMap, type CallInviteEventMap, CallInviteManager, type ChatMessage, type ClientPermissions, type CreateRoomResponse, DPoPManager, type DeviceInventory, DeviceManager, type Friend, type FriendsEventMap, FriendsManager, type IceCandidatePayload, type IncomingCallInvite, type JoinRoomResponse, type LocalIdentity, type LogLevel, type Logger, type MediaConstraints, type PeerMediaState, type PeerSignalPayload, type PendingFriendRequest, type Room, type RoomClient, RoomManager, type SdpPayload, type ServerSignalPayload, SignallingApiError, SignallingSDK, type SignallingSDKConfig, SignallingWebSocketError, type TurnCredential, type UpdateProfileRequest, type UserProfile, type UserStatus, WSCloseCode, type WSMessage, type WSTicket, type WebRTCEventMap, WebRTCManager, WebSocketClient, type WebSocketClientOptions, type WebSocketEventMap, type WebSocketTarget, isFatalCloseCode };
|