@voltkit-rtc/client 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Publishing a broadcast stream.
3
+ *
4
+ * A broadcast is one publisher and an audience of any size, delivered over
5
+ * LL-HLS and WebRTC. Publishing is WHIP: one HTTP exchange of SDP, then media
6
+ * straight to the media server. Viewers need nothing from this package -- they
7
+ * play the stream's LL-HLS URL in any HLS player.
8
+ */
9
+
10
+ import { AudioMixer, type MusicInput, type MusicOptions } from './music.js';
11
+
12
+ /** The media server refused, or could not be reached. */
13
+ export class WhipError extends Error {
14
+ /**
15
+ * @param status HTTP status, or 0 when the request never got an answer.
16
+ */
17
+ constructor(readonly status: number, message: string) {
18
+ super(message);
19
+ this.name = 'WhipError';
20
+ }
21
+
22
+ /**
23
+ * The publish URL was not accepted: its token is missing, expired, for a
24
+ * different stream, or the account behind it cannot go on air. The same URL
25
+ * will be refused again -- get a new one from your backend.
26
+ */
27
+ get isRefused(): boolean {
28
+ return this.status === 401 || this.status === 403;
29
+ }
30
+ }
31
+
32
+ export type BroadcastState =
33
+ | 'idle'
34
+ | 'connecting'
35
+ /** Media is flowing to the media server. */
36
+ | 'live'
37
+ /** The connection dropped. WebRTC may recover it; if it reaches `failed`, start again. */
38
+ | 'interrupted'
39
+ | 'failed'
40
+ | 'stopped';
41
+
42
+ export interface BroadcastEvents {
43
+ state: (state: BroadcastState) => void;
44
+ /**
45
+ * Publishing worked, but something that affects cost or quality did not
46
+ * apply -- most importantly, H.264 was not available in this browser.
47
+ */
48
+ warning: (message: string) => void;
49
+ }
50
+
51
+ export interface BroadcastStartOptions {
52
+ /** Media you already hold. It stays yours: `stop()` will not end its tracks. */
53
+ stream?: MediaStream;
54
+ /** Used to acquire camera and microphone when `stream` is not given. */
55
+ constraints?: MediaStreamConstraints;
56
+ /** Music playing inside the page, to go on air from the first second. See `setMusic`. */
57
+ music?: MusicInput;
58
+ musicOptions?: MusicOptions;
59
+ }
60
+
61
+ type Handler = (...args: never[]) => void;
62
+
63
+ const WHIP_TIMEOUT_MS = 15_000;
64
+ /** A browser that never reports "complete" still publishes with what it has. */
65
+ const ICE_GATHER_TIMEOUT_MS = 3_000;
66
+ /** Helper formats that ride along with a codec. Not codecs in their own right. */
67
+ const HELPER_FORMATS = new Set(['video/rtx', 'video/red', 'video/ulpfec', 'video/flexfec-03']);
68
+
69
+ /**
70
+ * Send an SDP offer to a WHIP endpoint and return the answer, plus the session
71
+ * resource the server names for ending it.
72
+ */
73
+ export async function whipOffer(
74
+ whipUrl: string,
75
+ offerSdp: string,
76
+ timeoutMs = WHIP_TIMEOUT_MS,
77
+ ): Promise<{ answerSdp: string; resource: string | null }> {
78
+ let res: Response;
79
+ try {
80
+ res = await fetch(whipUrl, {
81
+ method: 'POST',
82
+ headers: { 'Content-Type': 'application/sdp', Accept: 'application/sdp' },
83
+ body: offerSdp,
84
+ signal: AbortSignal.timeout(timeoutMs),
85
+ });
86
+ } catch (err) {
87
+ // Never echo the URL: it carries the publish token.
88
+ const timedOut = (err as Error)?.name === 'TimeoutError';
89
+ throw new WhipError(0, timedOut
90
+ ? 'the media server did not answer in time'
91
+ : 'could not reach the media server');
92
+ }
93
+ const body = await res.text();
94
+ if (res.status !== 201 && res.status !== 200) {
95
+ const line = body.split('\n')[0]?.trim().slice(0, 200);
96
+ throw new WhipError(res.status, line || 'the media server refused the stream');
97
+ }
98
+ const location = res.headers.get('Location');
99
+ // Location may be relative; it is relative to the URL that was posted to.
100
+ return { answerSdp: body, resource: location ? new URL(location, whipUrl).toString() : null };
101
+ }
102
+
103
+ /**
104
+ * Tell the server the session is over. Best effort: the media server also
105
+ * notices the connection going away, so a failure here is not an error.
106
+ */
107
+ export async function whipEnd(resource: string): Promise<void> {
108
+ try {
109
+ // keepalive lets it finish when called while the page is being closed.
110
+ await fetch(resource, { method: 'DELETE', keepalive: true, signal: AbortSignal.timeout(5_000) });
111
+ } catch {
112
+ /* the session ends when the connection does */
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Publishes camera and microphone to one broadcast stream.
118
+ *
119
+ * ```ts
120
+ * const publisher = new BroadcastPublisher(stream.publish.whipUrl);
121
+ * publisher.on('state', (s) => setState(s));
122
+ * const media = await publisher.start();
123
+ * video.srcObject = media;
124
+ * // ...
125
+ * await publisher.stop();
126
+ * ```
127
+ *
128
+ * The `whipUrl` comes from your backend (`POST /v1/streams`) and already
129
+ * carries the publish token. Treat it like a password.
130
+ */
131
+ export class BroadcastPublisher {
132
+ private pc: RTCPeerConnection | null = null;
133
+ private media: MediaStream | null = null;
134
+ private ownsMedia = false;
135
+ private resource: string | null = null;
136
+ private audioSender: RTCRtpSender | null = null;
137
+ private micTrack: MediaStreamTrack | null = null;
138
+ /** Present only while music is in use: the broadcast carries one audio track, so the two are mixed. */
139
+ private mixer: AudioMixer | null = null;
140
+ private micOn = true;
141
+ private current: BroadcastState = 'idle';
142
+ private readonly listeners = new Map<string, Set<Handler>>();
143
+
144
+ constructor(private readonly whipUrl: string) {}
145
+
146
+ get state(): BroadcastState {
147
+ return this.current;
148
+ }
149
+
150
+ /** What is being published, for a local preview. */
151
+ get localStream(): MediaStream | null {
152
+ return this.media;
153
+ }
154
+
155
+ on<K extends keyof BroadcastEvents>(event: K, fn: BroadcastEvents[K]): () => void {
156
+ let set = this.listeners.get(event);
157
+ if (!set) {
158
+ set = new Set();
159
+ this.listeners.set(event, set);
160
+ }
161
+ set.add(fn as Handler);
162
+ return () => set!.delete(fn as Handler);
163
+ }
164
+
165
+ private emit<K extends keyof BroadcastEvents>(event: K, ...args: Parameters<BroadcastEvents[K]>): void {
166
+ for (const fn of this.listeners.get(event) ?? []) {
167
+ try {
168
+ (fn as (...a: unknown[]) => void)(...args);
169
+ } catch (err) {
170
+ console.error(`listener for ${event} threw`, err);
171
+ }
172
+ }
173
+ }
174
+
175
+ private set(state: BroadcastState): void {
176
+ if (this.current === state) return;
177
+ this.current = state;
178
+ this.emit('state', state);
179
+ }
180
+
181
+ /** Go on air. Resolves once the media server has accepted the stream. */
182
+ async start(options: BroadcastStartOptions = {}): Promise<MediaStream> {
183
+ if (this.pc) throw new Error('already publishing; call stop() first');
184
+ this.set('connecting');
185
+ try {
186
+ this.ownsMedia = !options.stream;
187
+ const media = options.stream ?? await navigator.mediaDevices.getUserMedia(
188
+ options.constraints ?? { audio: true, video: { width: 1280, height: 720 } },
189
+ );
190
+ this.media = media;
191
+
192
+ const pc = new RTCPeerConnection();
193
+ this.pc = pc;
194
+ pc.addEventListener('connectionstatechange', () => {
195
+ if (this.pc !== pc) return;
196
+ if (pc.connectionState === 'connected') this.set('live');
197
+ else if (pc.connectionState === 'disconnected') this.set('interrupted');
198
+ else if (pc.connectionState === 'failed') this.set('failed');
199
+ });
200
+
201
+ this.micTrack = media.getAudioTracks()[0] ?? null;
202
+ if (options.music) {
203
+ this.mixer = new AudioMixer(this.micTrack);
204
+ this.mixer.setMusic(options.music, options.musicOptions);
205
+ }
206
+ for (const track of media.getVideoTracks()) {
207
+ this.preferH264(pc.addTransceiver(track, { direction: 'sendonly', streams: [media] }));
208
+ }
209
+ // Always an audio line, even with no microphone, so music can be added
210
+ // later without renegotiating with the media server.
211
+ const audio = this.mixer?.track ?? this.micTrack;
212
+ const transceiver = audio
213
+ ? pc.addTransceiver(audio, { direction: 'sendonly', streams: [media] })
214
+ : pc.addTransceiver('audio', { direction: 'sendonly' });
215
+ this.audioSender = transceiver.sender;
216
+
217
+ await pc.setLocalDescription(await pc.createOffer());
218
+ await iceGathered(pc);
219
+ const offerSdp = pc.localDescription?.sdp;
220
+ if (!offerSdp) throw new WhipError(0, 'could not create an offer in this browser');
221
+
222
+ const answer = await whipOffer(this.whipUrl, offerSdp);
223
+ this.resource = answer.resource;
224
+ await pc.setRemoteDescription({ type: 'answer', sdp: answer.answerSdp });
225
+ return media;
226
+ } catch (err) {
227
+ this.teardown();
228
+ this.set('failed');
229
+ throw err;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Put H.264 first, and offer nothing else for video.
235
+ *
236
+ * The media server repackages H.264 into HLS untouched. Any other codec
237
+ * makes it decode and re-encode every frame, which is the entire cost of a
238
+ * stream -- so a publisher that negotiates VP8 works, and quietly costs many
239
+ * times more.
240
+ */
241
+ private preferH264(transceiver: RTCRtpTransceiver): void {
242
+ try {
243
+ const all = RTCRtpSender.getCapabilities('video')?.codecs ?? [];
244
+ const h264 = all.filter((c) => c.mimeType.toLowerCase() === 'video/h264');
245
+ if (h264.length === 0) {
246
+ this.emit('warning', 'H.264 is not available in this browser; the stream will be transcoded');
247
+ return;
248
+ }
249
+ const helpers = all.filter((c) => HELPER_FORMATS.has(c.mimeType.toLowerCase()));
250
+ transceiver.setCodecPreferences([...h264, ...helpers]);
251
+ } catch (err) {
252
+ this.emit('warning', `could not prefer H.264 (${(err as Error).message}); the stream may be transcoded`);
253
+ }
254
+ }
255
+
256
+ /** Mute or unmute the microphone without leaving the air. Music keeps playing. */
257
+ setMicEnabled(enabled: boolean): void {
258
+ this.micOn = enabled;
259
+ if (this.mixer) this.mixer.setMicEnabled(enabled);
260
+ else if (this.micTrack) this.micTrack.enabled = enabled;
261
+ }
262
+
263
+ /**
264
+ * Put music from inside the page on air, mixed with the microphone.
265
+ *
266
+ * A broadcast carries a single audio track, so the two are mixed here, in
267
+ * the browser, before they are sent. The music never passes through the
268
+ * microphone, so the audience hears it clean, and each has its own switch:
269
+ * `setMusicEnabled` and `setMicEnabled`.
270
+ */
271
+ async setMusic(input: MusicInput, options?: MusicOptions): Promise<void> {
272
+ if (!this.pc || !this.audioSender) throw new Error('start() the broadcast before adding music');
273
+ if (!this.mixer) {
274
+ this.mixer = new AudioMixer(this.micTrack);
275
+ // The raw track was being sent until now; its own switch no longer applies.
276
+ if (this.micTrack) this.micTrack.enabled = true;
277
+ this.mixer.setMicEnabled(this.micOn);
278
+ await this.audioSender.replaceTrack(this.mixer.track);
279
+ }
280
+ this.mixer.setMusic(input, options);
281
+ }
282
+
283
+ /** Switch the music off or on for the audience. The microphone is untouched. */
284
+ setMusicEnabled(enabled: boolean): void {
285
+ this.mixer?.setMusicEnabled(enabled);
286
+ }
287
+
288
+ /** 0 to 1, for the audience. */
289
+ setMusicVolume(volume: number): void {
290
+ this.mixer?.setMusicVolume(volume);
291
+ }
292
+
293
+ /** Take the music off air. The microphone carries on. */
294
+ clearMusic(): void {
295
+ this.mixer?.clearMusic();
296
+ }
297
+
298
+ get musicEnabled(): boolean {
299
+ return this.mixer?.musicEnabled ?? false;
300
+ }
301
+
302
+ get micEnabled(): boolean {
303
+ return this.micOn && this.micTrack !== null;
304
+ }
305
+
306
+ /** Turn the camera on or off without leaving the air. */
307
+ setCamEnabled(enabled: boolean): void {
308
+ for (const t of this.media?.getVideoTracks() ?? []) t.enabled = enabled;
309
+ }
310
+
311
+ /** Go off air and release the camera and microphone. Idempotent. */
312
+ async stop(): Promise<void> {
313
+ const resource = this.resource;
314
+ this.teardown();
315
+ if (resource) await whipEnd(resource);
316
+ this.set('stopped');
317
+ }
318
+
319
+ private teardown(): void {
320
+ const { pc, media } = this;
321
+ this.pc = null;
322
+ this.media = null;
323
+ this.resource = null;
324
+ this.audioSender = null;
325
+ this.micTrack = null;
326
+ this.mixer?.close();
327
+ this.mixer = null;
328
+ this.micOn = true;
329
+ pc?.close();
330
+ if (this.ownsMedia && media) for (const t of media.getTracks()) t.stop();
331
+ }
332
+ }
333
+
334
+ /**
335
+ * WHIP sends one complete offer, so the candidates have to be in it before it
336
+ * is sent.
337
+ */
338
+ function iceGathered(pc: RTCPeerConnection): Promise<void> {
339
+ if (pc.iceGatheringState === 'complete') return Promise.resolve();
340
+ return new Promise((resolve) => {
341
+ const done = () => {
342
+ clearTimeout(timer);
343
+ pc.removeEventListener('icegatheringstatechange', check);
344
+ resolve();
345
+ };
346
+ const check = () => {
347
+ if (pc.iceGatheringState === 'complete') done();
348
+ };
349
+ const timer = setTimeout(done, ICE_GATHER_TIMEOUT_MS);
350
+ pc.addEventListener('icegatheringstatechange', check);
351
+ });
352
+ }
package/src/chat.ts ADDED
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Chat for the orchestrator's signaling protocol.
3
+ *
4
+ * A channel shares its name with the room of the same name, so there are two
5
+ * ways into one conversation: `StreamClient` carries chat over the socket it
6
+ * already holds for media, and `ChatClient` here opens a socket that carries
7
+ * nothing else -- for an audience that is watching a broadcast over a CDN and
8
+ * has no reason to be on an SFU.
9
+ */
10
+ import type { Role } from './index.js';
11
+
12
+ export interface ChatMessage {
13
+ /** Monotonic within a channel, so the oldest id held is a paging cursor. */
14
+ id: string;
15
+ channel: string;
16
+ from: string;
17
+ /** Present on a direct message, which only its two ends receive. */
18
+ to?: string;
19
+ /** "text" unless the sender said otherwise. Anything else is the app's own. */
20
+ type: string;
21
+ /**
22
+ * Delivered exactly as sent. Render it as text, never as HTML: escaping is
23
+ * the renderer's job and the server does not do it for you.
24
+ */
25
+ text?: string;
26
+ metadata?: unknown;
27
+ /** Echoed from the sender, to match an optimistic message to the real one. */
28
+ clientMsgId?: string;
29
+ ts: number;
30
+ }
31
+
32
+ export interface SendMessageOptions {
33
+ type?: string;
34
+ /** Make it a direct message to one identity. */
35
+ to?: string;
36
+ metadata?: unknown;
37
+ clientMsgId?: string;
38
+ /** Delivered and not stored: reactions, hearts, anything not worth replaying. */
39
+ ephemeral?: boolean;
40
+ }
41
+
42
+ export interface ChatState {
43
+ channel: string;
44
+ /** Distinct identities connected, in the room or only in its chat. */
45
+ members: number;
46
+ /** Whether a moderator has muted THIS identity. */
47
+ muted: boolean;
48
+ /** Only present for moderators. */
49
+ mutedIdentities?: string[];
50
+ }
51
+
52
+ export interface ChatJoinResult {
53
+ channel: string;
54
+ identity: string;
55
+ role: Role;
56
+ members: number;
57
+ muted: boolean;
58
+ /** The most recent messages, oldest first. */
59
+ messages: ChatMessage[];
60
+ }
61
+
62
+ export interface ChatEvents {
63
+ message: (m: ChatMessage) => void;
64
+ messageDeleted: (e: { id: string; by: string }) => void;
65
+ chatMuted: (e: { identity: string; muted: boolean; by: string }) => void;
66
+ typing: (e: { identity: string; typing: boolean }) => void;
67
+ }
68
+
69
+ export interface ChatClientEvents extends ChatEvents {
70
+ connected: () => void;
71
+ disconnected: (reason: string) => void;
72
+ joined: (result: ChatJoinResult) => void;
73
+ error: (err: Error) => void;
74
+ /** The session ended and will not recover. Nothing further will be emitted. */
75
+ closed: (reason: 'gave-up' | 'left' | 'ended') => void;
76
+ /** The account behind this connection ran out; see StreamClient's event. */
77
+ usageLimit: (e: { reason: string; graceSeconds: number; endsAt: number }) => void;
78
+ }
79
+
80
+ /** An error returned by the server, carrying its machine-readable code. */
81
+ export class ChatRpcError extends Error {
82
+ constructor(readonly code: string, message: string) {
83
+ super(`${code}: ${message}`);
84
+ this.name = 'ChatRpcError';
85
+ }
86
+ }
87
+
88
+ type Handler = (...args: never[]) => void;
89
+
90
+ interface Pending {
91
+ resolve: (v: unknown) => void;
92
+ reject: (e: Error) => void;
93
+ timer: ReturnType<typeof setTimeout>;
94
+ }
95
+
96
+ const RPC_TIMEOUT_MS = 15_000;
97
+ const MAX_RECONNECT_ATTEMPTS = 8;
98
+ /** A token that is wrong will be wrong next time too; retrying only adds noise. */
99
+ const TERMINAL_JOIN_ERRORS = new Set([
100
+ 'bad-token',
101
+ 'no-token',
102
+ // The account cannot use the service until it is topped up or restored.
103
+ 'quota-exhausted',
104
+ 'daily-limit-reached',
105
+ 'monthly-limit-reached',
106
+ 'tenant-suspended',
107
+ ]);
108
+
109
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
110
+
111
+ /**
112
+ * A chat-only connection. Takes the `wsUrl` and `token` returned by
113
+ * `POST /v1/chat/{channel}/tokens`.
114
+ */
115
+ export class ChatClient {
116
+ private ws: WebSocket | null = null;
117
+ private readonly pending = new Map<number, Pending>();
118
+ private readonly listeners = new Map<string, Set<Handler>>();
119
+ private nextId = 1;
120
+ private token: string;
121
+ private joinResult: ChatJoinResult | null = null;
122
+ private closedByUs = false;
123
+ private reconnecting = false;
124
+ private reconnectAttempt = 0;
125
+
126
+ constructor(private readonly wsUrl: string, token: string) {
127
+ this.token = token;
128
+ }
129
+
130
+ on<K extends keyof ChatClientEvents>(event: K, fn: ChatClientEvents[K]): () => void {
131
+ let set = this.listeners.get(event);
132
+ if (!set) {
133
+ set = new Set();
134
+ this.listeners.set(event, set);
135
+ }
136
+ set.add(fn as Handler);
137
+ return () => set!.delete(fn as Handler);
138
+ }
139
+
140
+ private emit<K extends keyof ChatClientEvents>(event: K, ...args: Parameters<ChatClientEvents[K]>): void {
141
+ for (const fn of this.listeners.get(event) ?? []) {
142
+ try {
143
+ (fn as (...a: unknown[]) => void)(...args);
144
+ } catch (err) {
145
+ console.error(`listener for ${event} threw`, err);
146
+ }
147
+ }
148
+ }
149
+
150
+ /** What join returned, including the history the channel had at that moment. */
151
+ get info(): ChatJoinResult | null {
152
+ return this.joinResult;
153
+ }
154
+
155
+ async connect(): Promise<ChatJoinResult> {
156
+ await this.openSocket();
157
+ const result = (await this.rpc('join', { token: this.token })) as ChatJoinResult;
158
+ this.joinResult = result;
159
+ this.emit('joined', result);
160
+ return result;
161
+ }
162
+
163
+ private openSocket(): Promise<void> {
164
+ this.detachSocket();
165
+ return new Promise((resolve, reject) => {
166
+ const url = new URL(this.wsUrl);
167
+ url.searchParams.set('token', this.token);
168
+ const ws = new WebSocket(url.toString());
169
+ this.ws = ws;
170
+
171
+ const onError = () => reject(new Error('websocket failed to open'));
172
+ ws.addEventListener('open', () => {
173
+ this.reconnectAttempt = 0;
174
+ this.emit('connected');
175
+ resolve();
176
+ }, { once: true });
177
+ ws.addEventListener('error', onError, { once: true });
178
+ ws.addEventListener('message', (ev) => {
179
+ if (this.ws === ws) this.onMessage(ev.data as string);
180
+ });
181
+ ws.addEventListener('close', (ev) => {
182
+ ws.removeEventListener('error', onError);
183
+ // Only the socket we currently consider ours may drive reconnection.
184
+ if (this.ws !== ws) return;
185
+ this.ws = null;
186
+ this.failAllPending(new Error(`socket closed (${ev.code})`));
187
+ this.emit('disconnected', ev.reason || String(ev.code));
188
+ if (ev.code === 4003 && !this.closedByUs) {
189
+ // Ended by the server after telling us why (usageLimit). Final.
190
+ this.closedByUs = true;
191
+ this.emit('closed', 'ended');
192
+ return;
193
+ }
194
+ if (!this.closedByUs) void this.scheduleReconnect();
195
+ });
196
+ });
197
+ }
198
+
199
+ private detachSocket(): void {
200
+ const ws = this.ws;
201
+ this.ws = null;
202
+ if (ws && ws.readyState <= WebSocket.OPEN) {
203
+ try {
204
+ ws.close(1000, 'superseded');
205
+ } catch {
206
+ /* already closing */
207
+ }
208
+ }
209
+ }
210
+
211
+ /** Single-flight, with backoff: the same shape as the room client's. */
212
+ private async scheduleReconnect(): Promise<void> {
213
+ if (this.reconnecting || this.closedByUs) return;
214
+ this.reconnecting = true;
215
+ try {
216
+ while (!this.closedByUs) {
217
+ this.reconnectAttempt += 1;
218
+ if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
219
+ this.emit('error', new Error(`gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
220
+ this.closedByUs = true;
221
+ this.emit('closed', 'gave-up');
222
+ return;
223
+ }
224
+ const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
225
+ await sleep(backoff + Math.random() * 400);
226
+ if (this.closedByUs) return;
227
+ try {
228
+ await this.connect();
229
+ return;
230
+ } catch (err) {
231
+ this.emit('error', err as Error);
232
+ if (err instanceof ChatRpcError && TERMINAL_JOIN_ERRORS.has(err.code)) {
233
+ this.closedByUs = true;
234
+ this.emit('closed', 'gave-up');
235
+ return;
236
+ }
237
+ }
238
+ }
239
+ } finally {
240
+ this.reconnecting = false;
241
+ }
242
+ }
243
+
244
+ close(): void {
245
+ if (this.closedByUs) return;
246
+ this.closedByUs = true;
247
+ const ws = this.ws;
248
+ this.ws = null;
249
+ ws?.close(1000, 'client closed');
250
+ this.emit('closed', 'left');
251
+ }
252
+
253
+ private rpc(method: string, params: unknown = {}): Promise<unknown> {
254
+ return new Promise((resolve, reject) => {
255
+ if (this.ws?.readyState !== WebSocket.OPEN) {
256
+ reject(new Error('socket is not open'));
257
+ return;
258
+ }
259
+ const id = this.nextId++;
260
+ const timer = setTimeout(() => {
261
+ this.pending.delete(id);
262
+ reject(new Error(`${method} timed out`));
263
+ }, RPC_TIMEOUT_MS);
264
+ this.pending.set(id, { resolve, reject, timer });
265
+ this.ws.send(JSON.stringify({ id, method, params }));
266
+ });
267
+ }
268
+
269
+ private failAllPending(err: Error): void {
270
+ for (const p of this.pending.values()) {
271
+ clearTimeout(p.timer);
272
+ p.reject(err);
273
+ }
274
+ this.pending.clear();
275
+ }
276
+
277
+ private onMessage(raw: string): void {
278
+ let msg: Record<string, unknown>;
279
+ try {
280
+ msg = JSON.parse(raw);
281
+ } catch {
282
+ return;
283
+ }
284
+ if (typeof msg.id === 'number') {
285
+ const p = this.pending.get(msg.id);
286
+ if (!p) return;
287
+ clearTimeout(p.timer);
288
+ this.pending.delete(msg.id);
289
+ if (msg.ok) p.resolve(msg.result);
290
+ else {
291
+ const e = msg.error as { code?: string; message?: string } | undefined;
292
+ p.reject(new ChatRpcError(e?.code ?? 'error', e?.message ?? 'unknown'));
293
+ }
294
+ return;
295
+ }
296
+ switch (msg.event) {
297
+ case 'message':
298
+ this.emit('message', msg.data as ChatMessage);
299
+ break;
300
+ case 'messageDeleted':
301
+ this.emit('messageDeleted', msg.data as { id: string; by: string });
302
+ break;
303
+ case 'chatMuted':
304
+ this.emit('chatMuted', msg.data as { identity: string; muted: boolean; by: string });
305
+ break;
306
+ case 'typing':
307
+ this.emit('typing', msg.data as { identity: string; typing: boolean });
308
+ break;
309
+ case 'usageLimit':
310
+ this.emit('usageLimit', msg.data as { reason: string; graceSeconds: number; endsAt: number });
311
+ break;
312
+ case 'tokenRefreshed': {
313
+ // A channel outlives its join token many times over. The server hands
314
+ // out a fresh one periodically so a reconnect at any point still works.
315
+ const e = msg.data as { token?: string };
316
+ if (e.token) this.token = e.token;
317
+ break;
318
+ }
319
+ default:
320
+ break;
321
+ }
322
+ }
323
+
324
+ sendMessage(text: string, opts: SendMessageOptions = {}): Promise<{ id: string; ts: number }> {
325
+ return this.rpc('sendMessage', { text, ...opts }) as Promise<{ id: string; ts: number }>;
326
+ }
327
+
328
+ /** Older messages, oldest first. Pass the oldest id you hold as `before`. */
329
+ async getMessages(opts: { limit?: number; before?: string } = {}): Promise<ChatMessage[]> {
330
+ const res = (await this.rpc('getMessages', opts)) as { messages: ChatMessage[] };
331
+ return res.messages;
332
+ }
333
+
334
+ /** Your own message, or anyone's if you are a moderator. */
335
+ deleteMessage(messageId: string): Promise<unknown> {
336
+ return this.rpc('deleteMessage', { messageId });
337
+ }
338
+
339
+ /** Moderators only. A mute outlasts the muted socket reconnecting. */
340
+ muteChat(identity: string, muted = true): Promise<unknown> {
341
+ return this.rpc('muteChat', { identity, muted });
342
+ }
343
+
344
+ /** Advisory, and throttled by the server; safe to call on every keystroke. */
345
+ setTyping(typing: boolean): Promise<unknown> {
346
+ return this.rpc('setTyping', { typing });
347
+ }
348
+
349
+ getChatState(): Promise<ChatState> {
350
+ return this.rpc('getChatState') as Promise<ChatState>;
351
+ }
352
+ }