summaryrefslogtreecommitdiffstats
path: root/web/play/src/net.ts
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--web/play/src/net.ts120
1 files changed, 120 insertions, 0 deletions
diff --git a/web/play/src/net.ts b/web/play/src/net.ts
new file mode 100644
index 0000000..0a963ba
--- /dev/null
+++ b/web/play/src/net.ts
@@ -0,0 +1,120 @@
1import type { Handlers } from "./types";
2
3const MAX_ATTEMPTS = 6;
4
5/**
6 * Browser side of the gateway socket.
7 *
8 * The cookie is HttpOnly and handled by the browser; this class never reads or
9 * stores a credential. A transient close reconnects inside the gateway's grace
10 * window; a deliberate takeover stops for good.
11 */
12export class Gateway {
13 private socket: WebSocket | null = null;
14 private stopped = false;
15 private replaced = false;
16 private attempts = 0;
17 private timer: number | undefined;
18
19 constructor(
20 private readonly url: string,
21 private readonly handlers: Handlers,
22 ) {}
23
24 connect(): void {
25 if (this.stopped) return;
26 this.clearTimer();
27
28 let socket: WebSocket;
29 try {
30 socket = new WebSocket(this.url);
31 } catch {
32 const retrying = this.scheduleReconnect();
33 if (!retrying) this.handlers.onClosed(false);
34 return;
35 }
36 this.socket = socket;
37
38 socket.addEventListener("open", () => {
39 this.attempts = 0;
40 this.handlers.onOpen();
41 });
42
43 socket.addEventListener("message", (event) => {
44 let message: any;
45 try {
46 message = JSON.parse(String(event.data));
47 } catch {
48 return;
49 }
50 switch (message.type) {
51 case "hello":
52 this.handlers.onHello(message);
53 break;
54 case "view":
55 this.handlers.onView(message.view);
56 break;
57 case "notice":
58 this.handlers.onNotice(String(message.text ?? ""));
59 break;
60 case "error":
61 this.handlers.onError(String(message.text ?? ""));
62 break;
63 case "replaced":
64 this.replaced = true;
65 this.handlers.onReplaced();
66 break;
67 case "pong":
68 this.handlers.onPong();
69 break;
70 }
71 });
72
73 socket.addEventListener("close", () => {
74 const wasThisSocket = this.socket === socket;
75 if (wasThisSocket) this.socket = null;
76 if (this.replaced || this.stopped) {
77 this.handlers.onClosed(false);
78 return;
79 }
80 const retrying = this.scheduleReconnect();
81 this.handlers.onClosed(retrying);
82 });
83
84 socket.addEventListener("error", () => {
85 // The close event follows; reconnect is scheduled there.
86 });
87 }
88
89 send(command: unknown): void {
90 if (this.socket?.readyState !== WebSocket.OPEN) return;
91 this.socket.send(JSON.stringify({ type: "command", command }));
92 }
93
94 ping(): void {
95 if (this.socket?.readyState === WebSocket.OPEN) {
96 this.socket.send(JSON.stringify({ type: "ping" }));
97 }
98 }
99
100 close(): void {
101 this.stopped = true;
102 this.clearTimer();
103 this.socket?.close();
104 }
105
106 private scheduleReconnect(): boolean {
107 if (this.stopped || this.replaced || this.attempts >= MAX_ATTEMPTS) return false;
108 this.attempts += 1;
109 const delay = Math.min(250 * 2 ** (this.attempts - 1), 4000);
110 this.timer = window.setTimeout(() => this.connect(), delay);
111 return true;
112 }
113
114 private clearTimer(): void {
115 if (this.timer !== undefined) {
116 window.clearTimeout(this.timer);
117 this.timer = undefined;
118 }
119 }
120}