codehost 0.20.5 → 0.22.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.
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
- import type { AgentInfo, PeerInfo, WorkspaceInfo } from "../shared/signaling";
2
+ import { type AgentInfo, type PeerInfo, type WorkspaceInfo, CLIENT_WIRE_ROLE, isClientRole } from "../shared/signaling";
3
3
  import { TOKEN_REQUIREMENTS, validateToken } from "../shared/token";
4
4
  import { SignalingClient } from "../shared/signaling-client";
5
5
  import type { RtcSignal } from "../shared/rtc";
@@ -26,11 +26,43 @@ import { deriveTags, matchQuery, shortRoomLabel, tagKey } from "../shared/tags";
26
26
 
27
27
  const TOKEN_KEY = "codehost.token";
28
28
 
29
- type ConnState = "idle" | "connecting" | "provisioning" | "connected" | "failed";
29
+ type ConnState = "idle" | "connecting" | "pending" | "provisioning" | "connected" | "failed" | "denied";
30
30
 
31
31
  /** A server discovered in a specific room (its token routes the signaling). */
32
32
  type RoomedServer = { server: PeerInfo; room: string };
33
33
 
34
+ /**
35
+ * A short "Browser · OS" label this page advertises in the room roster, so the
36
+ * host and other clients can tell devices apart (and spot a stranger that
37
+ * shouldn't have the token). Best-effort UA sniff; falls back to "browser".
38
+ */
39
+ function clientLabel(): string {
40
+ const ua = navigator.userAgent;
41
+ const browser = /Edg\//.test(ua) ? "Edge"
42
+ : /OPR\//.test(ua) ? "Opera"
43
+ : /Firefox\//.test(ua) ? "Firefox"
44
+ : /Chrome\//.test(ua) ? "Chrome"
45
+ : /Safari\//.test(ua) ? "Safari"
46
+ : "browser";
47
+ const os = /Mac OS X/.test(ua) ? "macOS"
48
+ : /Windows/.test(ua) ? "Windows"
49
+ : /Android/.test(ua) ? "Android"
50
+ : /(iPhone|iPad|iPod)/.test(ua) ? "iOS"
51
+ : /Linux/.test(ua) ? "Linux"
52
+ : "";
53
+ return os ? `${browser} · ${os}` : browser;
54
+ }
55
+
56
+ /** Coarse "Ns/Nm/Nh" from a worker-clock join time and the room's clock. */
57
+ function relTime(since?: number, now?: number): string | null {
58
+ if (!since || !now) return null;
59
+ const secs = Math.max(0, Math.round((now - since) / 1000));
60
+ if (secs < 60) return `${secs}s`;
61
+ const mins = Math.round(secs / 60);
62
+ if (mins < 60) return `${mins}m`;
63
+ return `${Math.round(mins / 60)}h`;
64
+ }
65
+
34
66
  /**
35
67
  * Read a room token handed in the URL fragment as `#t=<token>` (what the CLI
36
68
  * prints/opens after `setup`/`serve`). The page is static, so the fragment
@@ -98,7 +130,7 @@ function findRoomForDeepLink(dl: DeepLink, tokens: string[], timeoutMs = 6000):
98
130
  const client = new SignalingClient({
99
131
  url: getSignalUrl(),
100
132
  token: tok,
101
- role: "viewer",
133
+ role: CLIENT_WIRE_ROLE,
102
134
  onPeers: (peers) => {
103
135
  const servers = peers.filter((p) => p.role === "server");
104
136
  const res =
@@ -124,7 +156,9 @@ function findRoomForDeepLink(dl: DeepLink, tokens: string[], timeoutMs = 6000):
124
156
  */
125
157
  function RoomClient(props: {
126
158
  token: string;
159
+ label: string;
127
160
  onPeers: (peers: PeerInfo[]) => void;
161
+ onRoster: (clients: PeerInfo[], now?: number) => void;
128
162
  onStatus: (open: boolean) => void;
129
163
  onSignal: (from: string, data: unknown) => void;
130
164
  registerSender: (send: ((to: string, data: unknown) => void) | null) => void;
@@ -133,15 +167,23 @@ function RoomClient(props: {
133
167
  // not on every parent re-render (which would needlessly churn the WebSocket).
134
168
  const cb = useRef(props);
135
169
  cb.current = props;
136
- const { token } = props;
170
+ const { token, label } = props;
137
171
  useEffect(() => {
138
172
  const client = new SignalingClient({
139
173
  url: getSignalUrl(),
140
174
  token,
141
- role: "viewer",
175
+ // Connecting role. CLIENT_WIRE_ROLE is still the legacy "viewer" during the
176
+ // accept-both transition; servers match it via isClientRole either way.
177
+ role: CLIENT_WIRE_ROLE,
178
+ // Advertise a label so this tab shows up named in the room roster.
179
+ meta: { name: label },
142
180
  onOpen: () => cb.current.onStatus(true),
143
181
  onClose: () => cb.current.onStatus(false),
144
- onPeers: (peers) => cb.current.onPeers(peers.filter((p) => p.role === "server")),
182
+ onPeers: (peers, now) => {
183
+ cb.current.onPeers(peers.filter((p) => p.role === "server"));
184
+ // Other clients in the room (not us) — surfaced as the roster.
185
+ cb.current.onRoster(peers.filter((p) => isClientRole(p.role) && p.peerId !== client.peerId), now);
186
+ },
145
187
  onSignal: (from, data) => cb.current.onSignal(from, data),
146
188
  });
147
189
  cb.current.registerSender((to, data) => client.sendSignal(to, data));
@@ -154,6 +196,50 @@ function RoomClient(props: {
154
196
  return null;
155
197
  }
156
198
 
199
+ /** A copy-to-clipboard command row: label, the command, and a Copy button. */
200
+ function CopyCommand({ label, command }: { label: string; command: string }) {
201
+ const [copied, setCopied] = useState(false);
202
+ const copy = async () => {
203
+ try {
204
+ await navigator.clipboard.writeText(command);
205
+ } catch {
206
+ // clipboard blocked (insecure context / permission) — fall back to prompt
207
+ window.prompt("Copy this command:", command);
208
+ }
209
+ setCopied(true);
210
+ setTimeout(() => setCopied(false), 1500);
211
+ };
212
+ return (
213
+ <div style={styles.cmdRow}>
214
+ <span style={styles.cmdLabel}>{label}</span>
215
+ <code style={styles.cmdCode}>{command}</code>
216
+ <button style={styles.cmdCopy} onClick={copy}>
217
+ {copied ? "Copied!" : "Copy"}
218
+ </button>
219
+ </div>
220
+ );
221
+ }
222
+
223
+ /**
224
+ * "Set up a machine" card: the one-liner that turns any machine into a codehost
225
+ * server. The script bootstraps everything (Bun, the CLI, VS Code, the daemon),
226
+ * so the user needs no prerequisites — not even Bun. setup.sh/.ps1 are aliases
227
+ * of install.* served by Pages (see public/_redirects).
228
+ */
229
+ function SetupCard() {
230
+ return (
231
+ <div style={styles.setupCard}>
232
+ <div style={styles.setupHead}>Set up a machine</div>
233
+ <p style={styles.setupSub}>
234
+ Run this on a machine to serve it here. It installs everything — Bun, VS Code, and the
235
+ codehost daemon — no prerequisites, and it picks a token and opens the browser for you.
236
+ </p>
237
+ <CopyCommand label="macOS / Linux" command="curl -fsSL https://codehost.dev/setup.sh | sh" />
238
+ <CopyCommand label="Windows" command={'powershell -c "irm codehost.dev/setup.ps1 | iex"'} />
239
+ </div>
240
+ );
241
+ }
242
+
157
243
  export function Discovery() {
158
244
  // Joined rooms — each token *is* a room id, and we keep one live signaling
159
245
  // client per room (see RoomClient). Seeded from the persisted room list plus
@@ -170,6 +256,12 @@ export function Discovery() {
170
256
  // Per-room discovery state, merged into one workspace list below.
171
257
  const [serversByRoom, setServersByRoom] = useState<Record<string, PeerInfo[]>>({});
172
258
  const [roomOpen, setRoomOpen] = useState<Record<string, boolean>>({});
259
+ // Other clients (browsers) per room, for the "In this room" roster, plus the
260
+ // worker clock from the latest peers message for relative join times.
261
+ const [clientsByRoom, setClientsByRoom] = useState<Record<string, PeerInfo[]>>({});
262
+ const [roomNow, setRoomNow] = useState<number | undefined>(undefined);
263
+ // This tab's roster label, computed once.
264
+ const labelRef = useRef(clientLabel());
173
265
 
174
266
  // Token input = "join another room": validated, then appended to the set.
175
267
  // Never pre-filled with a saved token — it's a bearer secret.
@@ -204,6 +296,12 @@ export function Discovery() {
204
296
  const activePeerRef = useRef<string | null>(null);
205
297
  const activeRoomRef = useRef<string | null>(null);
206
298
  const sendersRef = useRef<Map<string, (to: string, data: unknown) => void>>(new Map());
299
+ // Admission control: the host can hold ("pending") or reject ("denied") us.
300
+ // `deniedRef` stops a trailing pc state change from overwriting the denied UI;
301
+ // the timer/reject refs let a control signal extend or abort the dial attempt.
302
+ const deniedRef = useRef(false);
303
+ const dialTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
304
+ const dialRejectRef = useRef<((e: Error) => void) | null>(null);
207
305
  // Whether the live connection pushed a history entry (so Disconnect/Back can
208
306
  // pop it back to the list).
209
307
  const pushedRef = useRef(false);
@@ -387,6 +485,7 @@ export function Discovery() {
387
485
  const send = sendersRef.current.get(room);
388
486
  if (!send) return;
389
487
  dialingRef.current = true; // synchronous gate against concurrent triggers
488
+ deniedRef.current = false;
390
489
  let didPush = false;
391
490
  try {
392
491
  // Clear any prior connection's broker state first: after an RTC drop the
@@ -435,23 +534,25 @@ export function Discovery() {
435
534
  const rtc = new RtcClient({
436
535
  sendSignal: (data: RtcSignal) => send(server.peerId, data),
437
536
  onState: (state) => {
438
- if (state === "failed" || state === "disconnected") setConnState("failed");
537
+ if ((state === "failed" || state === "disconnected") && !deniedRef.current) setConnState("failed");
439
538
  },
440
539
  onOpen: (channel) => {
441
- clearTimeout(timer);
540
+ if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
442
541
  resolve({ channel, bulk: rtc.bulkChannel });
443
542
  },
444
543
  onClose: () => setConnState((s) => (s === "connected" ? "idle" : s)),
445
544
  });
446
545
  rtcRef.current = rtc;
546
+ dialRejectRef.current = reject;
447
547
  // Don't hang forever dialing a peer that never answers (e.g. a stale
448
- // server still listed in the room): fail the attempt after 15s.
449
- const timer = setTimeout(() => {
548
+ // server still listed in the room): fail the attempt after 15s. A
549
+ // "pending" admission signal swaps this for a longer approval window.
550
+ dialTimerRef.current = setTimeout(() => {
450
551
  rtc.close();
451
552
  reject(new Error("connection timed out"));
452
553
  }, 15000);
453
554
  rtc.start().catch((err) => {
454
- clearTimeout(timer);
555
+ if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
455
556
  reject(err);
456
557
  });
457
558
  });
@@ -495,7 +596,7 @@ export function Discovery() {
495
596
  setResolving(null);
496
597
  recordConnect(server, room, openFolder);
497
598
  } catch {
498
- setConnState("failed");
599
+ setConnState(deniedRef.current ? "denied" : "failed");
499
600
  // Undo the optimistic history entry we pushed. revertingRef makes the
500
601
  // resulting popstate a no-op so the "failed" card stays on the list.
501
602
  if (didPush) {
@@ -717,6 +818,12 @@ export function Discovery() {
717
818
  const onlineRooms = tokens.filter((t) => roomOpen[t]).length;
718
819
  const activeServer = allServers.find((x) => x.server.peerId === activePeerId)?.server;
719
820
 
821
+ // Other clients (browsers) across all joined rooms, deduped by peerId — the
822
+ // "In this room" roster, so you can spot a device that shouldn't hold a token.
823
+ const otherClients = Object.values(clientsByRoom)
824
+ .flat()
825
+ .filter((c, i, all) => all.findIndex((x) => x.peerId === c.peerId) === i);
826
+
720
827
  // Annotate each server with its mnemonic fake-tags (incl. its room label), then
721
828
  // filter. The room token is hashed to a short label — never rendered raw.
722
829
  const tagged = allServers.map(({ server: s, room }) => ({
@@ -764,10 +871,39 @@ export function Discovery() {
764
871
  <RoomClient
765
872
  key={t}
766
873
  token={t}
874
+ label={labelRef.current}
767
875
  onPeers={(peers) => setServersByRoom((m) => ({ ...m, [t]: peers }))}
876
+ onRoster={(clients, now) => {
877
+ setClientsByRoom((m) => ({ ...m, [t]: clients }));
878
+ if (now) setRoomNow(now);
879
+ }}
768
880
  onStatus={(open) => setRoomOpen((m) => ({ ...m, [t]: open }))}
769
881
  onSignal={(from, data) => {
770
- if (from === activePeerRef.current) void rtcRef.current?.handleSignal(data);
882
+ if (from !== activePeerRef.current) return;
883
+ const kind = (data as { kind?: string } | null)?.kind;
884
+ if (kind === "pending") {
885
+ // Host is reviewing us — show "waiting" and stop the short dial timer
886
+ // from failing the attempt while a human decides.
887
+ setConnState("pending");
888
+ if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
889
+ dialTimerRef.current = setTimeout(() => {
890
+ rtcRef.current?.close();
891
+ dialRejectRef.current?.(new Error("approval timed out"));
892
+ }, 120000);
893
+ return;
894
+ }
895
+ if (kind === "denied") {
896
+ // Denied while dialing, or kicked after connecting — set state directly
897
+ // so it covers both (the dial promise may already be settled).
898
+ deniedRef.current = true;
899
+ if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
900
+ rtcRef.current?.close();
901
+ setIframeSrc(null);
902
+ setConnState("denied");
903
+ dialRejectRef.current?.(new Error("host denied the connection"));
904
+ return;
905
+ }
906
+ void rtcRef.current?.handleSignal(data);
771
907
  }}
772
908
  registerSender={(send) => {
773
909
  if (send) sendersRef.current.set(t, send);
@@ -978,12 +1114,10 @@ export function Discovery() {
978
1114
  </span>
979
1115
  )}
980
1116
  </div>
981
- {tokens.length === 0 && <p style={styles.dim}>Join a room to see your workspaces.</p>}
982
1117
  {tokens.length > 0 && serverCount === 0 && (
983
- <p style={styles.dim}>
984
- No servers online. Run <code style={styles.code}>bunx codehost serve -t &lt;token&gt;</code> on a machine.
985
- </p>
1118
+ <p style={styles.dim}>No servers online in your rooms yet.</p>
986
1119
  )}
1120
+ {serverCount === 0 && <SetupCard />}
987
1121
  {serverCount > 0 && (
988
1122
  <>
989
1123
  <input
@@ -1078,18 +1212,20 @@ export function Discovery() {
1078
1212
  )}
1079
1213
  <div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
1080
1214
  {isActive && (
1081
- <div style={styles.echo}>
1215
+ <div style={connState === "denied" ? styles.echoBad : styles.echo}>
1082
1216
  {connState === "connecting" && "negotiating WebRTC…"}
1217
+ {connState === "pending" && "waiting for the host to approve you…"}
1083
1218
  {connState === "failed" && "connection failed"}
1219
+ {connState === "denied" && "the host denied this connection"}
1084
1220
  </div>
1085
1221
  )}
1086
1222
  </div>
1087
1223
  <button
1088
1224
  style={styles.connectBtn}
1089
1225
  onClick={() => connectTo(s, room)}
1090
- disabled={isActive && connState === "connecting"}
1226
+ disabled={isActive && (connState === "connecting" || connState === "pending")}
1091
1227
  >
1092
- {isActive && connState === "connecting" ? "…" : "Connect"}
1228
+ {isActive && (connState === "connecting" || connState === "pending") ? "…" : "Connect"}
1093
1229
  </button>
1094
1230
  </li>
1095
1231
  );
@@ -1101,6 +1237,40 @@ export function Discovery() {
1101
1237
  <p style={styles.dim}>No workspace matches your filter.</p>
1102
1238
  )}
1103
1239
  </div>
1240
+
1241
+ {tokens.length > 0 && (
1242
+ <section style={styles.rosterSection}>
1243
+ <div style={styles.rosterHead}>
1244
+ In this room · you{otherClients.length > 0 ? ` + ${otherClients.length} other ${otherClients.length === 1 ? "client" : "clients"}` : ""}
1245
+ </div>
1246
+ <ul style={styles.list}>
1247
+ <li style={styles.personRow}>
1248
+ <span style={styles.personDot}>●</span>
1249
+ <span style={styles.personName}>{labelRef.current}</span>
1250
+ <span style={styles.dim}>you</span>
1251
+ </li>
1252
+ {otherClients.map((c) => {
1253
+ const age = relTime(c.since, roomNow);
1254
+ return (
1255
+ <li key={c.peerId} style={styles.personRow}>
1256
+ <span style={{ ...styles.personDot, color: "#dcb67a" }}>●</span>
1257
+ <span style={styles.personName}>{c.meta?.name ?? c.peerId.slice(0, 8)}</span>
1258
+ {age && <span style={styles.dim}>connected {age} ago</span>}
1259
+ </li>
1260
+ );
1261
+ })}
1262
+ </ul>
1263
+ <p style={styles.rosterHint}>
1264
+ Anyone holding a room's token can appear here and gets a full VS Code session
1265
+ (terminal + file write). See a device you don't recognize? Rotate the token with{" "}
1266
+ <code style={styles.code}>codehost setup --new-token</code>.
1267
+ </p>
1268
+ </section>
1269
+ )}
1270
+
1271
+ {/* When servers already exist the empty-state card is hidden, so keep an
1272
+ "add another machine" affordance available down here. */}
1273
+ {serverCount > 0 && <SetupCard />}
1104
1274
  </main>
1105
1275
  </div>
1106
1276
  </>
@@ -1185,6 +1355,20 @@ const styles: Record<string, React.CSSProperties> = {
1185
1355
  cardSub: { display: "flex", gap: 12, fontSize: 12, color: "#888", marginTop: 2 },
1186
1356
  cwd: { fontFamily: "monospace" },
1187
1357
  echo: { marginTop: 6, fontSize: 12, color: "#4ec9b0", fontFamily: "monospace" },
1358
+ echoBad: { marginTop: 6, fontSize: 12, color: "#f48771", fontFamily: "monospace" },
1359
+ rosterSection: { marginTop: 28 },
1360
+ rosterHead: { fontSize: 14, color: "#aaa", fontWeight: 600, margin: "0 0 12px" },
1361
+ setupCard: { marginTop: 20, background: "#252525", border: "1px solid #3d3d3d", borderRadius: 8, padding: "16px 18px" },
1362
+ setupHead: { fontSize: 15, color: "#fff", fontWeight: 600, marginBottom: 6 },
1363
+ setupSub: { fontSize: 13, color: "#aaa", margin: "0 0 14px", lineHeight: 1.5 },
1364
+ cmdRow: { display: "flex", alignItems: "center", gap: 10, marginTop: 8 },
1365
+ cmdLabel: { fontSize: 11, color: "#888", width: 88, flexShrink: 0 },
1366
+ cmdCode: { flex: 1, minWidth: 0, background: "#1b1b1b", border: "1px solid #3d3d3d", borderRadius: 6, padding: "8px 10px", fontFamily: "monospace", fontSize: 12.5, color: "#dcdcaa", overflow: "auto", whiteSpace: "nowrap" },
1367
+ cmdCopy: { flexShrink: 0, background: "#0e639c", border: "none", color: "#fff", padding: "8px 12px", borderRadius: 6, cursor: "pointer", fontSize: 12 },
1368
+ rosterHint: { margin: "10px 0 0", fontSize: 12, color: "#888" },
1369
+ personRow: { display: "flex", alignItems: "center", gap: 10, background: "#252525", border: "1px solid #3d3d3d", borderRadius: 8, padding: "8px 14px", fontSize: 13 },
1370
+ personDot: { color: "#4ec9b0", fontSize: 10 },
1371
+ personName: { color: "#eee", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
1188
1372
  connectBtn: { background: "#0e639c", border: "none", color: "#fff", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
1189
1373
  shareBtn: { background: "transparent", border: "1px solid #3d3d3d", color: "#ccc", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
1190
1374
  provLog: { flex: 1, margin: 0, padding: "14px 18px", overflow: "auto", background: "#1e1e1e", color: "#ccc", fontFamily: "monospace", fontSize: 12.5, lineHeight: 1.5, whiteSpace: "pre-wrap" },
@@ -1,5 +1,5 @@
1
1
  import { SignalingClient } from "../shared/signaling-client";
2
- import type { PeerInfo } from "../shared/signaling";
2
+ import { type PeerInfo, CLIENT_WIRE_ROLE } from "../shared/signaling";
3
3
  import type { RtcSignal } from "../shared/rtc";
4
4
  import { RtcClient } from "./rtc-client";
5
5
  import { TunnelClient } from "./tunnel-client";
@@ -44,7 +44,7 @@ export class CodehostRoom {
44
44
  this.signaling = new SignalingClient({
45
45
  url: opts.signalUrl ?? DEFAULT_SIGNAL_URL,
46
46
  token: opts.token,
47
- role: "viewer",
47
+ role: CLIENT_WIRE_ROLE,
48
48
  onOpen: () => opts.onStatus?.(true),
49
49
  onClose: () => opts.onStatus?.(false),
50
50
  onPeers: (peers) => {
package/worker/room.ts CHANGED
@@ -10,6 +10,8 @@ interface Attachment {
10
10
  peerId: string;
11
11
  role: Role;
12
12
  meta: PeerMeta | null;
13
+ /** Wall-clock ms when this socket joined (sent `hello`); for the room roster. */
14
+ since: number;
13
15
  /** Wall-clock ms of the last message from this socket (hello / ping / signal). */
14
16
  lastSeen: number;
15
17
  }
@@ -57,11 +59,13 @@ export class Room implements DurableObject {
57
59
  }
58
60
 
59
61
  if (msg.type === "hello") {
62
+ const now = Date.now();
60
63
  const att: Attachment = {
61
64
  peerId: msg.peerId,
62
65
  role: msg.role,
63
66
  meta: msg.meta ?? null,
64
- lastSeen: Date.now(),
67
+ since: now,
68
+ lastSeen: now,
65
69
  };
66
70
  ws.serializeAttachment(att);
67
71
  this.send(ws, { type: "welcome", peerId: msg.peerId });
@@ -165,13 +169,13 @@ export class Room implements DurableObject {
165
169
  const peers: PeerInfo[] = [];
166
170
  for (const ws of this.state.getWebSockets()) {
167
171
  const att = this.attachment(ws);
168
- if (att) peers.push({ peerId: att.peerId, role: att.role, meta: att.meta });
172
+ if (att) peers.push({ peerId: att.peerId, role: att.role, meta: att.meta, since: att.since });
169
173
  }
170
174
  return peers;
171
175
  }
172
176
 
173
177
  private broadcastPeers(): void {
174
- const message: ServerMessage = { type: "peers", peers: this.peerList() };
178
+ const message: ServerMessage = { type: "peers", peers: this.peerList(), now: Date.now() };
175
179
  const payload = JSON.stringify(message);
176
180
  for (const ws of this.state.getWebSockets()) {
177
181
  try {