codehost 0.11.1 → 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 +7 -0
- package/package.json +1 -1
- package/src/shared/repo.ts +12 -3
- package/src/web/discovery.tsx +133 -60
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
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
|
+
|
|
1
8
|
## [0.11.1](https://github.com/snomiao/codehost/compare/v0.11.0...v0.11.1) (2026-06-09)
|
|
2
9
|
|
|
3
10
|
|
package/package.json
CHANGED
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
|
+
}
|
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;
|