codehost 0.1.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/LICENSE +21 -0
- package/README.md +112 -0
- package/index.html +12 -0
- package/package.json +50 -0
- package/portless.json +3 -0
- package/src/App.tsx +93 -0
- package/src/cli/commands/list.ts +11 -0
- package/src/cli/commands/serve.ts +153 -0
- package/src/cli/commands/stop.ts +20 -0
- package/src/cli/index.ts +19 -0
- package/src/cli/oxmgr.ts +64 -0
- package/src/cli/rtc-daemon.ts +115 -0
- package/src/cli/tunnel.ts +233 -0
- package/src/cli/vscode.ts +85 -0
- package/src/components/Terminal.tsx +333 -0
- package/src/main.tsx +12 -0
- package/src/server.ts +69 -0
- package/src/shared/protocol.ts +160 -0
- package/src/shared/rtc.ts +26 -0
- package/src/shared/signaling-client.ts +135 -0
- package/src/shared/signaling.ts +75 -0
- package/src/shared/token.ts +74 -0
- package/src/style.css +13 -0
- package/src/terminal-ws.ts +255 -0
- package/src/web/config.ts +21 -0
- package/src/web/conn-broker.ts +341 -0
- package/src/web/conn-shared-worker.ts +132 -0
- package/src/web/discovery.tsx +267 -0
- package/src/web/rtc-client.ts +84 -0
- package/src/web/sw.ts +136 -0
- package/src/web/tsconfig.sw.json +12 -0
- package/src/web/tunnel-client.ts +190 -0
- package/src/web/tunnel-host.ts +63 -0
- package/src/web/tunnel-websocket.ts +113 -0
- package/tsconfig.json +14 -0
- package/vite.config.ts +17 -0
- package/vite.sw.config.ts +20 -0
- package/worker/index.ts +47 -0
- package/worker/room.ts +179 -0
- package/worker/tsconfig.json +13 -0
- package/worker/wrangler.jsonc +18 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// SharedWorker: coordinates a single WebRTC connection per server (peerId)
|
|
2
|
+
// across every codehost.dev tab of this origin. It owns no media itself
|
|
3
|
+
// (RTCPeerConnection is Window-only) — it just elects one tab as the "owner"
|
|
4
|
+
// that holds the data channel, and relays tunnel RPCs between the other tabs
|
|
5
|
+
// and that owner, with failover when the owner tab goes away.
|
|
6
|
+
//
|
|
7
|
+
// This file is intentionally DOM-free (only MessagePort/MessageEvent) so it
|
|
8
|
+
// type-checks under the app's DOM tsconfig and bundles as a module worker.
|
|
9
|
+
|
|
10
|
+
interface Tab {
|
|
11
|
+
id: number;
|
|
12
|
+
port: MessagePort;
|
|
13
|
+
alive: number; // last heartbeat (ms, from the tab's clock; only compared loosely)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Msg = Record<string, unknown> & { t: string };
|
|
17
|
+
|
|
18
|
+
const tabs = new Map<number, Tab>();
|
|
19
|
+
const owners = new Map<string, number>(); // peerId -> owner tabId
|
|
20
|
+
const subs = new Map<string, Set<number>>(); // peerId -> interested tabIds
|
|
21
|
+
const ready = new Set<string>(); // peerIds whose owner has an open channel
|
|
22
|
+
let nextId = 1;
|
|
23
|
+
|
|
24
|
+
// SharedWorkerGlobalScope isn't in the DOM lib; reach the connect hook via any.
|
|
25
|
+
(self as unknown as { onconnect: (e: MessageEvent) => void }).onconnect = (e) => {
|
|
26
|
+
const port = e.ports[0];
|
|
27
|
+
const tab: Tab = { id: nextId++, port, alive: Date.now() };
|
|
28
|
+
tabs.set(tab.id, tab);
|
|
29
|
+
port.onmessage = (ev: MessageEvent) => onMessage(tab, ev.data as Msg);
|
|
30
|
+
port.start?.();
|
|
31
|
+
send(tab, { t: "welcome", tabId: tab.id });
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function send(tab: Tab | undefined, msg: Msg): void {
|
|
35
|
+
tab?.port.postMessage(msg);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function onMessage(tab: Tab, msg: Msg): void {
|
|
39
|
+
tab.alive = Date.now();
|
|
40
|
+
switch (msg.t) {
|
|
41
|
+
case "ping":
|
|
42
|
+
return;
|
|
43
|
+
case "bye":
|
|
44
|
+
cleanup(tab.id);
|
|
45
|
+
return;
|
|
46
|
+
case "acquire": {
|
|
47
|
+
const peerId = msg.peerId as string;
|
|
48
|
+
(subs.get(peerId) ?? subs.set(peerId, new Set()).get(peerId)!).add(tab.id);
|
|
49
|
+
if (!owners.has(peerId)) {
|
|
50
|
+
owners.set(peerId, tab.id);
|
|
51
|
+
send(tab, { t: "role", peerId, owner: true });
|
|
52
|
+
} else {
|
|
53
|
+
send(tab, { t: "role", peerId, owner: false, ownerTabId: owners.get(peerId) });
|
|
54
|
+
// If the owner is already up, don't make the latecomer wait for the next
|
|
55
|
+
// broadcast — tell it the connection is ready right now.
|
|
56
|
+
if (ready.has(peerId)) send(tab, { t: "ready", peerId });
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
case "ready": {
|
|
61
|
+
// Owner finished establishing — wake everyone waiting on this peer.
|
|
62
|
+
const peerId = msg.peerId as string;
|
|
63
|
+
ready.add(peerId);
|
|
64
|
+
for (const id of subs.get(peerId) ?? []) {
|
|
65
|
+
if (id !== tab.id) send(tabs.get(id), { t: "ready", peerId });
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
case "release": {
|
|
70
|
+
const peerId = msg.peerId as string;
|
|
71
|
+
subs.get(peerId)?.delete(tab.id);
|
|
72
|
+
if (owners.get(peerId) === tab.id) reassign(peerId, tab.id);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
case "rpc": {
|
|
76
|
+
// Route a tunnel call to the current owner of this peer.
|
|
77
|
+
const peerId = msg.peerId as string;
|
|
78
|
+
const owner = tabs.get(owners.get(peerId) ?? -1);
|
|
79
|
+
if (!owner) {
|
|
80
|
+
send(tab, { t: "rpc-reply", peerId, callId: msg.callId, payload: { op: "error", message: "no owner" } });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
send(owner, { t: "rpc", peerId, callId: msg.callId, fromTabId: tab.id, payload: msg.payload });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
case "rpc-reply": {
|
|
87
|
+
// Owner answering a routed call — deliver to the original requester.
|
|
88
|
+
send(tabs.get(msg.toTabId as number), {
|
|
89
|
+
t: "rpc-reply",
|
|
90
|
+
peerId: msg.peerId,
|
|
91
|
+
callId: msg.callId,
|
|
92
|
+
payload: msg.payload,
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function cleanup(tabId: number): void {
|
|
100
|
+
tabs.delete(tabId);
|
|
101
|
+
for (const [peerId, set] of subs) {
|
|
102
|
+
set.delete(tabId);
|
|
103
|
+
if (owners.get(peerId) === tabId) reassign(peerId, tabId);
|
|
104
|
+
if (set.size === 0) {
|
|
105
|
+
subs.delete(peerId);
|
|
106
|
+
owners.delete(peerId);
|
|
107
|
+
ready.delete(peerId);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Promote a remaining subscriber to owner (failover), or drop the peer.
|
|
113
|
+
function reassign(peerId: string, goneTabId: number): void {
|
|
114
|
+
owners.delete(peerId);
|
|
115
|
+
ready.delete(peerId); // new owner must re-establish before anyone proxies
|
|
116
|
+
const candidate = [...(subs.get(peerId) ?? [])].find((id) => id !== goneTabId && tabs.has(id));
|
|
117
|
+
if (candidate == null) return;
|
|
118
|
+
owners.set(peerId, candidate);
|
|
119
|
+
send(tabs.get(candidate), { t: "promoted", peerId });
|
|
120
|
+
for (const id of subs.get(peerId) ?? []) {
|
|
121
|
+
if (id !== candidate) send(tabs.get(id), { t: "owner-gone", peerId });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Evict tabs that stopped heart-beating (covers crashes / killed tabs where no
|
|
126
|
+
// `bye` arrived). Heartbeats land ~every 4s; allow generous slack.
|
|
127
|
+
setInterval(() => {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
for (const [id, tab] of tabs) {
|
|
130
|
+
if (now - tab.alive > 15_000) cleanup(id);
|
|
131
|
+
}
|
|
132
|
+
}, 5_000);
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { PeerInfo } from "../shared/signaling";
|
|
3
|
+
import { TOKEN_REQUIREMENTS, validateToken } from "../shared/token";
|
|
4
|
+
import { SignalingClient } from "../shared/signaling-client";
|
|
5
|
+
import type { RtcSignal } from "../shared/rtc";
|
|
6
|
+
import { RtcClient } from "./rtc-client";
|
|
7
|
+
import { getSignalUrl } from "./config";
|
|
8
|
+
import { registerTunnelHost } from "./tunnel-host";
|
|
9
|
+
import { connBroker } from "./conn-broker";
|
|
10
|
+
|
|
11
|
+
const TOKEN_KEY = "codehost.token";
|
|
12
|
+
|
|
13
|
+
type ConnState = "idle" | "connecting" | "connected" | "failed";
|
|
14
|
+
|
|
15
|
+
export function Discovery() {
|
|
16
|
+
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? "");
|
|
17
|
+
const [draft, setDraft] = useState(token);
|
|
18
|
+
const [tokenError, setTokenError] = useState<string | null>(null);
|
|
19
|
+
const [connected, setConnected] = useState(false);
|
|
20
|
+
const [servers, setServers] = useState<PeerInfo[]>([]);
|
|
21
|
+
|
|
22
|
+
// Active WebRTC connection to one server (Phase 2: echo test).
|
|
23
|
+
const [activePeerId, setActivePeerId] = useState<string | null>(null);
|
|
24
|
+
const [connState, setConnState] = useState<ConnState>("idle");
|
|
25
|
+
|
|
26
|
+
// Once a server's data channel is open we mount its VS Code in an iframe.
|
|
27
|
+
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
|
28
|
+
|
|
29
|
+
const clientRef = useRef<SignalingClient | null>(null);
|
|
30
|
+
const rtcRef = useRef<RtcClient | null>(null);
|
|
31
|
+
const activePeerRef = useRef<string | null>(null);
|
|
32
|
+
|
|
33
|
+
// Register the Service Worker + connection broker once. The broker shares one
|
|
34
|
+
// WebRTC connection per server across tabs; on owner failover it asks us to
|
|
35
|
+
// reload the iframe so it reconnects through the new owner.
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
void registerTunnelHost();
|
|
38
|
+
connBroker.onLost((peerId) => {
|
|
39
|
+
if (peerId !== activePeerRef.current) return;
|
|
40
|
+
setIframeSrc(null);
|
|
41
|
+
setTimeout(() => setIframeSrc(`/vs/${peerId}/`), 400);
|
|
42
|
+
});
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!token) return;
|
|
47
|
+
const client = new SignalingClient({
|
|
48
|
+
url: getSignalUrl(),
|
|
49
|
+
token,
|
|
50
|
+
role: "viewer",
|
|
51
|
+
onOpen: () => setConnected(true),
|
|
52
|
+
onClose: () => setConnected(false),
|
|
53
|
+
onPeers: (peers) => setServers(peers.filter((p) => p.role === "server")),
|
|
54
|
+
onSignal: (from, data) => {
|
|
55
|
+
if (from === activePeerRef.current) void rtcRef.current?.handleSignal(data);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
clientRef.current = client;
|
|
59
|
+
client.connect();
|
|
60
|
+
return () => {
|
|
61
|
+
rtcRef.current?.close();
|
|
62
|
+
rtcRef.current = null;
|
|
63
|
+
if (activePeerRef.current) connBroker.disconnect(activePeerRef.current);
|
|
64
|
+
client.close();
|
|
65
|
+
clientRef.current = null;
|
|
66
|
+
setConnected(false);
|
|
67
|
+
setServers([]);
|
|
68
|
+
setActivePeerId(null);
|
|
69
|
+
activePeerRef.current = null;
|
|
70
|
+
setConnState("idle");
|
|
71
|
+
setIframeSrc(null);
|
|
72
|
+
};
|
|
73
|
+
}, [token]);
|
|
74
|
+
|
|
75
|
+
function applyToken(e: React.FormEvent) {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
const t = draft.trim();
|
|
78
|
+
const check = validateToken(t);
|
|
79
|
+
if (!check.ok) {
|
|
80
|
+
setTokenError(check.reason ?? "invalid token");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
setTokenError(null);
|
|
84
|
+
localStorage.setItem(TOKEN_KEY, t);
|
|
85
|
+
setToken(t);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function connectTo(server: PeerInfo) {
|
|
89
|
+
const client = clientRef.current;
|
|
90
|
+
if (!client) return;
|
|
91
|
+
|
|
92
|
+
rtcRef.current?.close();
|
|
93
|
+
rtcRef.current = null;
|
|
94
|
+
setIframeSrc(null);
|
|
95
|
+
setActivePeerId(server.peerId);
|
|
96
|
+
activePeerRef.current = server.peerId;
|
|
97
|
+
setConnState("connecting");
|
|
98
|
+
|
|
99
|
+
// The broker decides whether this tab owns the connection. `establish` is
|
|
100
|
+
// only invoked when we're the owner (or get promoted on failover); other
|
|
101
|
+
// tabs reuse the owner's channel via a proxy, so they never open WebRTC.
|
|
102
|
+
const establish = () =>
|
|
103
|
+
new Promise<RTCDataChannel>((resolve, reject) => {
|
|
104
|
+
const rtc = new RtcClient({
|
|
105
|
+
sendSignal: (data: RtcSignal) => client.sendSignal(server.peerId, data),
|
|
106
|
+
onState: (state) => {
|
|
107
|
+
if (state === "failed" || state === "disconnected") setConnState("failed");
|
|
108
|
+
},
|
|
109
|
+
onOpen: (channel) => {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
resolve(channel);
|
|
112
|
+
},
|
|
113
|
+
onClose: () => setConnState((s) => (s === "connected" ? "idle" : s)),
|
|
114
|
+
});
|
|
115
|
+
rtcRef.current = rtc;
|
|
116
|
+
// Don't hang forever dialing a peer that never answers (e.g. a stale
|
|
117
|
+
// server still listed in the room): fail the attempt after 15s.
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
rtc.close();
|
|
120
|
+
reject(new Error("connection timed out"));
|
|
121
|
+
}, 15000);
|
|
122
|
+
rtc.start().catch((err) => {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
reject(err);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await connBroker.connect(server.peerId, establish);
|
|
130
|
+
setConnState("connected");
|
|
131
|
+
setIframeSrc(`/vs/${server.peerId}/`);
|
|
132
|
+
} catch {
|
|
133
|
+
setConnState("failed");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function disconnect() {
|
|
138
|
+
rtcRef.current?.close();
|
|
139
|
+
rtcRef.current = null;
|
|
140
|
+
if (activePeerRef.current) connBroker.disconnect(activePeerRef.current);
|
|
141
|
+
setIframeSrc(null);
|
|
142
|
+
setActivePeerId(null);
|
|
143
|
+
activePeerRef.current = null;
|
|
144
|
+
setConnState("idle");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const activeServer = servers.find((s) => s.peerId === activePeerId);
|
|
148
|
+
|
|
149
|
+
// Connected view: VS Code in an iframe, served over the tunnel.
|
|
150
|
+
if (iframeSrc && connState === "connected") {
|
|
151
|
+
return (
|
|
152
|
+
<div style={styles.page}>
|
|
153
|
+
<header style={styles.header}>
|
|
154
|
+
<span style={styles.brand}>codehost</span>
|
|
155
|
+
<span style={styles.dim}>·</span>
|
|
156
|
+
<span style={styles.dim}>{activeServer?.meta?.name ?? activePeerId?.slice(0, 8)}</span>
|
|
157
|
+
{activeServer?.meta?.cwd && <span style={styles.cwd}>{activeServer.meta.cwd}</span>}
|
|
158
|
+
<span style={{ flex: 1 }} />
|
|
159
|
+
<button style={styles.connectBtn} onClick={disconnect}>
|
|
160
|
+
Disconnect
|
|
161
|
+
</button>
|
|
162
|
+
</header>
|
|
163
|
+
<iframe title="VS Code" src={iframeSrc} style={{ flex: 1, border: "none", width: "100%", background: "#1e1e1e" }} />
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div style={styles.page}>
|
|
170
|
+
<header style={styles.header}>
|
|
171
|
+
<span style={styles.brand}>codehost</span>
|
|
172
|
+
<span style={styles.dim}>·</span>
|
|
173
|
+
<span style={styles.dim}>{getSignalUrl()}</span>
|
|
174
|
+
<span style={{ flex: 1 }} />
|
|
175
|
+
<span style={{ ...styles.status, color: connected ? "#4ec9b0" : "#888" }}>
|
|
176
|
+
{token ? (connected ? "● connected" : "○ connecting…") : "○ no token"}
|
|
177
|
+
</span>
|
|
178
|
+
</header>
|
|
179
|
+
|
|
180
|
+
<main style={styles.main}>
|
|
181
|
+
<form onSubmit={applyToken} style={styles.tokenForm}>
|
|
182
|
+
<label style={styles.label}>Token</label>
|
|
183
|
+
<input
|
|
184
|
+
value={draft}
|
|
185
|
+
onChange={(e) => {
|
|
186
|
+
setDraft(e.target.value);
|
|
187
|
+
if (tokenError) setTokenError(null);
|
|
188
|
+
}}
|
|
189
|
+
placeholder="your room token"
|
|
190
|
+
style={styles.input}
|
|
191
|
+
/>
|
|
192
|
+
<button type="submit" style={styles.button}>
|
|
193
|
+
{token === draft.trim() ? "Reconnect" : "Connect"}
|
|
194
|
+
</button>
|
|
195
|
+
</form>
|
|
196
|
+
{tokenError ? (
|
|
197
|
+
<p style={styles.tokenError}>{tokenError}</p>
|
|
198
|
+
) : (
|
|
199
|
+
<p style={styles.tokenHint}>Token requires {TOKEN_REQUIREMENTS}.</p>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
<h2 style={styles.h2}>VS Code servers</h2>
|
|
203
|
+
{!token && <p style={styles.dim}>Enter a token to see your servers.</p>}
|
|
204
|
+
{token && servers.length === 0 && (
|
|
205
|
+
<p style={styles.dim}>
|
|
206
|
+
No servers online. Run{" "}
|
|
207
|
+
<code style={styles.code}>bunx codehost serve -t {token || "<token>"}</code> on a machine.
|
|
208
|
+
</p>
|
|
209
|
+
)}
|
|
210
|
+
<ul style={styles.list}>
|
|
211
|
+
{servers.map((s) => {
|
|
212
|
+
const isActive = s.peerId === activePeerId;
|
|
213
|
+
return (
|
|
214
|
+
<li key={s.peerId} style={styles.card}>
|
|
215
|
+
<div style={styles.cardMain}>
|
|
216
|
+
<div style={styles.cardName}>{s.meta?.name ?? s.peerId.slice(0, 8)}</div>
|
|
217
|
+
<div style={styles.cardSub}>
|
|
218
|
+
{s.meta?.host && <span>{s.meta.host}</span>}
|
|
219
|
+
{s.meta?.cwd && <span style={styles.cwd}>{s.meta.cwd}</span>}
|
|
220
|
+
</div>
|
|
221
|
+
{isActive && (
|
|
222
|
+
<div style={styles.echo}>
|
|
223
|
+
{connState === "connecting" && "negotiating WebRTC…"}
|
|
224
|
+
{connState === "failed" && "connection failed"}
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
<button
|
|
229
|
+
style={styles.connectBtn}
|
|
230
|
+
onClick={() => connectTo(s)}
|
|
231
|
+
disabled={isActive && connState === "connecting"}
|
|
232
|
+
>
|
|
233
|
+
{isActive && connState === "connecting" ? "…" : "Connect"}
|
|
234
|
+
</button>
|
|
235
|
+
</li>
|
|
236
|
+
);
|
|
237
|
+
})}
|
|
238
|
+
</ul>
|
|
239
|
+
</main>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const styles: Record<string, React.CSSProperties> = {
|
|
245
|
+
page: { display: "flex", flexDirection: "column", height: "100%", background: "#1f1f1f", color: "#ccc", fontFamily: "system-ui, sans-serif" },
|
|
246
|
+
header: { display: "flex", alignItems: "center", gap: 8, padding: "8px 14px", background: "#2d2d2d", borderBottom: "1px solid #3d3d3d", fontSize: 13 },
|
|
247
|
+
brand: { fontFamily: "monospace", fontWeight: 700, color: "#fff" },
|
|
248
|
+
dim: { color: "#888", fontSize: 12 },
|
|
249
|
+
status: { fontSize: 12 },
|
|
250
|
+
main: { flex: 1, overflow: "auto", padding: "20px 24px", maxWidth: 760, width: "100%", margin: "0 auto", boxSizing: "border-box" },
|
|
251
|
+
tokenForm: { display: "flex", alignItems: "center", gap: 8, marginBottom: 8 },
|
|
252
|
+
tokenHint: { margin: "0 0 20px", fontSize: 12, color: "#888" },
|
|
253
|
+
tokenError: { margin: "0 0 20px", fontSize: 12, color: "#f48771" },
|
|
254
|
+
label: { fontSize: 12, color: "#888" },
|
|
255
|
+
input: { flex: 1, background: "#252525", border: "1px solid #3d3d3d", color: "#eee", padding: "8px 10px", borderRadius: 6, fontSize: 13, outline: "none" },
|
|
256
|
+
button: { background: "#0e639c", border: "none", color: "#fff", padding: "8px 16px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
257
|
+
h2: { fontSize: 14, color: "#aaa", fontWeight: 600, margin: "0 0 12px" },
|
|
258
|
+
code: { background: "#252525", padding: "2px 6px", borderRadius: 4, fontFamily: "monospace", fontSize: 12 },
|
|
259
|
+
list: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 },
|
|
260
|
+
card: { display: "flex", alignItems: "center", gap: 12, background: "#252525", border: "1px solid #3d3d3d", borderRadius: 8, padding: "12px 14px" },
|
|
261
|
+
cardMain: { flex: 1, minWidth: 0 },
|
|
262
|
+
cardName: { fontSize: 14, fontWeight: 600, color: "#fff" },
|
|
263
|
+
cardSub: { display: "flex", gap: 12, fontSize: 12, color: "#888", marginTop: 2 },
|
|
264
|
+
cwd: { fontFamily: "monospace" },
|
|
265
|
+
echo: { marginTop: 6, fontSize: 12, color: "#4ec9b0", fontFamily: "monospace" },
|
|
266
|
+
connectBtn: { background: "#0e639c", border: "none", color: "#fff", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
267
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { CHANNEL_LABEL, ICE_SERVERS, type RtcSignal } from "../shared/rtc";
|
|
2
|
+
|
|
3
|
+
export interface RtcClientOptions {
|
|
4
|
+
/** Relay a signal to the server peer via the signaling channel. */
|
|
5
|
+
sendSignal: (data: RtcSignal) => void;
|
|
6
|
+
onOpen?: (channel: RTCDataChannel) => void;
|
|
7
|
+
onClose?: () => void;
|
|
8
|
+
onState?: (state: RTCPeerConnectionState) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Browser-side WebRTC client. The viewer is the offerer: it creates the data
|
|
13
|
+
* channel, makes an offer, and exchanges ICE with the daemon via signaling.
|
|
14
|
+
*/
|
|
15
|
+
export class RtcClient {
|
|
16
|
+
private pc: RTCPeerConnection;
|
|
17
|
+
private channel: RTCDataChannel | null = null;
|
|
18
|
+
|
|
19
|
+
constructor(private opts: RtcClientOptions) {
|
|
20
|
+
this.pc = new RTCPeerConnection({
|
|
21
|
+
iceServers: ICE_SERVERS.map((urls) => ({ urls })),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
this.pc.onicecandidate = (ev) => {
|
|
25
|
+
if (ev.candidate) {
|
|
26
|
+
this.opts.sendSignal({
|
|
27
|
+
kind: "candidate",
|
|
28
|
+
candidate: ev.candidate.candidate,
|
|
29
|
+
mid: ev.candidate.sdpMid ?? "0",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
this.pc.onconnectionstatechange = () => {
|
|
35
|
+
this.opts.onState?.(this.pc.connectionState);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Create the data channel + offer and kick off the handshake. */
|
|
40
|
+
async start(): Promise<void> {
|
|
41
|
+
const channel = this.pc.createDataChannel(CHANNEL_LABEL, { ordered: true });
|
|
42
|
+
channel.binaryType = "arraybuffer";
|
|
43
|
+
this.channel = channel;
|
|
44
|
+
channel.onopen = () => this.opts.onOpen?.(channel);
|
|
45
|
+
channel.onclose = () => this.opts.onClose?.();
|
|
46
|
+
|
|
47
|
+
const offer = await this.pc.createOffer();
|
|
48
|
+
await this.pc.setLocalDescription(offer);
|
|
49
|
+
this.opts.sendSignal({ kind: "offer", type: "offer", sdp: offer.sdp ?? "" });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Handle an inbound signal from the daemon. */
|
|
53
|
+
async handleSignal(data: unknown): Promise<void> {
|
|
54
|
+
const sig = data as RtcSignal;
|
|
55
|
+
if (!sig || typeof sig !== "object") return;
|
|
56
|
+
|
|
57
|
+
if (sig.kind === "answer") {
|
|
58
|
+
await this.pc.setRemoteDescription({ type: "answer", sdp: sig.sdp });
|
|
59
|
+
} else if (sig.kind === "candidate") {
|
|
60
|
+
try {
|
|
61
|
+
await this.pc.addIceCandidate({ candidate: sig.candidate, sdpMid: sig.mid });
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error("[rtc] addIceCandidate failed:", err);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get dataChannel(): RTCDataChannel | null {
|
|
69
|
+
return this.channel;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
close(): void {
|
|
73
|
+
try {
|
|
74
|
+
this.channel?.close();
|
|
75
|
+
} catch {
|
|
76
|
+
// ignore
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
this.pc.close();
|
|
80
|
+
} catch {
|
|
81
|
+
// ignore
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/web/sw.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
// Service Worker: intercepts HTTP requests to /vs/<peerId>/* and forwards them
|
|
3
|
+
// to the controlling page (which owns the WebRTC data channel) over a
|
|
4
|
+
// MessageChannel, streaming the response back. WebSocket connections can't be
|
|
5
|
+
// intercepted here, so we inject a bootstrap script into VS Code's HTML that
|
|
6
|
+
// overrides window.WebSocket inside the iframe (see tunnel-websocket.ts).
|
|
7
|
+
const sw = self as unknown as ServiceWorkerGlobalScope;
|
|
8
|
+
|
|
9
|
+
const VS_PREFIX = /^\/vs\/([^/]+)(\/.*)?$/;
|
|
10
|
+
|
|
11
|
+
sw.addEventListener("install", () => sw.skipWaiting());
|
|
12
|
+
sw.addEventListener("activate", (e) => e.waitUntil(sw.clients.claim()));
|
|
13
|
+
|
|
14
|
+
sw.addEventListener("fetch", (event: FetchEvent) => {
|
|
15
|
+
const url = new URL(event.request.url);
|
|
16
|
+
if (url.origin !== sw.location.origin) return;
|
|
17
|
+
|
|
18
|
+
// Serve the iframe bootstrap from the SW itself (same-origin, CSP 'self').
|
|
19
|
+
if (url.pathname === "/__codehost/bootstrap.js") {
|
|
20
|
+
event.respondWith(bootstrapResponse());
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const m = url.pathname.match(VS_PREFIX);
|
|
25
|
+
if (!m) return; // let the network/Pages handle the discovery app itself
|
|
26
|
+
const peerId = m[1];
|
|
27
|
+
|
|
28
|
+
event.respondWith(proxyOverTunnel(event.request, peerId));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
async function proxyOverTunnel(request: Request, peerId: string): Promise<Response> {
|
|
32
|
+
const client = await pickClient();
|
|
33
|
+
if (!client) return new Response("no codehost page open", { status: 502 });
|
|
34
|
+
|
|
35
|
+
const url = new URL(request.url);
|
|
36
|
+
const headers: Record<string, string> = {};
|
|
37
|
+
request.headers.forEach((v, k) => (headers[k] = v));
|
|
38
|
+
const bodyBuf =
|
|
39
|
+
request.method === "GET" || request.method === "HEAD"
|
|
40
|
+
? undefined
|
|
41
|
+
: new Uint8Array(await request.arrayBuffer());
|
|
42
|
+
|
|
43
|
+
const isDocument = request.mode === "navigate" || request.destination === "document";
|
|
44
|
+
|
|
45
|
+
return new Promise<Response>((resolve) => {
|
|
46
|
+
const mc = new MessageChannel();
|
|
47
|
+
let resolved = false;
|
|
48
|
+
|
|
49
|
+
mc.port1.onmessage = (ev) => {
|
|
50
|
+
const msg = ev.data;
|
|
51
|
+
if (msg.type === "head") {
|
|
52
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
53
|
+
start(controller) {
|
|
54
|
+
mc.port1.onmessage = (e2) => {
|
|
55
|
+
const m2 = e2.data;
|
|
56
|
+
if (m2.type === "body") controller.enqueue(new Uint8Array(m2.chunk));
|
|
57
|
+
else if (m2.type === "end") controller.close();
|
|
58
|
+
else if (m2.type === "error") controller.error(new Error(m2.message));
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Inject the WS-shim bootstrap into VS Code's HTML documents.
|
|
64
|
+
const headers = new Headers(msg.headers);
|
|
65
|
+
const ct = headers.get("content-type") ?? "";
|
|
66
|
+
if (isDocument && ct.includes("text/html")) {
|
|
67
|
+
resolved = true;
|
|
68
|
+
resolve(injectBootstrap(stream, msg, peerId, headers));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
resolved = true;
|
|
72
|
+
resolve(new Response(stream, { status: msg.status, statusText: msg.statusText, headers }));
|
|
73
|
+
} else if (msg.type === "error" && !resolved) {
|
|
74
|
+
resolved = true;
|
|
75
|
+
resolve(new Response(`tunnel error: ${msg.message}`, { status: 502 }));
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
client.postMessage(
|
|
80
|
+
{
|
|
81
|
+
type: "tunnel-fetch",
|
|
82
|
+
peerId,
|
|
83
|
+
method: request.method,
|
|
84
|
+
path: url.pathname + url.search,
|
|
85
|
+
headers,
|
|
86
|
+
body: bodyBuf,
|
|
87
|
+
},
|
|
88
|
+
[mc.port2, ...(bodyBuf ? [bodyBuf.buffer] : [])],
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Strip CSP so our injected same-origin bootstrap can run, and prepend the
|
|
94
|
+
// bootstrap <script> as the first thing in <head>.
|
|
95
|
+
async function injectBootstrap(
|
|
96
|
+
stream: ReadableStream<Uint8Array>,
|
|
97
|
+
head: { status: number; statusText: string },
|
|
98
|
+
peerId: string,
|
|
99
|
+
headers: Headers,
|
|
100
|
+
): Promise<Response> {
|
|
101
|
+
const raw = await new Response(stream).text();
|
|
102
|
+
const tag = `<script src="/__codehost/bootstrap.js" data-peer="${peerId}" data-base="/vs/${peerId}"></script>`;
|
|
103
|
+
const html = raw.includes("<head>") ? raw.replace("<head>", `<head>${tag}`) : tag + raw;
|
|
104
|
+
headers.delete("content-security-policy");
|
|
105
|
+
headers.delete("content-security-policy-report-only");
|
|
106
|
+
headers.set("content-length", String(new TextEncoder().encode(html).byteLength));
|
|
107
|
+
return new Response(html, { status: head.status, statusText: head.statusText, headers });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function pickClient(): Promise<Client | null> {
|
|
111
|
+
const all = await sw.clients.matchAll({ type: "window", includeUncontrolled: true });
|
|
112
|
+
// Prefer the top-level discovery page (not the VS Code iframe).
|
|
113
|
+
const top = all.find((c) => !new URL(c.url).pathname.startsWith("/vs/"));
|
|
114
|
+
return top ?? all[0] ?? null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function bootstrapResponse(): Response {
|
|
118
|
+
// Runs inside the VS Code iframe. The parent page exposes its TunnelClient
|
|
119
|
+
// factory at window.parent.__codehostMakeWS (same-origin), which returns a
|
|
120
|
+
// WebSocket-compatible class bound to the right peer + base path.
|
|
121
|
+
const js = `(() => {
|
|
122
|
+
const el = document.currentScript;
|
|
123
|
+
const base = el && el.getAttribute('data-base');
|
|
124
|
+
const peer = el && el.getAttribute('data-peer');
|
|
125
|
+
try {
|
|
126
|
+
const make = window.parent.__codehostMakeWS;
|
|
127
|
+
if (make && base && peer) {
|
|
128
|
+
const Shim = make(peer, base);
|
|
129
|
+
if (Shim) window.WebSocket = Shim;
|
|
130
|
+
}
|
|
131
|
+
} catch (e) { console.error('[codehost] WS shim install failed', e); }
|
|
132
|
+
})();`;
|
|
133
|
+
return new Response(js, {
|
|
134
|
+
headers: { "content-type": "text/javascript", "cache-control": "no-store" },
|
|
135
|
+
});
|
|
136
|
+
}
|