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.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/cli/approver.ts +212 -0
- package/src/cli/commands/dev.ts +19 -0
- package/src/cli/commands/open.ts +95 -0
- package/src/cli/commands/serve.ts +19 -0
- package/src/cli/commands/setup.ts +17 -1
- package/src/cli/daemonize.ts +8 -0
- package/src/cli/index.ts +2 -0
- package/src/cli/rtc-daemon.ts +70 -30
- package/src/cli/run-server.ts +49 -4
- package/src/shared/repo.ts +3 -3
- package/src/shared/rtc.ts +21 -1
- package/src/shared/signaling-client.ts +2 -2
- package/src/shared/signaling.ts +39 -7
- package/src/web/conn-broker.ts +5 -4
- package/src/web/discovery.tsx +149 -18
- package/src/web/room-client.ts +3 -3
- package/src/web/rtc-client.ts +20 -2
- package/src/web/tunnel-client.ts +31 -6
- package/worker/room.ts +7 -3
package/src/web/discovery.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import type
|
|
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:
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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" },
|
package/src/web/room-client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SignalingClient } from "../shared/signaling-client";
|
|
2
|
-
import type
|
|
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:
|
|
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) => {
|
package/src/web/rtc-client.ts
CHANGED
|
@@ -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
|
|
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 {
|
package/src/web/tunnel-client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
196
|
+
for (const part of chunk(body)) this.sendOn(lane, encodeFrame(Op.HttpReqBody, streamId, part));
|
|
177
197
|
}
|
|
178
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|