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.
- package/.claude/scheduled_tasks.lock +1 -0
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/cli/run-server.ts +7 -1
- package/src/shared/signaling-client.ts +43 -4
- package/src/web/discovery.tsx +54 -12
|
@@ -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
package/src/cli/run-server.ts
CHANGED
|
@@ -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: () =>
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 {
|
package/src/web/discovery.tsx
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
function
|
|
346
|
-
|
|
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
|
-
|
|
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
|
|