codehost 0.11.0 → 0.11.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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [0.11.1](https://github.com/snomiao/codehost/compare/v0.11.0...v0.11.1) (2026-06-09)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **signaling:** harden reconnect backoff + log close code/duration ([a0aa1ce](https://github.com/snomiao/codehost/commit/a0aa1ce7aeae49815fc51dd272a8e08852ed55b2))
7
+
1
8
  # [0.11.0](https://github.com/snomiao/codehost/compare/v0.10.0...v0.11.0) (2026-06-09)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,7 +50,13 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
50
50
  peerId,
51
51
  meta: opts.meta,
52
52
  onOpen: () => console.log(`[codehost] registered as "${opts.meta.name}" (${peerId.slice(0, 8)})`),
53
- onClose: () => console.log("[codehost] disconnected from signaling, reconnecting…"),
53
+ onClose: (info) => {
54
+ // Surface the close code + how long the socket lived: a near-instant drop
55
+ // (low ms) points at a middlebox killing the WebSocket after the upgrade,
56
+ // not the signaling server. Helps triage field reconnect storms.
57
+ const detail = info ? ` (code ${info.code}${info.reason ? ` "${info.reason}"` : ""}, up ${info.ms}ms)` : "";
58
+ console.log(`[codehost] disconnected from signaling${detail}, reconnecting…`);
59
+ },
54
60
  onSignal: (from, data) => rtc.handleSignal(from, data),
55
61
  });
56
62
 
@@ -17,9 +17,26 @@ export interface SignalingClientOptions {
17
17
  onPeers?: (peers: PeerInfo[]) => void;
18
18
  onSignal?: (from: string, data: unknown) => void;
19
19
  onOpen?: () => void;
20
- onClose?: () => void;
20
+ /** Called on every socket close. `info` carries the WebSocket close code,
21
+ * reason, and how long the socket stayed open (ms) — for diagnosing networks
22
+ * that complete the upgrade then drop the connection. */
23
+ onClose?: (info?: CloseInfo) => void;
21
24
  }
22
25
 
26
+ export interface CloseInfo {
27
+ code: number;
28
+ reason: string;
29
+ /** Milliseconds the socket was open before it closed. */
30
+ ms: number;
31
+ }
32
+
33
+ /** Reset the reconnect backoff only after a socket has stayed open this long. A
34
+ * connection that completes the handshake then drops within seconds (a
35
+ * middlebox that accepts the WebSocket upgrade but kills the socket, seen on
36
+ * some field networks) must keep backing off — otherwise every reset-to-1s
37
+ * open/close cycle becomes a sub-second reconnect storm. */
38
+ const STABLE_MS = 10_000;
39
+
23
40
  /**
24
41
  * Thin WebSocket client for the signaling room. Runs unchanged in the browser
25
42
  * and in Bun (both expose a global `WebSocket`). Auto-reconnects with backoff
@@ -31,6 +48,10 @@ export class SignalingClient {
31
48
  private closed = false;
32
49
  private reconnectDelay = 1000;
33
50
  private heartbeat: ReturnType<typeof setInterval> | null = null;
51
+ /** Fires STABLE_MS after a socket opens; only then is the backoff reset. */
52
+ private stableTimer: ReturnType<typeof setTimeout> | null = null;
53
+ /** Wall-clock ms when the current socket opened (0 if never/closed). */
54
+ private openedAt = 0;
34
55
 
35
56
  constructor(private opts: SignalingClientOptions) {
36
57
  this.peerId = opts.peerId ?? newPeerId();
@@ -51,7 +72,14 @@ export class SignalingClient {
51
72
  this.ws = ws;
52
73
 
53
74
  ws.onopen = () => {
54
- this.reconnectDelay = 1000;
75
+ this.openedAt = Date.now();
76
+ // Don't reset the backoff yet — only once the socket proves stable (see
77
+ // STABLE_MS). A handshake-then-drop network never reaches this timer, so
78
+ // its backoff keeps growing instead of hammering at 1s.
79
+ this.clearStableTimer();
80
+ this.stableTimer = setTimeout(() => {
81
+ this.reconnectDelay = 1000;
82
+ }, STABLE_MS);
55
83
  const hello: ClientMessage = {
56
84
  type: "hello",
57
85
  role: this.opts.role,
@@ -74,9 +102,12 @@ export class SignalingClient {
74
102
  else if (msg.type === "signal") this.opts.onSignal?.(msg.from, msg.data);
75
103
  };
76
104
 
77
- ws.onclose = () => {
105
+ ws.onclose = (ev) => {
106
+ this.clearStableTimer();
78
107
  this.stopHeartbeat();
79
- this.opts.onClose?.();
108
+ const ms = this.openedAt ? Date.now() - this.openedAt : 0;
109
+ this.openedAt = 0;
110
+ this.opts.onClose?.({ code: ev?.code ?? 0, reason: ev?.reason ?? "", ms });
80
111
  if (!this.closed) this.scheduleReconnect();
81
112
  };
82
113
 
@@ -110,6 +141,13 @@ export class SignalingClient {
110
141
  }
111
142
  }
112
143
 
144
+ private clearStableTimer(): void {
145
+ if (this.stableTimer != null) {
146
+ clearTimeout(this.stableTimer);
147
+ this.stableTimer = null;
148
+ }
149
+ }
150
+
113
151
  private scheduleReconnect(): void {
114
152
  const delay = this.reconnectDelay;
115
153
  this.reconnectDelay = Math.min(delay * 2, 15000);
@@ -126,6 +164,7 @@ export class SignalingClient {
126
164
  close(): void {
127
165
  this.closed = true;
128
166
  this.stopHeartbeat();
167
+ this.clearStableTimer();
129
168
  try {
130
169
  this.ws?.close();
131
170
  } catch {