codehost 0.10.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.
@@ -0,0 +1 @@
1
+ {"sessionId":"f8e4e571-944e-43d7-bbfa-267a5251e41c","pid":87968,"procStart":"Mon Jun 8 09:46:02 2026","acquiredAt":1780974562147}
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
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
+
8
+ # [0.11.0](https://github.com/snomiao/codehost/compare/v0.10.0...v0.11.0) (2026-06-09)
9
+
10
+
11
+ ### Features
12
+
13
+ * **web:** update URL on Connect + Back returns to the list ([7258a16](https://github.com/snomiao/codehost/commit/7258a168a0bcc1d476aab8a7f3fcd81f35ccfcb5))
14
+
1
15
  # [0.10.0](https://github.com/snomiao/codehost/compare/v0.9.1...v0.10.0) (2026-06-09)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.10.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 {
@@ -176,6 +176,11 @@ export function Discovery() {
176
176
  const activePeerRef = useRef<string | null>(null);
177
177
  const activeRoomRef = useRef<string | null>(null);
178
178
  const sendersRef = useRef<Map<string, (to: string, data: unknown) => void>>(new Map());
179
+ // Whether the live connection pushed a history entry (so Disconnect/Back can
180
+ // pop it), and whether we're currently *viewing* a workspace (so popstate only
181
+ // tears down from the iframe view, not during a mid-connect URL revert).
182
+ const pushedRef = useRef(false);
183
+ const viewingRef = useRef(false);
179
184
 
180
185
  // Deep-link resolution (/gh/<owner>/<repo>/... or /dev/<path>): parse once,
181
186
  // auto-connect when a matching server appears, remember the opened folder.
@@ -213,6 +218,13 @@ export function Discovery() {
213
218
  const folder = activeFolderRef.current;
214
219
  setTimeout(() => setIframeSrc(`/vs/${peerId}/${folderQuery(folder)}`), 400);
215
220
  });
221
+ // Back / Cmd+Left from a workspace returns to the list: the browser restores
222
+ // the previous URL and we drop the connection. Only when actually viewing —
223
+ // a mid-connect URL revert (failed dial) must leave the list state untouched.
224
+ const onPopState = () => {
225
+ if (viewingRef.current) teardownConn();
226
+ };
227
+ window.addEventListener("popstate", onPopState);
216
228
  // A valid token in the URL fragment (#t=<token>) joins the room and turns on
217
229
  // single-server auto-connect for it; consume it from the address bar after,
218
230
  // so the secret isn't left visible or re-applied on a manual reload.
@@ -242,6 +254,8 @@ export function Discovery() {
242
254
  }
243
255
  }
244
256
  }
257
+
258
+ return () => window.removeEventListener("popstate", onPopState);
245
259
  }, []);
246
260
 
247
261
  // Auto-connect once discovery turns up a match: a deep-link target across any
@@ -291,8 +305,19 @@ export function Discovery() {
291
305
  setActivePeerId(server.peerId);
292
306
  activePeerRef.current = server.peerId;
293
307
  activeRoomRef.current = room;
308
+ viewingRef.current = false;
294
309
  setConnState("connecting");
295
310
 
311
+ // Update the address bar the instant Connect is clicked (don't wait for the
312
+ // WebRTC handshake) and push a history entry, so Back / Cmd+Left returns to
313
+ // the list. Reverted if the connection fails.
314
+ const openFolder = folder ?? server.meta?.cwd;
315
+ const targetPath = shareablePathFor(server, openFolder);
316
+ const pushed = !!targetPath && targetPath !== window.location.pathname;
317
+ if (pushed) history.pushState(null, "", targetPath);
318
+ pushedRef.current = pushed;
319
+ sharePathRef.current = targetPath ?? window.location.pathname;
320
+
296
321
  // The broker decides whether this tab owns the connection. `establish` is
297
322
  // only invoked when we're the owner (or get promoted on failover); other
298
323
  // tabs reuse the owner's channel via a proxy, so they never open WebRTC.
@@ -326,29 +351,31 @@ export function Discovery() {
326
351
  await connBroker.connect(server.peerId, establish);
327
352
  setConnState("connected");
328
353
  // The daemon no longer sets a default folder (current VS Code serve-web
329
- // dropped that flag), so open the served workspace from here: an explicit
354
+ // dropped that flag), so open the served workspace from here: the
330
355
  // deep-link folder if we have one, else the server's reported cwd.
331
- const openFolder = folder ?? server.meta?.cwd;
332
356
  activeFolderRef.current = openFolder;
333
357
  setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
334
358
  setResolving(null);
335
359
  recordConnect(server, room, openFolder);
336
- updateAddressBar(server, openFolder);
360
+ viewingRef.current = true;
337
361
  } catch {
338
362
  setConnState("failed");
363
+ // Undo the optimistic history entry / URL change. We never started
364
+ // viewing, so the popstate handler leaves the "failed" card in place.
365
+ if (pushed) {
366
+ pushedRef.current = false;
367
+ history.back();
368
+ }
339
369
  }
340
370
  }
341
371
 
342
- // Reflect the live connection in the address bar as a clean, shareable deep
343
- // link (no token — Share adds that). If we arrived via a deep link, keep its
344
- // pathname; otherwise derive one from the server's repo identity or folder.
345
- function updateAddressBar(server: PeerInfo, folder?: string) {
346
- const path = deepLinkRef.current
372
+ // Shareable deep-link pathname for a server+folder, with no side effects (no
373
+ // token — Share adds that). Keeps an existing deep-link path as-is; otherwise
374
+ // derives /gh|/git|/dev from the server's repo identity or opened folder.
375
+ function shareablePathFor(server: PeerInfo, folder?: string): string | null {
376
+ return deepLinkRef.current
347
377
  ? window.location.pathname
348
378
  : shareableDeepLink({ repo: server.meta?.repo, branch: server.meta?.branch, folder });
349
- if (!path) return;
350
- sharePathRef.current = path;
351
- if (path !== window.location.pathname) history.replaceState(null, "", path);
352
379
  }
353
380
 
354
381
  async function shareLink() {
@@ -408,7 +435,10 @@ export function Discovery() {
408
435
  if (dl?.type === "repo") recordConnection(repoKey(dl.target), { ...base, folder });
409
436
  }
410
437
 
411
- function disconnect() {
438
+ // Tear down the active connection and return to the workspace list. Does NOT
439
+ // touch history — the caller (Disconnect → history.back, or a popstate from
440
+ // Cmd+Left) owns the URL.
441
+ function teardownConn() {
412
442
  rtcRef.current?.close();
413
443
  rtcRef.current = null;
414
444
  if (activePeerRef.current) connBroker.disconnect(activePeerRef.current);
@@ -418,6 +448,18 @@ export function Discovery() {
418
448
  activeRoomRef.current = null;
419
449
  setConnState("idle");
420
450
  sharePathRef.current = null;
451
+ pushedRef.current = false;
452
+ viewingRef.current = false;
453
+ }
454
+
455
+ function disconnect() {
456
+ // Mirror Cmd+Left: if connecting pushed a history entry, pop it — the
457
+ // browser restores the previous URL and our popstate handler tears down.
458
+ if (pushedRef.current) {
459
+ history.back();
460
+ return;
461
+ }
462
+ teardownConn();
421
463
  if (window.location.pathname !== "/") history.replaceState(null, "", "/");
422
464
  }
423
465