codehost 0.11.0 → 0.12.0
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 +14 -0
- package/package.json +1 -1
- package/src/cli/run-server.ts +7 -1
- package/src/shared/repo.ts +12 -3
- package/src/shared/signaling-client.ts +43 -4
- package/src/web/discovery.tsx +133 -60
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.12.0](https://github.com/snomiao/codehost/compare/v0.11.1...v0.12.0) (2026-06-09)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **web:** URL-driven reconnect — Forward rehydrates, auto-reconnect on drop ([8e1f186](https://github.com/snomiao/codehost/commit/8e1f186bd6611d5d56a9061fed23ed7316c735ca))
|
|
7
|
+
|
|
8
|
+
## [0.11.1](https://github.com/snomiao/codehost/compare/v0.11.0...v0.11.1) (2026-06-09)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **signaling:** harden reconnect backoff + log close code/duration ([a0aa1ce](https://github.com/snomiao/codehost/commit/a0aa1ce7aeae49815fc51dd272a8e08852ed55b2))
|
|
14
|
+
|
|
1
15
|
# [0.11.0](https://github.com/snomiao/codehost/compare/v0.10.0...v0.11.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
|
|
package/src/shared/repo.ts
CHANGED
|
@@ -138,10 +138,13 @@ export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Reso
|
|
|
138
138
|
return null;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
/** Pick a `dev`/repo server whose served cwd matches a /dev/<path> target.
|
|
141
|
+
/** Pick a `dev`/repo server whose served cwd matches a /dev/<path> target.
|
|
142
|
+
* Compares with leading + trailing slashes stripped: `parseDeepLink` forces a
|
|
143
|
+
* leading "/" on the path, but a served cwd may lack one (e.g. an `expose`
|
|
144
|
+
* server's `localhost:<port>`), so a trailing-only trim would never match it. */
|
|
142
145
|
export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolution | null {
|
|
143
|
-
const want =
|
|
144
|
-
const hit = servers.find((s) => s.meta &&
|
|
146
|
+
const want = stripEnds(target.path);
|
|
147
|
+
const hit = servers.find((s) => s.meta && stripEnds(s.meta.cwd) === want);
|
|
145
148
|
return hit ? { peerId: hit.peerId } : null;
|
|
146
149
|
}
|
|
147
150
|
|
|
@@ -172,3 +175,9 @@ function branchOk(meta: PeerMeta, target: RepoTarget): boolean {
|
|
|
172
175
|
function trimSlash(p: string): string {
|
|
173
176
|
return p.replace(/\/+$/, "");
|
|
174
177
|
}
|
|
178
|
+
|
|
179
|
+
/** Strip leading and trailing slashes — for comparing a `/dev/<path>` target to
|
|
180
|
+
* a served cwd that may or may not carry a leading slash. */
|
|
181
|
+
function stripEnds(p: string): string {
|
|
182
|
+
return p.replace(/^\/+|\/+$/g, "");
|
|
183
|
+
}
|
|
@@ -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
|
@@ -177,10 +177,19 @@ export function Discovery() {
|
|
|
177
177
|
const activeRoomRef = useRef<string | null>(null);
|
|
178
178
|
const sendersRef = useRef<Map<string, (to: string, data: unknown) => void>>(new Map());
|
|
179
179
|
// Whether the live connection pushed a history entry (so Disconnect/Back can
|
|
180
|
-
// pop it
|
|
181
|
-
// tears down from the iframe view, not during a mid-connect URL revert).
|
|
180
|
+
// pop it back to the list).
|
|
182
181
|
const pushedRef = useRef(false);
|
|
183
|
-
|
|
182
|
+
// A dial is in flight — a synchronous guard so the several reconnect triggers
|
|
183
|
+
// (popstate, server-list change, retry timer, deep-link auto-connect) never
|
|
184
|
+
// double-dial (connState updates a render too late to gate them).
|
|
185
|
+
const dialingRef = useRef(false);
|
|
186
|
+
// Set just before a failed-dial history.back() so the resulting popstate is
|
|
187
|
+
// treated as a URL revert, not a user navigation — the reconciler skips it once.
|
|
188
|
+
const revertingRef = useRef(false);
|
|
189
|
+
// Latest merged server list + connection state, read by the URL reconciler
|
|
190
|
+
// (invoked from the once-at-mount popstate handler) without a stale closure.
|
|
191
|
+
const allServersRef = useRef<RoomedServer[]>([]);
|
|
192
|
+
const connStateRef = useRef<ConnState>("idle");
|
|
184
193
|
|
|
185
194
|
// Deep-link resolution (/gh/<owner>/<repo>/... or /dev/<path>): parse once,
|
|
186
195
|
// auto-connect when a matching server appears, remember the opened folder.
|
|
@@ -218,11 +227,16 @@ export function Discovery() {
|
|
|
218
227
|
const folder = activeFolderRef.current;
|
|
219
228
|
setTimeout(() => setIframeSrc(`/vs/${peerId}/${folderQuery(folder)}`), 400);
|
|
220
229
|
});
|
|
221
|
-
// Back
|
|
222
|
-
//
|
|
223
|
-
//
|
|
230
|
+
// Back/Forward (Cmd+Left / Cmd+Right) reconcile the connection to the URL: a
|
|
231
|
+
// workspace deep link (re)connects to its server, the list URL drops the
|
|
232
|
+
// connection. The browser already changed the URL; we follow it. A failed
|
|
233
|
+
// dial's revert-back is skipped once (revertingRef) so it keeps the list.
|
|
224
234
|
const onPopState = () => {
|
|
225
|
-
if (
|
|
235
|
+
if (revertingRef.current) {
|
|
236
|
+
revertingRef.current = false;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
syncToUrl();
|
|
226
240
|
};
|
|
227
241
|
window.addEventListener("popstate", onPopState);
|
|
228
242
|
// A valid token in the URL fragment (#t=<token>) joins the room and turns on
|
|
@@ -265,6 +279,23 @@ export function Discovery() {
|
|
|
265
279
|
tryAutoConnect();
|
|
266
280
|
}, [serversByRoom, tokens]);
|
|
267
281
|
|
|
282
|
+
// Keep the connection in sync with the URL as servers come and go: reconnect
|
|
283
|
+
// when the workspace named by the address bar (re)appears in a room — covers a
|
|
284
|
+
// daemon restart or a dropped channel while the tab stays open.
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
syncToUrl();
|
|
287
|
+
}, [serversByRoom]);
|
|
288
|
+
|
|
289
|
+
// Safety-net retry: while the URL names a workspace we're not connected to and
|
|
290
|
+
// no dial is in flight, retry every few seconds — covers a dropped channel
|
|
291
|
+
// whose server never left the room (so no list change fires the effect above).
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (!parseDeepLink(window.location.pathname)) return;
|
|
294
|
+
if (connState === "connected" || connState === "connecting") return;
|
|
295
|
+
const id = setInterval(() => syncToUrl(), 5000);
|
|
296
|
+
return () => clearInterval(id);
|
|
297
|
+
}, [connState, serversByRoom]);
|
|
298
|
+
|
|
268
299
|
function joinFromInput(e: React.FormEvent) {
|
|
269
300
|
e.preventDefault();
|
|
270
301
|
const t = draft.trim();
|
|
@@ -295,59 +326,70 @@ export function Discovery() {
|
|
|
295
326
|
sendersRef.current.delete(t);
|
|
296
327
|
}
|
|
297
328
|
|
|
298
|
-
async function connectTo(server: PeerInfo, room: string, folder?: string) {
|
|
329
|
+
async function connectTo(server: PeerInfo, room: string, folder?: string, fromHistory = false) {
|
|
299
330
|
const send = sendersRef.current.get(room);
|
|
300
331
|
if (!send) return;
|
|
332
|
+
dialingRef.current = true; // synchronous gate against concurrent triggers
|
|
333
|
+
let didPush = false;
|
|
334
|
+
try {
|
|
335
|
+
// Clear any prior connection's broker state first: after an RTC drop the
|
|
336
|
+
// broker still holds the dead channel in `locals`, so re-dialing the same
|
|
337
|
+
// peer would otherwise resolve straight to it. Also covers switching peers.
|
|
338
|
+
if (activePeerRef.current) connBroker.disconnect(activePeerRef.current);
|
|
301
339
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
340
|
+
rtcRef.current?.close();
|
|
341
|
+
rtcRef.current = null;
|
|
342
|
+
setIframeSrc(null);
|
|
343
|
+
setActivePeerId(server.peerId);
|
|
344
|
+
activePeerRef.current = server.peerId;
|
|
345
|
+
activeRoomRef.current = room;
|
|
346
|
+
setConnState("connecting");
|
|
347
|
+
|
|
348
|
+
// Update the address bar the instant Connect is clicked (don't wait for the
|
|
349
|
+
// handshake) and push a history entry, so Back returns to the list and
|
|
350
|
+
// Forward returns here. When `fromHistory`, the browser already set the URL
|
|
351
|
+
// (back/forward/reconnect) — don't push again, but a prior entry exists.
|
|
352
|
+
const openFolder = folder ?? server.meta?.cwd;
|
|
353
|
+
if (fromHistory) {
|
|
354
|
+
pushedRef.current = true;
|
|
355
|
+
sharePathRef.current = window.location.pathname;
|
|
356
|
+
} else {
|
|
357
|
+
const targetPath = shareablePathFor(server, openFolder);
|
|
358
|
+
didPush = !!targetPath && targetPath !== window.location.pathname;
|
|
359
|
+
if (didPush) history.pushState(null, "", targetPath);
|
|
360
|
+
pushedRef.current = didPush;
|
|
361
|
+
sharePathRef.current = targetPath ?? window.location.pathname;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// The broker decides whether this tab owns the connection. `establish` is
|
|
365
|
+
// only invoked when we're the owner (or get promoted on failover); other
|
|
366
|
+
// tabs reuse the owner's channel via a proxy, so they never open WebRTC.
|
|
367
|
+
const establish = () =>
|
|
368
|
+
new Promise<RTCDataChannel>((resolve, reject) => {
|
|
369
|
+
const rtc = new RtcClient({
|
|
370
|
+
sendSignal: (data: RtcSignal) => send(server.peerId, data),
|
|
371
|
+
onState: (state) => {
|
|
372
|
+
if (state === "failed" || state === "disconnected") setConnState("failed");
|
|
373
|
+
},
|
|
374
|
+
onOpen: (channel) => {
|
|
375
|
+
clearTimeout(timer);
|
|
376
|
+
resolve(channel);
|
|
377
|
+
},
|
|
378
|
+
onClose: () => setConnState((s) => (s === "connected" ? "idle" : s)),
|
|
379
|
+
});
|
|
380
|
+
rtcRef.current = rtc;
|
|
381
|
+
// Don't hang forever dialing a peer that never answers (e.g. a stale
|
|
382
|
+
// server still listed in the room): fail the attempt after 15s.
|
|
383
|
+
const timer = setTimeout(() => {
|
|
384
|
+
rtc.close();
|
|
385
|
+
reject(new Error("connection timed out"));
|
|
386
|
+
}, 15000);
|
|
387
|
+
rtc.start().catch((err) => {
|
|
332
388
|
clearTimeout(timer);
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
onClose: () => setConnState((s) => (s === "connected" ? "idle" : s)),
|
|
336
|
-
});
|
|
337
|
-
rtcRef.current = rtc;
|
|
338
|
-
// Don't hang forever dialing a peer that never answers (e.g. a stale
|
|
339
|
-
// server still listed in the room): fail the attempt after 15s.
|
|
340
|
-
const timer = setTimeout(() => {
|
|
341
|
-
rtc.close();
|
|
342
|
-
reject(new Error("connection timed out"));
|
|
343
|
-
}, 15000);
|
|
344
|
-
rtc.start().catch((err) => {
|
|
345
|
-
clearTimeout(timer);
|
|
346
|
-
reject(err);
|
|
389
|
+
reject(err);
|
|
390
|
+
});
|
|
347
391
|
});
|
|
348
|
-
});
|
|
349
392
|
|
|
350
|
-
try {
|
|
351
393
|
await connBroker.connect(server.peerId, establish);
|
|
352
394
|
setConnState("connected");
|
|
353
395
|
// The daemon no longer sets a default folder (current VS Code serve-web
|
|
@@ -357,15 +399,16 @@ export function Discovery() {
|
|
|
357
399
|
setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
|
|
358
400
|
setResolving(null);
|
|
359
401
|
recordConnect(server, room, openFolder);
|
|
360
|
-
viewingRef.current = true;
|
|
361
402
|
} catch {
|
|
362
403
|
setConnState("failed");
|
|
363
|
-
// Undo the optimistic history entry
|
|
364
|
-
//
|
|
365
|
-
if (
|
|
366
|
-
|
|
404
|
+
// Undo the optimistic history entry we pushed. revertingRef makes the
|
|
405
|
+
// resulting popstate a no-op so the "failed" card stays on the list.
|
|
406
|
+
if (didPush) {
|
|
407
|
+
revertingRef.current = true;
|
|
367
408
|
history.back();
|
|
368
409
|
}
|
|
410
|
+
} finally {
|
|
411
|
+
dialingRef.current = false;
|
|
369
412
|
}
|
|
370
413
|
}
|
|
371
414
|
|
|
@@ -449,7 +492,33 @@ export function Discovery() {
|
|
|
449
492
|
setConnState("idle");
|
|
450
493
|
sharePathRef.current = null;
|
|
451
494
|
pushedRef.current = false;
|
|
452
|
-
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Resolve a workspace deep-link path to a live server across all joined rooms.
|
|
498
|
+
function findServerForDeepLink(dl: DeepLink): (RoomedServer & { folder?: string }) | null {
|
|
499
|
+
if (!dl) return null;
|
|
500
|
+
const peers = allServersRef.current.map((x) => x.server);
|
|
501
|
+
const res = dl.type === "repo" ? resolveRepoTarget(peers, dl.target) : resolveDevTarget(peers, dl.target);
|
|
502
|
+
if (!res) return null;
|
|
503
|
+
const match = allServersRef.current.find((x) => x.server.peerId === res.peerId);
|
|
504
|
+
return match ? { ...match, folder: res.folder } : null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Reconcile the live connection to the current URL. Drives Back/Forward nav and
|
|
508
|
+
// auto-reconnect: a workspace deep link connects to (or reconnects to) the
|
|
509
|
+
// server it resolves to; the list URL ("/") drops the connection. Reads only
|
|
510
|
+
// refs/window, so it's safe to call from the once-at-mount popstate handler.
|
|
511
|
+
function syncToUrl() {
|
|
512
|
+
const dl = parseDeepLink(window.location.pathname);
|
|
513
|
+
if (!dl) {
|
|
514
|
+
if (activePeerRef.current) teardownConn();
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (dialingRef.current) return; // a dial is already in flight
|
|
518
|
+
const target = findServerForDeepLink(dl);
|
|
519
|
+
if (!target) return; // its server isn't present (yet) — wait for it to appear
|
|
520
|
+
if (activePeerRef.current === target.server.peerId && connStateRef.current === "connected") return;
|
|
521
|
+
void connectTo(target.server, target.room, target.folder, true);
|
|
453
522
|
}
|
|
454
523
|
|
|
455
524
|
function disconnect() {
|
|
@@ -468,6 +537,10 @@ export function Discovery() {
|
|
|
468
537
|
const allServers: RoomedServer[] = tokens.flatMap((t) =>
|
|
469
538
|
(serversByRoom[t] ?? []).map((server) => ({ server, room: t })),
|
|
470
539
|
);
|
|
540
|
+
// Mirror the latest merged servers + connection state into refs so the URL
|
|
541
|
+
// reconciler (called from event handlers/timers) never reads a stale closure.
|
|
542
|
+
allServersRef.current = allServers;
|
|
543
|
+
connStateRef.current = connState;
|
|
471
544
|
const serverCount = allServers.length;
|
|
472
545
|
const onlineRooms = tokens.filter((t) => roomOpen[t]).length;
|
|
473
546
|
const activeServer = allServers.find((x) => x.server.peerId === activePeerId)?.server;
|