@tribe-nest/media-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +68 -0
  2. package/build/core/index.d.ts +17 -0
  3. package/build/core/index.d.ts.map +1 -0
  4. package/build/core/index.js +41 -0
  5. package/build/core/index.js.map +1 -0
  6. package/build/core/reconnect.d.ts +95 -0
  7. package/build/core/reconnect.d.ts.map +1 -0
  8. package/build/core/reconnect.js +160 -0
  9. package/build/core/reconnect.js.map +1 -0
  10. package/build/core/signal.d.ts +184 -0
  11. package/build/core/signal.d.ts.map +1 -0
  12. package/build/core/signal.js +416 -0
  13. package/build/core/signal.js.map +1 -0
  14. package/build/core/socket.d.ts +57 -0
  15. package/build/core/socket.d.ts.map +1 -0
  16. package/build/core/socket.js +37 -0
  17. package/build/core/socket.js.map +1 -0
  18. package/build/core/state.d.ts +67 -0
  19. package/build/core/state.d.ts.map +1 -0
  20. package/build/core/state.js +193 -0
  21. package/build/core/state.js.map +1 -0
  22. package/build/index.d.ts +29 -0
  23. package/build/index.d.ts.map +1 -0
  24. package/build/index.js +51 -0
  25. package/build/index.js.map +1 -0
  26. package/build/protocol.d.ts +10 -0
  27. package/build/protocol.d.ts.map +1 -0
  28. package/build/protocol.js +26 -0
  29. package/build/protocol.js.map +1 -0
  30. package/build/react/index.d.ts +147 -0
  31. package/build/react/index.d.ts.map +1 -0
  32. package/build/react/index.js +319 -0
  33. package/build/react/index.js.map +1 -0
  34. package/build/room/browserDevice.d.ts +3 -0
  35. package/build/room/browserDevice.d.ts.map +1 -0
  36. package/build/room/browserDevice.js +94 -0
  37. package/build/room/browserDevice.js.map +1 -0
  38. package/build/room/device.d.ts +114 -0
  39. package/build/room/device.d.ts.map +1 -0
  40. package/build/room/device.js +3 -0
  41. package/build/room/device.js.map +1 -0
  42. package/build/room/room.d.ts +219 -0
  43. package/build/room/room.d.ts.map +1 -0
  44. package/build/room/room.js +438 -0
  45. package/build/room/room.js.map +1 -0
  46. package/package.json +69 -0
  47. package/src/_tests/clientBoundary.spec.ts +110 -0
  48. package/src/core/_tests/coreBoundary.spec.ts +70 -0
  49. package/src/core/_tests/fakeSignalServer.ts +188 -0
  50. package/src/core/_tests/reconnect.spec.ts +180 -0
  51. package/src/core/_tests/signal.spec.ts +347 -0
  52. package/src/core/_tests/state.spec.ts +226 -0
  53. package/src/core/index.ts +63 -0
  54. package/src/core/reconnect.ts +233 -0
  55. package/src/core/signal.ts +527 -0
  56. package/src/core/socket.ts +58 -0
  57. package/src/core/state.ts +251 -0
  58. package/src/index.ts +54 -0
  59. package/src/protocol.ts +9 -0
  60. package/src/react/_tests/hooks.spec.tsx +509 -0
  61. package/src/react/index.tsx +439 -0
  62. package/src/room/_tests/room.spec.ts +595 -0
  63. package/src/room/browserDevice.ts +114 -0
  64. package/src/room/device.ts +119 -0
  65. package/src/room/room.ts +600 -0
@@ -0,0 +1,527 @@
1
+ import {
2
+ MEDIA_PROTOCOL_VERSION,
3
+ MediaError,
4
+ isEvent,
5
+ serverFrameSchema,
6
+ type EventFrame,
7
+ type EventName,
8
+ type MediaErrorCode,
9
+ type RequestFrame,
10
+ type ServerFrame,
11
+ } from "@tribe-nest/media-protocol";
12
+
13
+ import { SOCKET_OPEN, defaultWebSocketFactory, type MediaWebSocketFactory, type WebSocketLike } from "./socket";
14
+
15
+ /**
16
+ * The headless signalling client: one socket, media-protocol frames, nothing
17
+ * else.
18
+ *
19
+ * It owns exactly three things the wire specifies and no policy beyond them:
20
+ *
21
+ * 1. **Request/reply correlation.** A request carries an `id` and gets exactly
22
+ * one reply with that `id`; an event carries no `id` and is never replied
23
+ * to. That is the whole protocol, so it is the whole of this class.
24
+ * 2. **The handshake.** `join` is the FIRST frame on the socket and carries the
25
+ * token. Nothing else may be sent before it.
26
+ * 3. **Why the connection ended**, as a `DisconnectCause` the reconnect policy
27
+ * can decide on. A socket that just closes tells the caller nothing, and
28
+ * "the node is draining" and "the room ended" need opposite responses.
29
+ *
30
+ * It deliberately does NOT reconnect, hold room state or know what a track is.
31
+ * Those are `core/reconnect.ts`, `core/state.ts` and P3b's room API.
32
+ */
33
+
34
+ /** What a caller must supply to open a connection. Fetched fresh EVERY attempt. */
35
+ export type MediaCoreCredentials = {
36
+ /** `MEDIA_URL`. The load balancer, never a node address the client picked. */
37
+ mediaUrl: string;
38
+ /** A join ticket, minted per attempt. */
39
+ token: string;
40
+ };
41
+
42
+ export type SignalLogLevel = "debug" | "warn" | "error";
43
+
44
+ export type MediaSignalOptions = {
45
+ /**
46
+ * Called before EVERY connection attempt, never once at construction.
47
+ *
48
+ * A join ticket expires in minutes and a call lasts an hour, so a token
49
+ * handed over once is a defect rather than a naming choice: the first
50
+ * reconnect after a network blip would present an expired token.
51
+ */
52
+ getCredentials: () => Promise<MediaCoreCredentials> | MediaCoreCredentials;
53
+ /** Defaults to `globalThis.WebSocket`, resolved lazily. */
54
+ webSocket?: MediaWebSocketFactory;
55
+ requestTimeoutMs?: number;
56
+ joinTimeoutMs?: number;
57
+ /**
58
+ * Overridable only so a spec can drive a mismatch. Production always sends
59
+ * `MEDIA_PROTOCOL_VERSION`.
60
+ */
61
+ protocolVersion?: number;
62
+ onLog?: (level: SignalLogLevel, message: string, detail?: unknown) => void;
63
+ };
64
+
65
+ export type SignalPhase = "idle" | "connecting" | "joining" | "joined" | "closed";
66
+
67
+ /**
68
+ * Why the connection ended.
69
+ *
70
+ * The distinctions are the ones a reconnect decision actually turns on: a
71
+ * drained node must not be retried, a closed room must not be rejoined, and a
72
+ * refusal carries the code the caller has to show a human.
73
+ */
74
+ export type DisconnectCause =
75
+ | { type: "closed_by_client" }
76
+ | { type: "room_closed"; reason: string }
77
+ | { type: "draining"; reconnectAfterMs: number }
78
+ | { type: "refused"; code: MediaErrorCode; message?: string }
79
+ | { type: "socket_closed"; code?: number; reason?: string };
80
+
81
+ type JoinedFrame = Extract<EventFrame, { event: "joined" }>;
82
+
83
+ /** `Omit` over a union collapses it to the common keys, which for a frame union
84
+ * is just `method`. Distributing keeps every variant's payload. */
85
+ type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
86
+
87
+ /**
88
+ * Everything a caller may send. `join` is absent on purpose: the handshake
89
+ * belongs to `connect()`, and a second join on a live socket is not something
90
+ * the protocol has an answer for.
91
+ */
92
+ export type SignalRequest = DistributiveOmit<Exclude<RequestFrame, { method: "join" }>, "id">;
93
+
94
+ type Pending = {
95
+ resolve: (data: unknown) => void;
96
+ reject: (error: unknown) => void;
97
+ timer: ReturnType<typeof setTimeout> | null;
98
+ method: string;
99
+ };
100
+
101
+ type AnyHandler = (frame: EventFrame) => void;
102
+ type CloseHandler = (cause: DisconnectCause) => void;
103
+
104
+ const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
105
+ const DEFAULT_JOIN_TIMEOUT_MS = 10_000;
106
+
107
+ /**
108
+ * A join ticket in a URL is a join ticket in every access log between the
109
+ * client and the node, and load balancers log query strings by default. The
110
+ * contract puts the token in the first frame; this refuses a URL that looks
111
+ * like someone routed around that.
112
+ */
113
+ export function assertTokenNotInUrl(mediaUrl: string): void {
114
+ let url: URL;
115
+ try {
116
+ url = new URL(mediaUrl);
117
+ } catch {
118
+ throw new Error(`mediaUrl is not a URL: ${mediaUrl}`);
119
+ }
120
+ for (const key of url.searchParams.keys()) {
121
+ if (/token|jwt|ticket|auth/i.test(key)) {
122
+ throw new Error(`refusing to connect: mediaUrl carries "${key}" in the query string. The token goes in the first frame.`);
123
+ }
124
+ }
125
+ }
126
+
127
+ /** Maps a thrown error back to the cause the reconnect policy decides on. */
128
+ export function causeFromError(error: unknown): DisconnectCause {
129
+ if (error instanceof MediaError) {
130
+ return { type: "refused", code: error.code, message: error.message };
131
+ }
132
+ return { type: "socket_closed", reason: error instanceof Error ? error.message : String(error) };
133
+ }
134
+
135
+ export class MediaSignal {
136
+ private readonly options: MediaSignalOptions;
137
+ private socket: WebSocketLike | null = null;
138
+ private nextId = 0;
139
+ private readonly pending = new Map<number, Pending>();
140
+ private readonly handlers = new Map<EventName, Set<(frame: EventFrame) => void>>();
141
+ private readonly anyHandlers = new Set<AnyHandler>();
142
+ private readonly closeHandlers = new Set<CloseHandler>();
143
+ /** Set by the events that explain a close BEFORE the socket goes away. */
144
+ private terminalCause: DisconnectCause | null = null;
145
+ private phaseValue: SignalPhase = "idle";
146
+ private identityValue: string | null = null;
147
+ private joinedResolve: ((frame: JoinedFrame) => void) | null = null;
148
+ private connectReject: ((error: unknown) => void) | null = null;
149
+ /**
150
+ * Which connection attempt is the current one.
151
+ *
152
+ * `connect()` awaits `getCredentials()` before it has a socket, and until that
153
+ * socket exists there is nothing for `close()` to close: it flips the phase
154
+ * and returns. So the attempt is stamped and re-checked, and an attempt that
155
+ * is no longer the current one abandons itself instead of opening a socket
156
+ * and JOINING with a live token that nothing then holds a reference to.
157
+ */
158
+ private attempt = 0;
159
+ private boundClose: ((event: { code?: number; reason?: string }) => void) | null = null;
160
+
161
+ constructor(options: MediaSignalOptions) {
162
+ this.options = options;
163
+ }
164
+
165
+ get phase(): SignalPhase {
166
+ return this.phaseValue;
167
+ }
168
+
169
+ /** Our own identity, as the node reported it in `joined`. */
170
+ get identity(): string | null {
171
+ return this.identityValue;
172
+ }
173
+
174
+ /**
175
+ * Opens the socket, sends `join` as the first frame, and resolves once BOTH
176
+ * the join reply and the `joined` event have arrived.
177
+ *
178
+ * Waiting for both is deliberate. The reply says the node accepted the token;
179
+ * the event carries `routerRtpCapabilities`, the peer snapshot and the
180
+ * recording flag. A caller that resolved on the reply alone would be handed a
181
+ * room it knows nothing about, and the contract fixes no order between the
182
+ * two frames, so neither may be assumed to arrive first.
183
+ */
184
+ async connect(): Promise<JoinedFrame> {
185
+ if (this.phaseValue === "connecting" || this.phaseValue === "joining" || this.phaseValue === "joined") {
186
+ throw new Error("connect() called on a signal that is already connecting or connected");
187
+ }
188
+ this.resetForConnect();
189
+ const attempt = ++this.attempt;
190
+ this.phaseValue = "connecting";
191
+
192
+ const credentials = await this.options.getCredentials();
193
+ // Fetching a ticket is a network round trip of its own, so this window is
194
+ // seconds wide and a caller giving up inside it is ordinary. Every later
195
+ // step has a socket and is cancelled by `handleClose`; this one is the only
196
+ // part of a connect that a `close()` could not reach.
197
+ this.assertAttemptIsCurrent(attempt);
198
+ assertTokenNotInUrl(credentials.mediaUrl);
199
+
200
+ const factory = this.options.webSocket ?? defaultWebSocketFactory;
201
+ const socket = factory(credentials.mediaUrl);
202
+ this.socket = socket;
203
+ this.bind(socket);
204
+
205
+ try {
206
+ await this.waitForOpen(socket);
207
+ this.phaseValue = "joining";
208
+ const joined = await this.performJoin(credentials.token);
209
+ this.phaseValue = "joined";
210
+ this.identityValue = joined.identity;
211
+ return joined;
212
+ } catch (error) {
213
+ // A failed handshake leaves nothing worth keeping open, and the cause is
214
+ // recorded first so close handlers see the refusal rather than a bare
215
+ // socket close.
216
+ if (!this.terminalCause) this.terminalCause = causeFromError(error);
217
+ this.closeSocket();
218
+ throw error;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Sends a request and resolves with its `data`.
224
+ *
225
+ * `unknown`, not a guessed shape: the wire declares `data: unknown` because
226
+ * most of it is mediasoup's, and a client that pretends otherwise is asserting
227
+ * a contract this package does not hold.
228
+ */
229
+ request(frame: SignalRequest, timeoutMs?: number): Promise<unknown> {
230
+ const socket = this.socket;
231
+ if (!socket || this.phaseValue === "closed" || this.phaseValue === "idle") {
232
+ return Promise.reject(new MediaError("internal", `cannot send ${frame.method}: the signal connection is ${this.phaseValue}`));
233
+ }
234
+ const id = this.nextId++;
235
+ const promise = this.track(id, frame.method, timeoutMs ?? this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
236
+ try {
237
+ this.send({ ...frame, id } as RequestFrame);
238
+ } catch (error) {
239
+ // A socket that died between the check and the send must reject this
240
+ // call, not throw past a `.catch()` the caller reasonably attached.
241
+ this.settle(id, error);
242
+ }
243
+ return promise;
244
+ }
245
+
246
+ /**
247
+ * Best-effort `leave` then close.
248
+ *
249
+ * Best-effort because the node tears the session down on socket close anyway.
250
+ * A leave that hangs must not stop a user closing a tab.
251
+ */
252
+ async leave(): Promise<void> {
253
+ if (this.phaseValue === "joined") {
254
+ try {
255
+ await this.request({ method: "leave" });
256
+ } catch (error) {
257
+ this.log("debug", "leave was not acknowledged; closing anyway", error);
258
+ }
259
+ }
260
+ this.close();
261
+ }
262
+
263
+ /** Closes the socket. Idempotent. */
264
+ close(): void {
265
+ if (!this.terminalCause) this.terminalCause = { type: "closed_by_client" };
266
+ this.closeSocket();
267
+ }
268
+
269
+ on<K extends EventName>(event: K, handler: (frame: Extract<EventFrame, { event: K }>) => void): () => void {
270
+ const set = this.handlers.get(event) ?? new Set();
271
+ const erased = handler as (frame: EventFrame) => void;
272
+ set.add(erased);
273
+ this.handlers.set(event, set);
274
+ return () => {
275
+ set.delete(erased);
276
+ };
277
+ }
278
+
279
+ /** Every event, in arrival order. This is what the state reducer is fed. */
280
+ onAny(handler: AnyHandler): () => void {
281
+ this.anyHandlers.add(handler);
282
+ return () => {
283
+ this.anyHandlers.delete(handler);
284
+ };
285
+ }
286
+
287
+ onClose(handler: CloseHandler): () => void {
288
+ this.closeHandlers.add(handler);
289
+ return () => {
290
+ this.closeHandlers.delete(handler);
291
+ };
292
+ }
293
+
294
+ // ---------------------------------------------------------------- internals
295
+
296
+ /**
297
+ * Throws when this attempt has been overtaken: either `close()` ran, or a
298
+ * second `connect()` started (which the entry guard permits once the phase is
299
+ * `closed`, so an abandoned attempt must not go on to open a second socket).
300
+ */
301
+ private assertAttemptIsCurrent(attempt: number): void {
302
+ if (attempt === this.attempt && this.phaseValue !== "closed") return;
303
+ throw new MediaError("internal", "connect() was abandoned: the connection was closed before the socket was opened");
304
+ }
305
+
306
+ private resetForConnect(): void {
307
+ this.terminalCause = null;
308
+ this.identityValue = null;
309
+ this.joinedResolve = null;
310
+ this.connectReject = null;
311
+ this.pending.clear();
312
+ this.nextId = 0;
313
+ }
314
+
315
+ private bind(socket: WebSocketLike): void {
316
+ socket.addEventListener("message", (event) => this.handleMessage(event.data));
317
+ socket.addEventListener("error", (event) => this.log("warn", "signal socket error", event.error));
318
+ const onClose = (event: { code?: number; reason?: string }) => this.handleClose(event);
319
+ this.boundClose = onClose;
320
+ socket.addEventListener("close", onClose);
321
+ }
322
+
323
+ private waitForOpen(socket: WebSocketLike): Promise<void> {
324
+ if (socket.readyState === SOCKET_OPEN) return Promise.resolve();
325
+ return new Promise<void>((resolve, reject) => {
326
+ this.connectReject = reject;
327
+ const onOpen = () => {
328
+ socket.removeEventListener("open", onOpen);
329
+ this.connectReject = null;
330
+ resolve();
331
+ };
332
+ socket.addEventListener("open", onOpen);
333
+ });
334
+ }
335
+
336
+ private async performJoin(token: string): Promise<JoinedFrame> {
337
+ const id = this.nextId++;
338
+ const timeoutMs = this.options.joinTimeoutMs ?? DEFAULT_JOIN_TIMEOUT_MS;
339
+ const reply = this.track(id, "join", timeoutMs);
340
+ const joined = new Promise<JoinedFrame>((resolve, reject) => {
341
+ this.joinedResolve = resolve;
342
+ // The same rejection path as the reply: a socket that dies mid-handshake
343
+ // must not leave `connect()` hanging until the join timeout.
344
+ const previous = this.connectReject;
345
+ this.connectReject = (error) => {
346
+ previous?.(error);
347
+ reject(error);
348
+ };
349
+ });
350
+
351
+ this.send({
352
+ method: "join",
353
+ id,
354
+ protocolVersion: this.options.protocolVersion ?? MEDIA_PROTOCOL_VERSION,
355
+ token,
356
+ });
357
+
358
+ const [, joinedFrame] = await Promise.all([reply, this.withDeadline(joined, timeoutMs, "joined")]);
359
+ return joinedFrame;
360
+ }
361
+
362
+ private withDeadline<T>(promise: Promise<T>, ms: number, what: string): Promise<T> {
363
+ return new Promise<T>((resolve, reject) => {
364
+ const timer = setTimeout(() => {
365
+ // No `timeout` code exists in the frozen error union, so this reports
366
+ // `internal` and says what timed out in the message.
367
+ reject(new MediaError("internal", `timed out waiting for ${what} after ${ms}ms`));
368
+ }, ms);
369
+ promise.then(
370
+ (value) => {
371
+ clearTimeout(timer);
372
+ resolve(value);
373
+ },
374
+ (error) => {
375
+ clearTimeout(timer);
376
+ reject(error);
377
+ },
378
+ );
379
+ });
380
+ }
381
+
382
+ private track(id: number, method: string, timeoutMs: number): Promise<unknown> {
383
+ return new Promise<unknown>((resolve, reject) => {
384
+ const timer = setTimeout(() => {
385
+ this.pending.delete(id);
386
+ reject(new MediaError("internal", `no reply to ${method} (id ${id}) after ${timeoutMs}ms`));
387
+ }, timeoutMs);
388
+ this.pending.set(id, { resolve, reject, timer, method });
389
+ });
390
+ }
391
+
392
+ private settle(id: number, error: unknown): void {
393
+ const pending = this.pending.get(id);
394
+ if (!pending) return;
395
+ this.pending.delete(id);
396
+ if (pending.timer) clearTimeout(pending.timer);
397
+ pending.reject(error);
398
+ }
399
+
400
+ private send(frame: RequestFrame): void {
401
+ const socket = this.socket;
402
+ if (!socket) throw new MediaError("internal", "no socket");
403
+ socket.send(JSON.stringify(frame));
404
+ }
405
+
406
+ private handleMessage(data: unknown): void {
407
+ const text = toText(data);
408
+ if (text === null) {
409
+ this.log("warn", "dropping a non-text signal frame");
410
+ return;
411
+ }
412
+ let raw: unknown;
413
+ try {
414
+ raw = JSON.parse(text);
415
+ } catch {
416
+ this.log("warn", "dropping an unparseable signal frame");
417
+ return;
418
+ }
419
+ const parsed = serverFrameSchema.safeParse(raw);
420
+ if (!parsed.success) {
421
+ // Dropped, not thrown: a node that gains a frame in a later protocol
422
+ // version must not kill an older client's live call. A real mismatch is
423
+ // caught by the version handshake, loudly, at join.
424
+ this.log("warn", "dropping a frame that does not match the protocol", raw);
425
+ return;
426
+ }
427
+ this.dispatch(parsed.data);
428
+ }
429
+
430
+ private dispatch(frame: ServerFrame): void {
431
+ if (isEvent(frame)) {
432
+ this.handleEvent(frame);
433
+ return;
434
+ }
435
+ const pending = this.pending.get(frame.id);
436
+ if (!pending) {
437
+ // Either a second reply to one id or a reply to a request that already
438
+ // timed out. Both are protocol violations by the node, so they are said
439
+ // out loud rather than absorbed.
440
+ this.log("warn", `reply for unknown request id ${frame.id}`, frame);
441
+ return;
442
+ }
443
+ this.pending.delete(frame.id);
444
+ if (pending.timer) clearTimeout(pending.timer);
445
+ if (frame.ok) {
446
+ pending.resolve(frame.data);
447
+ } else {
448
+ pending.reject(new MediaError(frame.code, frame.message ?? `${pending.method} refused: ${frame.code}`));
449
+ }
450
+ }
451
+
452
+ private handleEvent(frame: EventFrame): void {
453
+ switch (frame.event) {
454
+ case "joined":
455
+ this.identityValue = frame.identity;
456
+ this.joinedResolve?.(frame);
457
+ this.joinedResolve = null;
458
+ break;
459
+ case "draining":
460
+ // Recorded now because the node closes the socket next, and by then the
461
+ // reason is gone. Reconnecting to this node is precisely wrong.
462
+ this.terminalCause = { type: "draining", reconnectAfterMs: frame.reconnectAfterMs };
463
+ break;
464
+ case "roomClosed":
465
+ this.terminalCause = { type: "room_closed", reason: frame.reason };
466
+ break;
467
+ default:
468
+ break;
469
+ }
470
+
471
+ for (const handler of this.handlers.get(frame.event) ?? []) handler(frame);
472
+ for (const handler of this.anyHandlers) handler(frame);
473
+ }
474
+
475
+ private handleClose(event: { code?: number; reason?: string }): void {
476
+ if (this.phaseValue === "closed") return;
477
+ this.phaseValue = "closed";
478
+ const cause: DisconnectCause = this.terminalCause ?? {
479
+ type: "socket_closed",
480
+ ...(event.code === undefined ? {} : { code: event.code }),
481
+ ...(event.reason === undefined ? {} : { reason: event.reason }),
482
+ };
483
+
484
+ const closedError = new MediaError("internal", `signal connection closed before a reply arrived (${cause.type})`);
485
+ for (const [, pending] of this.pending) {
486
+ if (pending.timer) clearTimeout(pending.timer);
487
+ pending.reject(closedError);
488
+ }
489
+ this.pending.clear();
490
+
491
+ this.connectReject?.(closedError);
492
+ this.connectReject = null;
493
+ this.joinedResolve = null;
494
+
495
+ for (const handler of this.closeHandlers) handler(cause);
496
+ }
497
+
498
+ private closeSocket(): void {
499
+ const socket = this.socket;
500
+ if (!socket) {
501
+ // Nothing was ever opened, so no close event will arrive to settle
502
+ // pending work. Settle it here.
503
+ if (this.phaseValue !== "closed") this.handleClose({});
504
+ return;
505
+ }
506
+ this.socket = null;
507
+ try {
508
+ socket.close(1000, "client");
509
+ } catch (error) {
510
+ this.log("debug", "socket.close threw", error);
511
+ }
512
+ if (this.boundClose) socket.removeEventListener("close", this.boundClose);
513
+ this.boundClose = null;
514
+ this.handleClose({ code: 1000 });
515
+ }
516
+
517
+ private log(level: SignalLogLevel, message: string, detail?: unknown): void {
518
+ this.options.onLog?.(level, message, detail);
519
+ }
520
+ }
521
+
522
+ function toText(data: unknown): string | null {
523
+ if (typeof data === "string") return data;
524
+ if (data instanceof Uint8Array) return new TextDecoder().decode(data);
525
+ if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
526
+ return null;
527
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The one WebSocket shape `core/` knows about.
3
+ *
4
+ * Four hosts have to drive this client and no two of them have the same socket:
5
+ * a browser has the DOM one, Node 24 has a global `WebSocket`, older Node
6
+ * services carry `ws`, and the load harness may want a socket that lies about
7
+ * latency. Rather than branch on the environment, `core/` takes a factory and
8
+ * never constructs anything itself.
9
+ *
10
+ * The listener style is `addEventListener`, because that is the intersection:
11
+ * the browser, Node's global and the `ws` package all provide it, while only
12
+ * `ws` provides `.on()`.
13
+ */
14
+
15
+ export const SOCKET_CONNECTING = 0;
16
+ export const SOCKET_OPEN = 1;
17
+ export const SOCKET_CLOSING = 2;
18
+ export const SOCKET_CLOSED = 3;
19
+
20
+ export type SocketOpenEvent = { type: "open" };
21
+ export type SocketMessageEvent = { type: "message"; data: unknown };
22
+ export type SocketErrorEvent = { type: "error"; error?: unknown };
23
+ export type SocketCloseEvent = { type: "close"; code?: number; reason?: string };
24
+
25
+ export type SocketEventMap = {
26
+ open: SocketOpenEvent;
27
+ message: SocketMessageEvent;
28
+ error: SocketErrorEvent;
29
+ close: SocketCloseEvent;
30
+ };
31
+
32
+ export interface WebSocketLike {
33
+ readonly readyState: number;
34
+ send(data: string): void;
35
+ close(code?: number, reason?: string): void;
36
+ addEventListener<K extends keyof SocketEventMap>(type: K, listener: (event: SocketEventMap[K]) => void): void;
37
+ removeEventListener<K extends keyof SocketEventMap>(type: K, listener: (event: SocketEventMap[K]) => void): void;
38
+ }
39
+
40
+ export type MediaWebSocketFactory = (url: string) => WebSocketLike;
41
+
42
+ /**
43
+ * Resolves `globalThis.WebSocket` at CALL time, never at module load.
44
+ *
45
+ * Reading it at module scope would make importing this package fail outright in
46
+ * a host that has no global socket but was going to pass its own factory, and
47
+ * "importing a type broke my service" is the class of problem the isomorphic
48
+ * split exists to prevent.
49
+ */
50
+ export function defaultWebSocketFactory(url: string): WebSocketLike {
51
+ const ctor = (globalThis as { WebSocket?: new (url: string) => unknown }).WebSocket;
52
+ if (!ctor) {
53
+ throw new Error(
54
+ "no global WebSocket in this runtime. Pass `webSocket: (url) => new WS(url)` to the signal client.",
55
+ );
56
+ }
57
+ return new ctor(url) as unknown as WebSocketLike;
58
+ }