codehost 0.18.0 → 0.18.2

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,17 @@
1
+ ## [0.18.2](https://github.com/snomiao/codehost/compare/v0.18.1...v0.18.2) (2026-06-11)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **signaling:** recover instantly when a throttled tab wakes (visibility/focus/online) ([d459132](https://github.com/snomiao/codehost/commit/d45913267f053ee1fee062bae550cfe60a193207))
7
+
8
+ ## [0.18.1](https://github.com/snomiao/codehost/compare/v0.18.0...v0.18.1) (2026-06-11)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **signaling:** abort connect attempts stuck in CONNECTING; guard tunnel stream enqueue ([b3ae0d1](https://github.com/snomiao/codehost/commit/b3ae0d11299a195b8d09e42355cd14a3b09b8d2f))
14
+
1
15
  # [0.18.0](https://github.com/snomiao/codehost/compare/v0.17.0...v0.18.0) (2026-06-11)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,6 +37,12 @@ export interface CloseInfo {
37
37
  * open/close cycle becomes a sub-second reconnect storm. */
38
38
  const STABLE_MS = 10_000;
39
39
 
40
+ /** Abort a connect attempt that hasn't opened by this deadline. Observed in the
41
+ * field (Chrome, page-load burst): a socket can sit in CONNECTING for minutes
42
+ * and never fire close — so without this, no retry ever runs, even though a
43
+ * freshly-created socket to the same room opens instantly. */
44
+ const CONNECT_TIMEOUT_MS = 10_000;
45
+
40
46
  /**
41
47
  * Thin WebSocket client for the signaling room. Runs unchanged in the browser
42
48
  * and in Bun (both expose a global `WebSocket`). Auto-reconnects with backoff
@@ -47,6 +53,7 @@ export class SignalingClient {
47
53
  private ws: WebSocket | null = null;
48
54
  private closed = false;
49
55
  private reconnectDelay = 1000;
56
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
50
57
  private heartbeat: ReturnType<typeof setInterval> | null = null;
51
58
  /** Fires STABLE_MS after a socket opens; only then is the backoff reset. */
52
59
  private stableTimer: ReturnType<typeof setTimeout> | null = null;
@@ -59,9 +66,53 @@ export class SignalingClient {
59
66
 
60
67
  connect(): void {
61
68
  this.closed = false;
69
+ this.attachWakeListeners();
62
70
  this.open();
63
71
  }
64
72
 
73
+ // ---- background-tab recovery -------------------------------------------
74
+ // Chrome throttles timers in hidden tabs to minutes, so the backoff retry
75
+ // (and the connect-timeout abort) may be arbitrarily far away even though a
76
+ // fresh socket would connect in milliseconds. When the tab becomes visible /
77
+ // focused / back online, recover NOW instead of waiting for a timer.
78
+
79
+ private onWake = (): void => {
80
+ if (this.closed) return;
81
+ const state = this.ws?.readyState;
82
+ if (state === 1 /* OPEN */) return;
83
+ if (state === 0 /* CONNECTING */) {
84
+ // Stuck handshake: abort — onclose reschedules, and timers run normally
85
+ // now that the tab is active.
86
+ try {
87
+ this.ws?.close();
88
+ } catch {
89
+ // ignore
90
+ }
91
+ return;
92
+ }
93
+ // Closed and waiting out the (throttled) backoff: skip the wait.
94
+ if (this.reconnectTimer != null) {
95
+ this.clearReconnectTimer();
96
+ this.open();
97
+ }
98
+ };
99
+
100
+ private attachWakeListeners(): void {
101
+ const doc = (globalThis as { document?: EventTarget }).document;
102
+ doc?.addEventListener("visibilitychange", this.onWake);
103
+ const win = (globalThis as { window?: EventTarget }).window;
104
+ win?.addEventListener("focus", this.onWake);
105
+ win?.addEventListener("online", this.onWake);
106
+ }
107
+
108
+ private detachWakeListeners(): void {
109
+ const doc = (globalThis as { document?: EventTarget }).document;
110
+ doc?.removeEventListener("visibilitychange", this.onWake);
111
+ const win = (globalThis as { window?: EventTarget }).window;
112
+ win?.removeEventListener("focus", this.onWake);
113
+ win?.removeEventListener("online", this.onWake);
114
+ }
115
+
65
116
  private roomUrl(): string {
66
117
  const base = this.opts.url.replace(/\/+$/, "");
67
118
  return `${base}/room/${encodeURIComponent(this.opts.token)}`;
@@ -71,7 +122,21 @@ export class SignalingClient {
71
122
  const ws = new WebSocket(this.roomUrl());
72
123
  this.ws = ws;
73
124
 
125
+ // A stuck CONNECTING socket never fires close on its own — abort it so the
126
+ // normal onclose -> backoff -> retry path takes over.
127
+ const connectTimer = setTimeout(() => {
128
+ if (ws.readyState === 0 /* CONNECTING */) {
129
+ try {
130
+ ws.close();
131
+ } catch {
132
+ // closing an unopened socket may throw in some runtimes — the
133
+ // onerror/onclose path still runs
134
+ }
135
+ }
136
+ }, CONNECT_TIMEOUT_MS);
137
+
74
138
  ws.onopen = () => {
139
+ clearTimeout(connectTimer);
75
140
  this.openedAt = Date.now();
76
141
  // Don't reset the backoff yet — only once the socket proves stable (see
77
142
  // STABLE_MS). A handshake-then-drop network never reaches this timer, so
@@ -103,6 +168,7 @@ export class SignalingClient {
103
168
  };
104
169
 
105
170
  ws.onclose = (ev) => {
171
+ clearTimeout(connectTimer);
106
172
  this.clearStableTimer();
107
173
  this.stopHeartbeat();
108
174
  const ms = this.openedAt ? Date.now() - this.openedAt : 0;
@@ -151,11 +217,20 @@ export class SignalingClient {
151
217
  private scheduleReconnect(): void {
152
218
  const delay = this.reconnectDelay;
153
219
  this.reconnectDelay = Math.min(delay * 2, 15000);
154
- setTimeout(() => {
220
+ this.clearReconnectTimer();
221
+ this.reconnectTimer = setTimeout(() => {
222
+ this.reconnectTimer = null;
155
223
  if (!this.closed) this.open();
156
224
  }, delay);
157
225
  }
158
226
 
227
+ private clearReconnectTimer(): void {
228
+ if (this.reconnectTimer != null) {
229
+ clearTimeout(this.reconnectTimer);
230
+ this.reconnectTimer = null;
231
+ }
232
+ }
233
+
159
234
  sendSignal(to: string, data: unknown): void {
160
235
  const msg: ClientMessage = { type: "signal", to, data };
161
236
  this.ws?.send(JSON.stringify(msg));
@@ -173,6 +248,8 @@ export class SignalingClient {
173
248
 
174
249
  close(): void {
175
250
  this.closed = true;
251
+ this.detachWakeListeners();
252
+ this.clearReconnectTimer();
176
253
  this.stopHeartbeat();
177
254
  this.clearStableTimer();
178
255
  try {
@@ -129,7 +129,13 @@ export class TunnelClient {
129
129
  }),
130
130
  );
131
131
  },
132
- onBody: (b) => controller?.enqueue(b),
132
+ onBody: (b) => {
133
+ try {
134
+ controller?.enqueue(b);
135
+ } catch {
136
+ // stream already closed/cancelled (consumer went away mid-body)
137
+ }
138
+ },
133
139
  onEnd: () => {
134
140
  try {
135
141
  controller?.close();