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/cli/rtc-daemon.ts
CHANGED
|
@@ -69,27 +69,39 @@ function nativeLoadError(cause: unknown): Error {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
export interface RtcDaemonOptions {
|
|
72
|
-
/** Relay a signal to a
|
|
72
|
+
/** Relay a signal to a client peer via the signaling channel. */
|
|
73
73
|
sendSignal: (to: string, data: RtcSignal) => void;
|
|
74
|
-
/** Called when a
|
|
75
|
-
onChannel: (
|
|
74
|
+
/** Called when a client's data channel opens. */
|
|
75
|
+
onChannel: (clientId: string, channel: DataChannel) => void;
|
|
76
|
+
/**
|
|
77
|
+
* Admission gate. Resolve true to answer the client's offer, false to deny.
|
|
78
|
+
* Until it resolves we buffer the answer + local ICE so a pending client never
|
|
79
|
+
* completes a connection. Omitted → admit everyone (default).
|
|
80
|
+
*/
|
|
81
|
+
admit?: (clientId: string) => Promise<boolean>;
|
|
82
|
+
/** Called once when a client's connection is torn down (drop / deny / kick). */
|
|
83
|
+
onClose?: (clientId: string) => void;
|
|
76
84
|
}
|
|
77
85
|
|
|
78
|
-
interface
|
|
86
|
+
interface ClientConn {
|
|
79
87
|
pc: PeerConnectionT;
|
|
88
|
+
/** True once the host has admitted this client; gates outbound answer/ICE. */
|
|
89
|
+
admitted: boolean;
|
|
90
|
+
/** Outbound signals withheld while admission is pending. */
|
|
91
|
+
buffered: RtcSignal[];
|
|
80
92
|
}
|
|
81
93
|
|
|
82
94
|
/**
|
|
83
|
-
* Daemon-side WebRTC manager. The browser (
|
|
84
|
-
*
|
|
95
|
+
* Daemon-side WebRTC manager. The browser (client) is the offerer; for each
|
|
96
|
+
* client that sends an offer we create an answering PeerConnection and surface
|
|
85
97
|
* its data channel. STUN-only.
|
|
86
98
|
*/
|
|
87
99
|
export class RtcDaemon {
|
|
88
|
-
private
|
|
100
|
+
private clients = new Map<string, ClientConn>();
|
|
89
101
|
|
|
90
102
|
constructor(private opts: RtcDaemonOptions) {}
|
|
91
103
|
|
|
92
|
-
/** Route an inbound signaling payload from a
|
|
104
|
+
/** Route an inbound signaling payload from a client. */
|
|
93
105
|
handleSignal(from: string, data: unknown): void {
|
|
94
106
|
const sig = data as RtcSignal;
|
|
95
107
|
if (!sig || typeof sig !== "object") return;
|
|
@@ -97,7 +109,7 @@ export class RtcDaemon {
|
|
|
97
109
|
if (sig.kind === "offer") {
|
|
98
110
|
this.acceptOffer(from, sig.sdp);
|
|
99
111
|
} else if (sig.kind === "candidate") {
|
|
100
|
-
const conn = this.
|
|
112
|
+
const conn = this.clients.get(from);
|
|
101
113
|
if (conn) {
|
|
102
114
|
try {
|
|
103
115
|
conn.pc.addRemoteCandidate(sig.candidate, sig.mid);
|
|
@@ -108,60 +120,88 @@ export class RtcDaemon {
|
|
|
108
120
|
}
|
|
109
121
|
}
|
|
110
122
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
this.
|
|
123
|
+
/** Close a specific client's connection (host kick). */
|
|
124
|
+
close(clientId: string): void {
|
|
125
|
+
this.dropClient(clientId);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private acceptOffer(clientId: string, sdp: string): void {
|
|
129
|
+
// Replace any prior connection for this client (e.g. page reload).
|
|
130
|
+
this.dropClient(clientId);
|
|
114
131
|
|
|
115
|
-
const pc = new ndc.PeerConnection(`
|
|
132
|
+
const pc = new ndc.PeerConnection(`client-${clientId.slice(0, 8)}`, {
|
|
116
133
|
iceServers: ICE_SERVERS,
|
|
117
134
|
});
|
|
118
|
-
|
|
135
|
+
const conn: ClientConn = { pc, admitted: false, buffered: [] };
|
|
136
|
+
this.clients.set(clientId, conn);
|
|
137
|
+
|
|
138
|
+
// Withhold the answer + our ICE until the host admits this client; we still
|
|
139
|
+
// accept the offer + remote candidates so ICE is ready to flush instantly.
|
|
140
|
+
const emit = (sig: RtcSignal) => {
|
|
141
|
+
if (conn.admitted) this.opts.sendSignal(clientId, sig);
|
|
142
|
+
else conn.buffered.push(sig);
|
|
143
|
+
};
|
|
119
144
|
|
|
120
145
|
pc.onLocalDescription((localSdp, type) => {
|
|
121
|
-
|
|
122
|
-
kind: type as "answer",
|
|
123
|
-
type: type as "answer",
|
|
124
|
-
sdp: localSdp,
|
|
125
|
-
});
|
|
146
|
+
emit({ kind: type as "answer", type: type as "answer", sdp: localSdp });
|
|
126
147
|
});
|
|
127
148
|
|
|
128
149
|
pc.onLocalCandidate((candidate, mid) => {
|
|
129
|
-
|
|
150
|
+
emit({ kind: "candidate", candidate, mid });
|
|
130
151
|
});
|
|
131
152
|
|
|
132
153
|
pc.onStateChange((state) => {
|
|
133
|
-
console.log(`[rtc] ${
|
|
154
|
+
console.log(`[rtc] ${clientId.slice(0, 8)} state: ${state}`);
|
|
134
155
|
if (state === "disconnected" || state === "failed" || state === "closed") {
|
|
135
|
-
this.
|
|
156
|
+
this.dropClient(clientId);
|
|
136
157
|
}
|
|
137
158
|
});
|
|
138
159
|
|
|
139
160
|
pc.onDataChannel((dc) => {
|
|
140
|
-
console.log(`[rtc] ${
|
|
141
|
-
this.opts.onChannel(
|
|
161
|
+
console.log(`[rtc] ${clientId.slice(0, 8)} channel "${dc.getLabel()}" open`);
|
|
162
|
+
this.opts.onChannel(clientId, dc);
|
|
142
163
|
});
|
|
143
164
|
|
|
144
165
|
try {
|
|
145
166
|
pc.setRemoteDescription(sdp, "offer");
|
|
146
167
|
} catch (err) {
|
|
147
|
-
console.error(`[rtc] setRemoteDescription failed for ${
|
|
148
|
-
this.
|
|
168
|
+
console.error(`[rtc] setRemoteDescription failed for ${clientId.slice(0, 8)}:`, err);
|
|
169
|
+
this.dropClient(clientId);
|
|
170
|
+
return;
|
|
149
171
|
}
|
|
172
|
+
|
|
173
|
+
const admit = this.opts.admit ? this.opts.admit(clientId) : Promise.resolve(true);
|
|
174
|
+
admit
|
|
175
|
+
.then((ok) => {
|
|
176
|
+
// The client may have reloaded/dropped while we waited — only act if
|
|
177
|
+
// this exact connection is still current.
|
|
178
|
+
if (this.clients.get(clientId) !== conn) return;
|
|
179
|
+
if (!ok) {
|
|
180
|
+
this.opts.sendSignal(clientId, { kind: "denied" });
|
|
181
|
+
this.dropClient(clientId);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
conn.admitted = true;
|
|
185
|
+
for (const sig of conn.buffered) this.opts.sendSignal(clientId, sig);
|
|
186
|
+
conn.buffered = [];
|
|
187
|
+
})
|
|
188
|
+
.catch(() => this.dropClient(clientId));
|
|
150
189
|
}
|
|
151
190
|
|
|
152
|
-
private
|
|
153
|
-
const conn = this.
|
|
191
|
+
private dropClient(clientId: string): void {
|
|
192
|
+
const conn = this.clients.get(clientId);
|
|
154
193
|
if (!conn) return;
|
|
155
|
-
this.
|
|
194
|
+
this.clients.delete(clientId);
|
|
156
195
|
try {
|
|
157
196
|
conn.pc.close();
|
|
158
197
|
} catch {
|
|
159
198
|
// ignore
|
|
160
199
|
}
|
|
200
|
+
this.opts.onClose?.(clientId);
|
|
161
201
|
}
|
|
162
202
|
|
|
163
203
|
closeAll(): void {
|
|
164
|
-
for (const id of [...this.
|
|
204
|
+
for (const id of [...this.clients.keys()]) this.dropClient(id);
|
|
165
205
|
try {
|
|
166
206
|
ndc.cleanup();
|
|
167
207
|
} catch {
|
package/src/cli/run-server.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { watch } from "node:fs";
|
|
2
2
|
import { basename, dirname } from "node:path";
|
|
3
|
-
import { type PeerMeta, newPeerId } from "../shared/signaling";
|
|
3
|
+
import { type PeerMeta, isClientRole, newPeerId } from "../shared/signaling";
|
|
4
4
|
import { SignalingClient } from "../shared/signaling-client";
|
|
5
|
+
import { Approver, type ApprovePolicy } from "./approver";
|
|
5
6
|
import { RtcDaemon } from "./rtc-daemon";
|
|
6
7
|
import { type LocalRequest, Tunnel } from "./tunnel";
|
|
7
8
|
import { handleProvision, isProvisionPath, type ProvisionDeps } from "./provision-server";
|
|
@@ -46,6 +47,10 @@ export interface RunServerOptions {
|
|
|
46
47
|
* host workspace registry). Watched via their parent directory so the file
|
|
47
48
|
* may not exist yet. */
|
|
48
49
|
watchFiles?: string[];
|
|
50
|
+
/** Client admission policy (default "auto"). */
|
|
51
|
+
approve?: ApprovePolicy;
|
|
52
|
+
/** Label substrings auto-approved under the "confirm" policy. */
|
|
53
|
+
allow?: string[];
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
/** How often a daemon re-enumerates its workspaces (manual clones show up). */
|
|
@@ -53,7 +58,7 @@ const META_REFRESH_MS = 60_000;
|
|
|
53
58
|
|
|
54
59
|
/**
|
|
55
60
|
* Foreground server loop shared by `serve`, `dev`, and `expose`: register in the
|
|
56
|
-
* signaling room with the given meta and bridge each
|
|
61
|
+
* signaling room with the given meta and bridge each client's data channel to a
|
|
57
62
|
* local server (VS Code for serve/dev, an arbitrary port for expose). Never
|
|
58
63
|
* resolves.
|
|
59
64
|
*/
|
|
@@ -68,6 +73,23 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
|
|
|
68
73
|
const target = await opts.launch(basePath);
|
|
69
74
|
|
|
70
75
|
let rtc: RtcDaemon;
|
|
76
|
+
|
|
77
|
+
// Track client labels from the room roster so approval prompts and the bridge
|
|
78
|
+
// log name *who* connected (a leaked-token tell), not just an opaque peerId.
|
|
79
|
+
const clientNames = new Map<string, string>();
|
|
80
|
+
const labelFor = (clientId: string) => clientNames.get(clientId) ?? "unknown client";
|
|
81
|
+
|
|
82
|
+
const approver = new Approver({
|
|
83
|
+
policy: opts.approve ?? "auto",
|
|
84
|
+
allow: opts.allow ?? [],
|
|
85
|
+
kick: (clientId) => {
|
|
86
|
+
// Tell the client why it's being cut off, then tear down the connection.
|
|
87
|
+
client.sendSignal(clientId, { kind: "denied" });
|
|
88
|
+
rtc.close(clientId);
|
|
89
|
+
},
|
|
90
|
+
notifyPending: (clientId) => client.sendSignal(clientId, { kind: "pending" }),
|
|
91
|
+
});
|
|
92
|
+
|
|
71
93
|
const client = new SignalingClient({
|
|
72
94
|
url: opts.signal,
|
|
73
95
|
token: opts.token,
|
|
@@ -82,6 +104,12 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
|
|
|
82
104
|
const detail = info ? ` (code ${info.code}${info.reason ? ` "${info.reason}"` : ""}, up ${info.ms}ms)` : "";
|
|
83
105
|
console.log(`[codehost] disconnected from signaling${detail}, reconnecting…`);
|
|
84
106
|
},
|
|
107
|
+
onPeers: (peers) => {
|
|
108
|
+
clientNames.clear();
|
|
109
|
+
for (const p of peers) {
|
|
110
|
+
if (isClientRole(p.role) && p.meta?.name) clientNames.set(p.peerId, p.meta.name);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
85
113
|
onSignal: (from, data) => rtc.handleSignal(from, data),
|
|
86
114
|
});
|
|
87
115
|
|
|
@@ -107,14 +135,30 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
|
|
|
107
135
|
}
|
|
108
136
|
: undefined;
|
|
109
137
|
|
|
138
|
+
// A client opens two data channels (interactive + bulk), so onChannel fires
|
|
139
|
+
// twice per connection — log/register "connected" only on the first.
|
|
140
|
+
const bridged = new Set<string>();
|
|
110
141
|
rtc = new RtcDaemon({
|
|
111
142
|
sendSignal: (to, data) => client.sendSignal(to, data),
|
|
112
|
-
|
|
113
|
-
|
|
143
|
+
admit: (clientId) => approver.admit(clientId, labelFor(clientId)),
|
|
144
|
+
onChannel: (clientId, channel) => {
|
|
145
|
+
if (!bridged.has(clientId)) {
|
|
146
|
+
bridged.add(clientId);
|
|
147
|
+
const who = labelFor(clientId);
|
|
148
|
+
console.log(`[codehost] ${who} (${clientId.slice(0, 8)}) connected; bridging to :${target.port}`);
|
|
149
|
+
approver.onConnected(clientId, who);
|
|
150
|
+
}
|
|
114
151
|
new Tunnel(channel, target.port, target.stripBasePath, onLocal);
|
|
115
152
|
},
|
|
153
|
+
onClose: (clientId) => {
|
|
154
|
+
bridged.delete(clientId);
|
|
155
|
+
approver.onDisconnected(clientId);
|
|
156
|
+
},
|
|
116
157
|
});
|
|
117
158
|
|
|
159
|
+
approver.banner();
|
|
160
|
+
approver.start();
|
|
161
|
+
|
|
118
162
|
client.connect();
|
|
119
163
|
if (opts.refreshMeta) {
|
|
120
164
|
setInterval(refreshMeta, opts.metaRefreshMs ?? META_REFRESH_MS);
|
|
@@ -144,6 +188,7 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
|
|
|
144
188
|
|
|
145
189
|
const shutdown = () => {
|
|
146
190
|
console.log("\n[codehost] shutting down");
|
|
191
|
+
approver.stop();
|
|
147
192
|
rtc.closeAll();
|
|
148
193
|
client.close();
|
|
149
194
|
target.stop?.();
|
package/src/shared/repo.ts
CHANGED
|
@@ -219,7 +219,7 @@ export function resolveRepoTarget(
|
|
|
219
219
|
.sort(
|
|
220
220
|
(a, b) =>
|
|
221
221
|
Number(prefers(b.meta, prefer)) - Number(prefers(a.meta, prefer)) ||
|
|
222
|
-
(b.meta?.cwd
|
|
222
|
+
(b.meta?.cwd?.length ?? 0) - (a.meta?.cwd?.length ?? 0),
|
|
223
223
|
);
|
|
224
224
|
|
|
225
225
|
// A root that *enumerated* a matching checkout knows it exists on disk —
|
|
@@ -233,7 +233,7 @@ export function resolveRepoTarget(
|
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
const root = roots[0];
|
|
236
|
-
if (root && root.meta) {
|
|
236
|
+
if (root && root.meta?.cwd) {
|
|
237
237
|
const folder = `${trimSlash(root.meta.cwd)}/${fillLayout(root.meta.layout || DEFAULT_LAYOUT, target)}`;
|
|
238
238
|
return { peerId: root.peerId, folder };
|
|
239
239
|
}
|
|
@@ -250,7 +250,7 @@ export function resolveRepoTarget(
|
|
|
250
250
|
export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolution | null {
|
|
251
251
|
const want = stripEnds(target.path);
|
|
252
252
|
const hostOk = (meta: PeerMeta) => !target.host || meta.host === target.host;
|
|
253
|
-
const hit = servers.find((s) => s.meta && stripEnds(s.meta.cwd) === want && hostOk(s.meta));
|
|
253
|
+
const hit = servers.find((s) => s.meta?.cwd && stripEnds(s.meta.cwd) === want && hostOk(s.meta));
|
|
254
254
|
if (hit) return { peerId: hit.peerId };
|
|
255
255
|
for (const s of servers) {
|
|
256
256
|
if (!s.meta || !hostOk(s.meta)) continue;
|
package/src/shared/rtc.ts
CHANGED
|
@@ -20,7 +20,27 @@ export interface CandidateSignal {
|
|
|
20
20
|
mid: string;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Host-side admission control, relayed opaquely like any other signal. The
|
|
25
|
+
* daemon sends "pending" while a client awaits the host's approval and "denied"
|
|
26
|
+
* if the host rejects (or kicks) it — so the client can show why it's waiting or
|
|
27
|
+
* was cut off, instead of a silent hang. Approval needs no signal: the daemon
|
|
28
|
+
* just answers the offer. Old daemons never send these; old clients ignore an
|
|
29
|
+
* unknown `kind`, so this is backward compatible both ways.
|
|
30
|
+
*/
|
|
31
|
+
export interface ControlSignal {
|
|
32
|
+
kind: "pending" | "denied";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type RtcSignal = SdpSignal | CandidateSignal | ControlSignal;
|
|
24
36
|
|
|
25
37
|
/** Label used for the control/tunnel data channel. */
|
|
26
38
|
export const CHANNEL_LABEL = "codehost";
|
|
39
|
+
/**
|
|
40
|
+
* Second data channel for bulk HTTP bodies. Separate channel = separate SCTP
|
|
41
|
+
* stream, so multi-MB asset downloads no longer head-of-line block the
|
|
42
|
+
* interactive WS traffic (VS Code remote protocol, terminal) on
|
|
43
|
+
* CHANNEL_LABEL. The daemon spins up one Tunnel per incoming channel, so old
|
|
44
|
+
* daemons handle this unmodified; old browsers simply never open it.
|
|
45
|
+
*/
|
|
46
|
+
export const BULK_CHANNEL_LABEL = "codehost-bulk";
|
|
@@ -14,7 +14,7 @@ export interface SignalingClientOptions {
|
|
|
14
14
|
role: Role;
|
|
15
15
|
meta?: PeerMeta;
|
|
16
16
|
peerId?: string;
|
|
17
|
-
onPeers?: (peers: PeerInfo[]) => void;
|
|
17
|
+
onPeers?: (peers: PeerInfo[], now?: number) => void;
|
|
18
18
|
onSignal?: (from: string, data: unknown) => void;
|
|
19
19
|
onOpen?: () => void;
|
|
20
20
|
/** Called on every socket close. `info` carries the WebSocket close code,
|
|
@@ -192,7 +192,7 @@ export class SignalingClient {
|
|
|
192
192
|
} catch {
|
|
193
193
|
return;
|
|
194
194
|
}
|
|
195
|
-
if (msg.type === "peers") this.opts.onPeers?.(msg.peers);
|
|
195
|
+
if (msg.type === "peers") this.opts.onPeers?.(msg.peers, msg.now);
|
|
196
196
|
else if (msg.type === "signal") this.opts.onSignal?.(msg.from, msg.data);
|
|
197
197
|
};
|
|
198
198
|
|
package/src/shared/signaling.ts
CHANGED
|
@@ -2,7 +2,26 @@
|
|
|
2
2
|
// Worker / Durable Object. A "room" is keyed by the user's token; every member
|
|
3
3
|
// of a room can see the others and exchange WebRTC SDP/ICE via the relay.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Room roles. A connecting browser is a "client"; "viewer" is the legacy wire
|
|
7
|
+
* value for the same role, kept so older daemons/pages still interop (the term
|
|
8
|
+
* understated the access — a connected client gets the host's full VS Code:
|
|
9
|
+
* terminal + file write). Backward-compat plan ("accept both, emit old"):
|
|
10
|
+
* receivers treat client and viewer alike via `isClientRole`, and new code
|
|
11
|
+
* still EMITS the legacy `CLIENT_WIRE_ROLE` ("viewer") until daemons have rolled
|
|
12
|
+
* forward; a later release flips the emit to "client".
|
|
13
|
+
*/
|
|
14
|
+
export type Role = "server" | "client" | "viewer";
|
|
15
|
+
|
|
16
|
+
/** The connecting-role value new code emits. Still the legacy "viewer" during
|
|
17
|
+
* the accept-both transition; flip to "client" once daemons recognize it. */
|
|
18
|
+
export const CLIENT_WIRE_ROLE: Role = "viewer";
|
|
19
|
+
|
|
20
|
+
/** True for either spelling of the connecting (browser) role. Use everywhere a
|
|
21
|
+
* receiver decides "is this peer a client" so both old and new peers match. */
|
|
22
|
+
export function isClientRole(role: Role): boolean {
|
|
23
|
+
return role === "client" || role === "viewer";
|
|
24
|
+
}
|
|
6
25
|
|
|
7
26
|
/** One live agent CLI session on the daemon's machine (sourced from agent-yes's
|
|
8
27
|
* registry) — advertised so clients can see which agents run where. Interact
|
|
@@ -36,14 +55,19 @@ export interface WorkspaceInfo {
|
|
|
36
55
|
config?: boolean;
|
|
37
56
|
}
|
|
38
57
|
|
|
39
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Metadata a room member advertises. Servers (`codehost serve`/`dev`) fill the
|
|
60
|
+
* workspace fields; a client (the codehost.dev page) sends just `name` as its
|
|
61
|
+
* roster label and leaves the server-only fields unset — hence everything but
|
|
62
|
+
* `name` is optional.
|
|
63
|
+
*/
|
|
40
64
|
export interface PeerMeta {
|
|
41
|
-
/** Human label
|
|
65
|
+
/** Human label. Server: defaults to hostname. Client: a browser/OS label. */
|
|
42
66
|
name: string;
|
|
43
|
-
/**
|
|
44
|
-
cwd
|
|
45
|
-
/**
|
|
46
|
-
host
|
|
67
|
+
/** Server only: directory the VS Code instance is serving (repo dir or root). */
|
|
68
|
+
cwd?: string;
|
|
69
|
+
/** Server only: hostname of the machine running the daemon. */
|
|
70
|
+
host?: string;
|
|
47
71
|
/**
|
|
48
72
|
* Stable machine identity (UUID persisted in ~/.codehost/config.json). All
|
|
49
73
|
* daemons on one machine share it, unlike the per-process peerId, so clients
|
|
@@ -84,6 +108,10 @@ export interface PeerInfo {
|
|
|
84
108
|
peerId: string;
|
|
85
109
|
role: Role;
|
|
86
110
|
meta: PeerMeta | null;
|
|
111
|
+
/** Worker-stamped join time (ms). Compare against `PeersMessage.now`, which
|
|
112
|
+
* is on the same clock, for a roster "connected N ago" without clock skew.
|
|
113
|
+
* Absent from older workers. */
|
|
114
|
+
since?: number;
|
|
87
115
|
}
|
|
88
116
|
|
|
89
117
|
// ---- Client -> Server ----
|
|
@@ -133,6 +161,10 @@ export interface WelcomeMessage {
|
|
|
133
161
|
export interface PeersMessage {
|
|
134
162
|
type: "peers";
|
|
135
163
|
peers: PeerInfo[];
|
|
164
|
+
/** Worker wall-clock (ms) at send time — same clock as `PeerInfo.since`, so a
|
|
165
|
+
* client renders relative join ages without trusting its own clock. Absent
|
|
166
|
+
* from older workers. */
|
|
167
|
+
now?: number;
|
|
136
168
|
}
|
|
137
169
|
|
|
138
170
|
/** A signal relayed from another peer. */
|
package/src/web/conn-broker.ts
CHANGED
|
@@ -15,9 +15,10 @@ import { TunnelClient, type TunnelLike, type TunnelWsHandlers, type TunnelWsHand
|
|
|
15
15
|
|
|
16
16
|
type AnyMsg = Record<string, any>;
|
|
17
17
|
|
|
18
|
-
/** Creates the RTCPeerConnection for a peer and resolves with its open
|
|
18
|
+
/** Creates the RTCPeerConnection for a peer and resolves with its open
|
|
19
|
+
* interactive channel plus the (possibly still-connecting) bulk lane.
|
|
19
20
|
* Provided by the UI (discovery.tsx) and invoked only when this tab is owner. */
|
|
20
|
-
export type Establish = () => Promise<RTCDataChannel>;
|
|
21
|
+
export type Establish = () => Promise<{ channel: RTCDataChannel; bulk: RTCDataChannel | null }>;
|
|
21
22
|
|
|
22
23
|
class ConnBroker {
|
|
23
24
|
private port: MessagePort | null = null;
|
|
@@ -110,8 +111,8 @@ class ConnBroker {
|
|
|
110
111
|
if (!establish) return;
|
|
111
112
|
this.establishing.add(peerId);
|
|
112
113
|
try {
|
|
113
|
-
const channel = await establish();
|
|
114
|
-
this.locals.set(peerId, new TunnelClient(channel));
|
|
114
|
+
const { channel, bulk } = await establish();
|
|
115
|
+
this.locals.set(peerId, new TunnelClient(channel, bulk));
|
|
115
116
|
this.post({ t: "ready", peerId });
|
|
116
117
|
this.resolveReady(peerId);
|
|
117
118
|
} catch (err) {
|