codehost 0.20.4 → 0.21.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));
@@ -170,6 +212,12 @@ export function Discovery() {
170
212
  // Per-room discovery state, merged into one workspace list below.
171
213
  const [serversByRoom, setServersByRoom] = useState<Record<string, PeerInfo[]>>({});
172
214
  const [roomOpen, setRoomOpen] = useState<Record<string, boolean>>({});
215
+ // Other clients (browsers) per room, for the "In this room" roster, plus the
216
+ // worker clock from the latest peers message for relative join times.
217
+ const [clientsByRoom, setClientsByRoom] = useState<Record<string, PeerInfo[]>>({});
218
+ const [roomNow, setRoomNow] = useState<number | undefined>(undefined);
219
+ // This tab's roster label, computed once.
220
+ const labelRef = useRef(clientLabel());
173
221
 
174
222
  // Token input = "join another room": validated, then appended to the set.
175
223
  // Never pre-filled with a saved token — it's a bearer secret.
@@ -204,6 +252,12 @@ export function Discovery() {
204
252
  const activePeerRef = useRef<string | null>(null);
205
253
  const activeRoomRef = useRef<string | null>(null);
206
254
  const sendersRef = useRef<Map<string, (to: string, data: unknown) => void>>(new Map());
255
+ // Admission control: the host can hold ("pending") or reject ("denied") us.
256
+ // `deniedRef` stops a trailing pc state change from overwriting the denied UI;
257
+ // the timer/reject refs let a control signal extend or abort the dial attempt.
258
+ const deniedRef = useRef(false);
259
+ const dialTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
260
+ const dialRejectRef = useRef<((e: Error) => void) | null>(null);
207
261
  // Whether the live connection pushed a history entry (so Disconnect/Back can
208
262
  // pop it back to the list).
209
263
  const pushedRef = useRef(false);
@@ -387,6 +441,7 @@ export function Discovery() {
387
441
  const send = sendersRef.current.get(room);
388
442
  if (!send) return;
389
443
  dialingRef.current = true; // synchronous gate against concurrent triggers
444
+ deniedRef.current = false;
390
445
  let didPush = false;
391
446
  try {
392
447
  // Clear any prior connection's broker state first: after an RTC drop the
@@ -431,27 +486,29 @@ export function Discovery() {
431
486
  // only invoked when we're the owner (or get promoted on failover); other
432
487
  // tabs reuse the owner's channel via a proxy, so they never open WebRTC.
433
488
  const establish = () =>
434
- new Promise<RTCDataChannel>((resolve, reject) => {
489
+ new Promise<{ channel: RTCDataChannel; bulk: RTCDataChannel | null }>((resolve, reject) => {
435
490
  const rtc = new RtcClient({
436
491
  sendSignal: (data: RtcSignal) => send(server.peerId, data),
437
492
  onState: (state) => {
438
- if (state === "failed" || state === "disconnected") setConnState("failed");
493
+ if ((state === "failed" || state === "disconnected") && !deniedRef.current) setConnState("failed");
439
494
  },
440
495
  onOpen: (channel) => {
441
- clearTimeout(timer);
442
- resolve(channel);
496
+ if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
497
+ resolve({ channel, bulk: rtc.bulkChannel });
443
498
  },
444
499
  onClose: () => setConnState((s) => (s === "connected" ? "idle" : s)),
445
500
  });
446
501
  rtcRef.current = rtc;
502
+ dialRejectRef.current = reject;
447
503
  // 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(() => {
504
+ // server still listed in the room): fail the attempt after 15s. A
505
+ // "pending" admission signal swaps this for a longer approval window.
506
+ dialTimerRef.current = setTimeout(() => {
450
507
  rtc.close();
451
508
  reject(new Error("connection timed out"));
452
509
  }, 15000);
453
510
  rtc.start().catch((err) => {
454
- clearTimeout(timer);
511
+ if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
455
512
  reject(err);
456
513
  });
457
514
  });
@@ -495,7 +552,7 @@ export function Discovery() {
495
552
  setResolving(null);
496
553
  recordConnect(server, room, openFolder);
497
554
  } catch {
498
- setConnState("failed");
555
+ setConnState(deniedRef.current ? "denied" : "failed");
499
556
  // Undo the optimistic history entry we pushed. revertingRef makes the
500
557
  // resulting popstate a no-op so the "failed" card stays on the list.
501
558
  if (didPush) {
@@ -717,6 +774,12 @@ export function Discovery() {
717
774
  const onlineRooms = tokens.filter((t) => roomOpen[t]).length;
718
775
  const activeServer = allServers.find((x) => x.server.peerId === activePeerId)?.server;
719
776
 
777
+ // Other clients (browsers) across all joined rooms, deduped by peerId — the
778
+ // "In this room" roster, so you can spot a device that shouldn't hold a token.
779
+ const otherClients = Object.values(clientsByRoom)
780
+ .flat()
781
+ .filter((c, i, all) => all.findIndex((x) => x.peerId === c.peerId) === i);
782
+
720
783
  // Annotate each server with its mnemonic fake-tags (incl. its room label), then
721
784
  // filter. The room token is hashed to a short label — never rendered raw.
722
785
  const tagged = allServers.map(({ server: s, room }) => ({
@@ -764,10 +827,39 @@ export function Discovery() {
764
827
  <RoomClient
765
828
  key={t}
766
829
  token={t}
830
+ label={labelRef.current}
767
831
  onPeers={(peers) => setServersByRoom((m) => ({ ...m, [t]: peers }))}
832
+ onRoster={(clients, now) => {
833
+ setClientsByRoom((m) => ({ ...m, [t]: clients }));
834
+ if (now) setRoomNow(now);
835
+ }}
768
836
  onStatus={(open) => setRoomOpen((m) => ({ ...m, [t]: open }))}
769
837
  onSignal={(from, data) => {
770
- if (from === activePeerRef.current) void rtcRef.current?.handleSignal(data);
838
+ if (from !== activePeerRef.current) return;
839
+ const kind = (data as { kind?: string } | null)?.kind;
840
+ if (kind === "pending") {
841
+ // Host is reviewing us — show "waiting" and stop the short dial timer
842
+ // from failing the attempt while a human decides.
843
+ setConnState("pending");
844
+ if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
845
+ dialTimerRef.current = setTimeout(() => {
846
+ rtcRef.current?.close();
847
+ dialRejectRef.current?.(new Error("approval timed out"));
848
+ }, 120000);
849
+ return;
850
+ }
851
+ if (kind === "denied") {
852
+ // Denied while dialing, or kicked after connecting — set state directly
853
+ // so it covers both (the dial promise may already be settled).
854
+ deniedRef.current = true;
855
+ if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
856
+ rtcRef.current?.close();
857
+ setIframeSrc(null);
858
+ setConnState("denied");
859
+ dialRejectRef.current?.(new Error("host denied the connection"));
860
+ return;
861
+ }
862
+ void rtcRef.current?.handleSignal(data);
771
863
  }}
772
864
  registerSender={(send) => {
773
865
  if (send) sendersRef.current.set(t, send);
@@ -1078,18 +1170,20 @@ export function Discovery() {
1078
1170
  )}
1079
1171
  <div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
1080
1172
  {isActive && (
1081
- <div style={styles.echo}>
1173
+ <div style={connState === "denied" ? styles.echoBad : styles.echo}>
1082
1174
  {connState === "connecting" && "negotiating WebRTC…"}
1175
+ {connState === "pending" && "waiting for the host to approve you…"}
1083
1176
  {connState === "failed" && "connection failed"}
1177
+ {connState === "denied" && "the host denied this connection"}
1084
1178
  </div>
1085
1179
  )}
1086
1180
  </div>
1087
1181
  <button
1088
1182
  style={styles.connectBtn}
1089
1183
  onClick={() => connectTo(s, room)}
1090
- disabled={isActive && connState === "connecting"}
1184
+ disabled={isActive && (connState === "connecting" || connState === "pending")}
1091
1185
  >
1092
- {isActive && connState === "connecting" ? "…" : "Connect"}
1186
+ {isActive && (connState === "connecting" || connState === "pending") ? "…" : "Connect"}
1093
1187
  </button>
1094
1188
  </li>
1095
1189
  );
@@ -1101,6 +1195,36 @@ export function Discovery() {
1101
1195
  <p style={styles.dim}>No workspace matches your filter.</p>
1102
1196
  )}
1103
1197
  </div>
1198
+
1199
+ {tokens.length > 0 && (
1200
+ <section style={styles.rosterSection}>
1201
+ <div style={styles.rosterHead}>
1202
+ In this room · you{otherClients.length > 0 ? ` + ${otherClients.length} other ${otherClients.length === 1 ? "client" : "clients"}` : ""}
1203
+ </div>
1204
+ <ul style={styles.list}>
1205
+ <li style={styles.personRow}>
1206
+ <span style={styles.personDot}>●</span>
1207
+ <span style={styles.personName}>{labelRef.current}</span>
1208
+ <span style={styles.dim}>you</span>
1209
+ </li>
1210
+ {otherClients.map((c) => {
1211
+ const age = relTime(c.since, roomNow);
1212
+ return (
1213
+ <li key={c.peerId} style={styles.personRow}>
1214
+ <span style={{ ...styles.personDot, color: "#dcb67a" }}>●</span>
1215
+ <span style={styles.personName}>{c.meta?.name ?? c.peerId.slice(0, 8)}</span>
1216
+ {age && <span style={styles.dim}>connected {age} ago</span>}
1217
+ </li>
1218
+ );
1219
+ })}
1220
+ </ul>
1221
+ <p style={styles.rosterHint}>
1222
+ Anyone holding a room's token can appear here and gets a full VS Code session
1223
+ (terminal + file write). See a device you don't recognize? Rotate the token with{" "}
1224
+ <code style={styles.code}>codehost setup --new-token</code>.
1225
+ </p>
1226
+ </section>
1227
+ )}
1104
1228
  </main>
1105
1229
  </div>
1106
1230
  </>
@@ -1185,6 +1309,13 @@ const styles: Record<string, React.CSSProperties> = {
1185
1309
  cardSub: { display: "flex", gap: 12, fontSize: 12, color: "#888", marginTop: 2 },
1186
1310
  cwd: { fontFamily: "monospace" },
1187
1311
  echo: { marginTop: 6, fontSize: 12, color: "#4ec9b0", fontFamily: "monospace" },
1312
+ echoBad: { marginTop: 6, fontSize: 12, color: "#f48771", fontFamily: "monospace" },
1313
+ rosterSection: { marginTop: 28 },
1314
+ rosterHead: { fontSize: 14, color: "#aaa", fontWeight: 600, margin: "0 0 12px" },
1315
+ rosterHint: { margin: "10px 0 0", fontSize: 12, color: "#888" },
1316
+ personRow: { display: "flex", alignItems: "center", gap: 10, background: "#252525", border: "1px solid #3d3d3d", borderRadius: 8, padding: "8px 14px", fontSize: 13 },
1317
+ personDot: { color: "#4ec9b0", fontSize: 10 },
1318
+ personName: { color: "#eee", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
1188
1319
  connectBtn: { background: "#0e639c", border: "none", color: "#fff", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
1189
1320
  shareBtn: { background: "transparent", border: "1px solid #3d3d3d", color: "#ccc", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
1190
1321
  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) => {
@@ -91,7 +91,7 @@ export class CodehostRoom {
91
91
  onOpen: (channel) => {
92
92
  clearTimeout(timer);
93
93
  this.dialFailedAt.delete(peerId);
94
- resolve(new TunnelClient(channel));
94
+ resolve(new TunnelClient(channel, rtc.bulkChannel));
95
95
  },
96
96
  onClose: drop,
97
97
  onState: (state) => {
@@ -1,4 +1,4 @@
1
- import { CHANNEL_LABEL, ICE_SERVERS, type RtcSignal } from "../shared/rtc";
1
+ import { BULK_CHANNEL_LABEL, CHANNEL_LABEL, ICE_SERVERS, type RtcSignal } from "../shared/rtc";
2
2
 
3
3
  export interface RtcClientOptions {
4
4
  /** Relay a signal to the server peer via the signaling channel. */
@@ -15,6 +15,7 @@ export interface RtcClientOptions {
15
15
  export class RtcClient {
16
16
  private pc: RTCPeerConnection;
17
17
  private channel: RTCDataChannel | null = null;
18
+ private bulk: RTCDataChannel | null = null;
18
19
 
19
20
  constructor(private opts: RtcClientOptions) {
20
21
  this.pc = new RTCPeerConnection({
@@ -36,7 +37,7 @@ export class RtcClient {
36
37
  };
37
38
  }
38
39
 
39
- /** Create the data channel + offer and kick off the handshake. */
40
+ /** Create the data channels + offer and kick off the handshake. */
40
41
  async start(): Promise<void> {
41
42
  const channel = this.pc.createDataChannel(CHANNEL_LABEL, { ordered: true });
42
43
  channel.binaryType = "arraybuffer";
@@ -44,6 +45,13 @@ export class RtcClient {
44
45
  channel.onopen = () => this.opts.onOpen?.(channel);
45
46
  channel.onclose = () => this.opts.onClose?.();
46
47
 
48
+ // Bulk lane for HTTP bodies (its own SCTP stream — no HOL with the
49
+ // interactive channel above). Senders fall back to the interactive channel
50
+ // until it opens, so nothing waits on it.
51
+ const bulk = this.pc.createDataChannel(BULK_CHANNEL_LABEL, { ordered: true });
52
+ bulk.binaryType = "arraybuffer";
53
+ this.bulk = bulk;
54
+
47
55
  const offer = await this.pc.createOffer();
48
56
  await this.pc.setLocalDescription(offer);
49
57
  this.opts.sendSignal({ kind: "offer", type: "offer", sdp: offer.sdp ?? "" });
@@ -69,6 +77,11 @@ export class RtcClient {
69
77
  return this.channel;
70
78
  }
71
79
 
80
+ /** The bulk lane (may still be CONNECTING when the interactive one opens). */
81
+ get bulkChannel(): RTCDataChannel | null {
82
+ return this.bulk;
83
+ }
84
+
72
85
  /**
73
86
  * Which ICE path the nominated candidate pair uses: "lan" when both ends
74
87
  * are host candidates (same network — traffic never leaves it), "p2p" for a
@@ -111,6 +124,11 @@ export class RtcClient {
111
124
  } catch {
112
125
  // ignore
113
126
  }
127
+ try {
128
+ this.bulk?.close();
129
+ } catch {
130
+ // ignore
131
+ }
114
132
  try {
115
133
  this.pc.close();
116
134
  } catch {
@@ -49,9 +49,24 @@ export class TunnelClient {
49
49
  private wsRx = new WsReassembler(); // reassembles daemon -> browser WS messages
50
50
  private textEncoder = new TextEncoder();
51
51
 
52
- constructor(private channel: RTCDataChannel) {
52
+ /**
53
+ * `channel` carries the interactive traffic (WebSocket frames — VS Code's
54
+ * remote protocol, terminals); `bulk`, when provided and open, carries HTTP
55
+ * request/response streams on its own SCTP stream so multi-MB asset bodies
56
+ * never head-of-line block a keystroke. The daemon runs one Tunnel per
57
+ * channel, so each lane answers on itself and no demuxing is needed beyond
58
+ * listening on both.
59
+ */
60
+ constructor(
61
+ private channel: RTCDataChannel,
62
+ private bulk: RTCDataChannel | null = null,
63
+ ) {
53
64
  channel.binaryType = "arraybuffer";
54
65
  channel.addEventListener("message", (ev) => this.onFrame(ev.data));
66
+ if (bulk) {
67
+ bulk.binaryType = "arraybuffer";
68
+ bulk.addEventListener("message", (ev) => this.onFrame(ev.data));
69
+ }
55
70
  }
56
71
 
57
72
  private allocId(): number {
@@ -171,11 +186,16 @@ export class TunnelClient {
171
186
  },
172
187
  });
173
188
 
174
- this.send(encodeJson(Op.HttpReq, streamId, { method, path, headers: reqHeaders }));
189
+ // HTTP rides the bulk lane. Pinned per request: every frame of this
190
+ // stream must hit the SAME channel — the daemon runs one Tunnel per
191
+ // channel, so a mid-request switch (e.g. bulk finishing its handshake)
192
+ // would strand the stream across two Tunnels.
193
+ const lane = this.bulk?.readyState === "open" ? this.bulk : this.channel;
194
+ this.sendOn(lane, encodeJson(Op.HttpReq, streamId, { method, path, headers: reqHeaders }));
175
195
  if (body && body.byteLength) {
176
- for (const part of chunk(body)) this.send(encodeFrame(Op.HttpReqBody, streamId, part));
196
+ for (const part of chunk(body)) this.sendOn(lane, encodeFrame(Op.HttpReqBody, streamId, part));
177
197
  }
178
- this.send(encodeFrame(Op.HttpReqEnd, streamId));
198
+ this.sendOn(lane, encodeFrame(Op.HttpReqEnd, streamId));
179
199
  });
180
200
  }
181
201
 
@@ -198,12 +218,17 @@ export class TunnelClient {
198
218
  };
199
219
  }
200
220
 
221
+ /** Interactive-channel send (WS frames, control). */
201
222
  private send(frame: Uint8Array): void {
202
- if (this.channel.readyState === "open") {
223
+ this.sendOn(this.channel, frame);
224
+ }
225
+
226
+ private sendOn(ch: RTCDataChannel, frame: Uint8Array): void {
227
+ if (ch.readyState === "open") {
203
228
  // Copy into a fresh ArrayBuffer-backed view to satisfy send()'s typing.
204
229
  const copy = new Uint8Array(frame.byteLength);
205
230
  copy.set(frame);
206
- this.channel.send(copy.buffer);
231
+ ch.send(copy.buffer);
207
232
  }
208
233
  }
209
234
 
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 {