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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 = trimSlash(target.path);
144
- const hit = servers.find((s) => s.meta && trimSlash(s.meta.cwd) === want);
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
+ }
@@ -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), 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).
180
+ // pop it back to the list).
182
181
  const pushedRef = useRef(false);
183
- const viewingRef = useRef(false);
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 / 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.
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 (viewingRef.current) teardownConn();
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
- rtcRef.current?.close();
303
- rtcRef.current = null;
304
- setIframeSrc(null);
305
- setActivePeerId(server.peerId);
306
- activePeerRef.current = server.peerId;
307
- activeRoomRef.current = room;
308
- viewingRef.current = false;
309
- setConnState("connecting");
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
-
321
- // The broker decides whether this tab owns the connection. `establish` is
322
- // only invoked when we're the owner (or get promoted on failover); other
323
- // tabs reuse the owner's channel via a proxy, so they never open WebRTC.
324
- const establish = () =>
325
- new Promise<RTCDataChannel>((resolve, reject) => {
326
- const rtc = new RtcClient({
327
- sendSignal: (data: RtcSignal) => send(server.peerId, data),
328
- onState: (state) => {
329
- if (state === "failed" || state === "disconnected") setConnState("failed");
330
- },
331
- onOpen: (channel) => {
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
- resolve(channel);
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 / URL change. We never started
364
- // viewing, so the popstate handler leaves the "failed" card in place.
365
- if (pushed) {
366
- pushedRef.current = false;
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
- viewingRef.current = false;
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;