sueta / client / src / lib / signaling.ts
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
/**
 * WebSocket signaling client: reconnect with jittered backoff, app-level
 * ping/pong watchdog, roster diffing on rejoin. Blind-relay protocol per
 * docs/PROTOCOL.md ยง4.
 */

import type { Envelope } from "./crypto";
import type { ClientFrame, ServerFrame } from "./protocol";

export interface SignalingEvents {
  /** Fired on every (re)join with the full current roster. */
  peers(peers: string[]): void;
  peerJoined(peer: string): void;
  peerLeft(peer: string): void;
  signal(from: string, payload: Envelope): void;
  status(connected: boolean): void;
  error(code: string): void;
}

export class Signaling {
  #url: string;
  #room: string;
  #peer: string;
  #ev: SignalingEvents;

  #ws: WebSocket | null = null;
  #closed = false;
  #started = false; // wake-up kicks must not connect before the first connect()
  #attempt = 0;
  #pingTimer: ReturnType<typeof setInterval> | null = null;
  #missedPongs = 0;
  #queue: ClientFrame[] = [];

  constructor(url: string, room: string, peer: string, events: SignalingEvents) {
    this.#url = url;
    this.#room = room;
    this.#peer = peer;
    this.#ev = events;
    document.addEventListener("visibilitychange", this.#onVisible);
    window.addEventListener("pageshow", this.#kick);
    window.addEventListener("online", this.#kick);
  }

  connect(): void {
    if (this.#closed || (this.#ws && this.#ws.readyState <= WebSocket.OPEN)) return;
    this.#started = true;
    const ws = new WebSocket(this.#url);
    this.#ws = ws;
    ws.onopen = () => {
      this.#attempt = 0;
      this.#missedPongs = 0;
      this.#send({ t: "join", room: this.#room, peer: this.#peer });
      for (const f of this.#queue.splice(0)) this.#send(f);
      this.#pingTimer = setInterval(() => {
        if (++this.#missedPongs > 2) {
          ws.close(); // watchdog: server is gone even if TCP hasn't noticed
          return;
        }
        this.#send({ t: "ping" });
      }, 25_000);
      this.#ev.status(true);
    };
    ws.onmessage = (e) => {
      let m: ServerFrame;
      try {
        m = JSON.parse(e.data as string);
      } catch {
        return;
      }
      switch (m.t) {
        case "peers":
          this.#ev.peers(m.peers ?? []);
          break;
        case "peer-joined":
          this.#ev.peerJoined(m.peer);
          break;
        case "peer-left":
          this.#ev.peerLeft(m.peer);
          break;
        case "signal":
          this.#ev.signal(m.from, m.payload);
          break;
        case "pong":
          this.#missedPongs = 0;
          break;
        case "error":
          this.#ev.error(m.code);
          break;
      }
    };
    ws.onclose = () => {
      if (this.#pingTimer) clearInterval(this.#pingTimer);
      this.#pingTimer = null;
      this.#ws = null;
      this.#ev.status(false);
      if (!this.#closed) {
        const delay = Math.min(15_000, 500 * 2 ** this.#attempt++) * (0.5 + Math.random());
        setTimeout(() => this.connect(), delay);
      }
    };
    ws.onerror = () => ws.close();
  }

  /** Send an encrypted signal to a peer; queued (bounded) while reconnecting. */
  signal(to: string, payload: Envelope): void {
    const frame: ClientFrame = { t: "signal", to, payload };
    if (this.#ws?.readyState === WebSocket.OPEN) {
      this.#send(frame);
    } else if (this.#queue.length < 50) {
      this.#queue.push(frame);
    }
  }

  get connected(): boolean {
    return this.#ws?.readyState === WebSocket.OPEN;
  }

  close(): void {
    this.#closed = true;
    document.removeEventListener("visibilitychange", this.#onVisible);
    window.removeEventListener("pageshow", this.#kick);
    window.removeEventListener("online", this.#kick);
    this.#ws?.close();
  }

  #send(f: ClientFrame): void {
    this.#ws?.send(JSON.stringify(f));
  }

  #onVisible = () => {
    if (document.visibilityState === "visible") this.#kick();
  };

  /** iOS kills sockets in background; reconnect immediately when we're back. */
  #kick = () => {
    if (this.#closed || !this.#started) return;
    if (!this.#ws || this.#ws.readyState > WebSocket.OPEN) {
      this.#attempt = 0;
      this.connect();
    } else {
      this.#send({ t: "ping" }); // probe a possibly-dead socket
    }
  };
}