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.
@@ -0,0 +1,233 @@
1
+ import type { DataChannel } from "node-datachannel";
2
+ import {
3
+ type HttpReqHead,
4
+ Op,
5
+ WsReassembler,
6
+ chunk,
7
+ decodeFrame,
8
+ encodeFrame,
9
+ encodeJson,
10
+ payloadJson,
11
+ payloadText,
12
+ wsMessageFrames,
13
+ } from "../shared/protocol";
14
+
15
+ const textDecoder = new TextDecoder();
16
+
17
+ // Hop-by-hop headers that must not be forwarded across the tunnel.
18
+ const HOP_BY_HOP = new Set([
19
+ "connection",
20
+ "keep-alive",
21
+ "proxy-authenticate",
22
+ "proxy-authorization",
23
+ "te",
24
+ "trailer",
25
+ "transfer-encoding",
26
+ "upgrade",
27
+ "content-length",
28
+ "host",
29
+ ]);
30
+
31
+ interface HttpStream {
32
+ head: HttpReqHead;
33
+ body: Uint8Array[];
34
+ }
35
+
36
+ /**
37
+ * Bridges one WebRTC data channel to a local `code serve-web` instance.
38
+ * Multiplexes concurrent HTTP requests and WebSocket connections by streamId.
39
+ */
40
+ export class Tunnel {
41
+ private httpStreams = new Map<number, HttpStream>();
42
+ private wsConns = new Map<number, WebSocket>();
43
+ private wsRx = new WsReassembler(); // reassembles browser -> daemon WS messages
44
+ private origin: string; // e.g. http://127.0.0.1:11991
45
+ private wsOrigin: string; // e.g. ws://127.0.0.1:11991
46
+
47
+ constructor(
48
+ private channel: DataChannel,
49
+ private vscodePort: number,
50
+ ) {
51
+ this.origin = `http://127.0.0.1:${vscodePort}`;
52
+ this.wsOrigin = `ws://127.0.0.1:${vscodePort}`;
53
+ this.channel.onMessage((msg) => {
54
+ if (typeof msg === "string") return; // all frames are binary
55
+ const buf = msg as Buffer;
56
+ void this.onFrame(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
57
+ });
58
+ this.channel.onClosed(() => this.closeAll());
59
+ }
60
+
61
+ private async onFrame(data: Uint8Array): Promise<void> {
62
+ const { op, streamId, payload } = decodeFrame(data);
63
+ switch (op) {
64
+ case Op.HttpReq:
65
+ this.httpStreams.set(streamId, { head: payloadJson<HttpReqHead>(payload), body: [] });
66
+ break;
67
+ case Op.HttpReqBody:
68
+ this.httpStreams.get(streamId)?.body.push(payload.slice());
69
+ break;
70
+ case Op.HttpReqEnd:
71
+ await this.doHttp(streamId);
72
+ break;
73
+ case Op.WsOpen:
74
+ this.openWs(streamId, payloadJson<{ path: string; protocols?: string[] }>(payload));
75
+ break;
76
+ case Op.WsCont:
77
+ this.wsRx.cont(streamId, payload);
78
+ break;
79
+ case Op.WsText:
80
+ this.wsConns.get(streamId)?.send(textDecoder.decode(this.wsRx.finish(streamId, payload)));
81
+ break;
82
+ case Op.WsBin:
83
+ this.wsConns.get(streamId)?.send(this.wsRx.finish(streamId, payload));
84
+ break;
85
+ case Op.WsClose:
86
+ this.wsRx.drop(streamId);
87
+ this.wsConns.get(streamId)?.close();
88
+ this.wsConns.delete(streamId);
89
+ break;
90
+ }
91
+ }
92
+
93
+ // ---- HTTP ----
94
+
95
+ private async doHttp(streamId: number): Promise<void> {
96
+ const stream = this.httpStreams.get(streamId);
97
+ if (!stream) return;
98
+ this.httpStreams.delete(streamId);
99
+
100
+ const { method, path, headers } = stream.head;
101
+ const reqHeaders = new Headers();
102
+ for (const [k, v] of Object.entries(headers)) {
103
+ if (!HOP_BY_HOP.has(k.toLowerCase())) reqHeaders.set(k, v);
104
+ }
105
+ reqHeaders.set("host", `127.0.0.1:${this.vscodePort}`);
106
+
107
+ const hasBody = method !== "GET" && method !== "HEAD" && stream.body.length > 0;
108
+ const body = hasBody ? concat(stream.body) : undefined;
109
+
110
+ try {
111
+ const res = await fetch(this.origin + path, {
112
+ method,
113
+ headers: reqHeaders,
114
+ body: body as BodyInit | undefined,
115
+ redirect: "manual",
116
+ });
117
+
118
+ const resHeaders: Record<string, string> = {};
119
+ res.headers.forEach((v, k) => {
120
+ if (!HOP_BY_HOP.has(k.toLowerCase())) resHeaders[k] = v;
121
+ });
122
+
123
+ await this.send(
124
+ encodeJson(Op.HttpResHead, streamId, {
125
+ status: res.status,
126
+ statusText: res.statusText,
127
+ headers: resHeaders,
128
+ }),
129
+ );
130
+
131
+ if (res.body) {
132
+ const reader = res.body.getReader();
133
+ for (;;) {
134
+ const { done, value } = await reader.read();
135
+ if (done) break;
136
+ for (const part of chunk(value)) {
137
+ await this.send(encodeFrame(Op.HttpResBody, streamId, part));
138
+ }
139
+ }
140
+ }
141
+ await this.send(encodeFrame(Op.HttpResEnd, streamId));
142
+ } catch (err) {
143
+ await this.send(
144
+ encodeJson(Op.Error, streamId, { message: `proxy error: ${String(err)}` }),
145
+ );
146
+ }
147
+ }
148
+
149
+ // ---- WebSocket ----
150
+
151
+ private openWs(streamId: number, info: { path: string; protocols?: string[] }): void {
152
+ let ws: WebSocket;
153
+ try {
154
+ ws = new WebSocket(this.wsOrigin + info.path, info.protocols);
155
+ } catch (err) {
156
+ void this.send(encodeJson(Op.WsOpenAck, streamId, { ok: false, error: String(err) }));
157
+ return;
158
+ }
159
+ ws.binaryType = "arraybuffer";
160
+ this.wsConns.set(streamId, ws);
161
+
162
+ ws.onopen = () => {
163
+ void this.send(encodeJson(Op.WsOpenAck, streamId, { ok: true, protocol: ws.protocol }));
164
+ };
165
+ ws.onmessage = (ev: MessageEvent) => {
166
+ const [terminal, u8] =
167
+ typeof ev.data === "string"
168
+ ? [Op.WsText, new TextEncoder().encode(ev.data)] as const
169
+ : [Op.WsBin, ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array(ev.data as ArrayBufferLike)] as const;
170
+ for (const frame of wsMessageFrames(terminal, streamId, u8)) void this.send(frame);
171
+ };
172
+ ws.onclose = (ev: CloseEvent) => {
173
+ void this.send(encodeJson(Op.WsClose, streamId, { code: ev.code, reason: ev.reason }));
174
+ this.wsConns.delete(streamId);
175
+ };
176
+ ws.onerror = () => {
177
+ void this.send(encodeJson(Op.WsClose, streamId, { code: 1006, reason: "error" }));
178
+ this.wsConns.delete(streamId);
179
+ };
180
+ }
181
+
182
+ // ---- send: serialized FIFO with backpressure ----
183
+
184
+ // Frames must reach the channel in enqueue order (chunked bodies and
185
+ // fragmented WS messages rely on it), but each send may pause for drain.
186
+ // Chaining on `sendTail` preserves order even when callers don't await.
187
+ private sendTail: Promise<void> = Promise.resolve();
188
+
189
+ private send(frame: Uint8Array): Promise<void> {
190
+ const p = this.sendTail.then(async () => {
191
+ await this.waitForDrain();
192
+ if (!this.channel.isOpen()) return;
193
+ this.channel.sendMessageBinary(Buffer.from(frame.buffer, frame.byteOffset, frame.byteLength));
194
+ });
195
+ this.sendTail = p.catch(() => {});
196
+ return p;
197
+ }
198
+
199
+ private waitForDrain(): Promise<void> {
200
+ const HIGH = 8 * 1024 * 1024; // 8 MB
201
+ if (!this.channel.isOpen() || this.channel.bufferedAmount() < HIGH) return Promise.resolve();
202
+ return new Promise((resolve) => {
203
+ const tick = () => {
204
+ if (!this.channel.isOpen() || this.channel.bufferedAmount() < HIGH) resolve();
205
+ else setTimeout(tick, 10);
206
+ };
207
+ tick();
208
+ });
209
+ }
210
+
211
+ closeAll(): void {
212
+ for (const ws of this.wsConns.values()) {
213
+ try {
214
+ ws.close();
215
+ } catch {
216
+ // ignore
217
+ }
218
+ }
219
+ this.wsConns.clear();
220
+ this.httpStreams.clear();
221
+ }
222
+ }
223
+
224
+ function concat(parts: Uint8Array[]): Uint8Array {
225
+ const total = parts.reduce((n, p) => n + p.byteLength, 0);
226
+ const out = new Uint8Array(total);
227
+ let off = 0;
228
+ for (const p of parts) {
229
+ out.set(p, off);
230
+ off += p.byteLength;
231
+ }
232
+ return out;
233
+ }
@@ -0,0 +1,85 @@
1
+ import { spawn, type Subprocess } from "bun";
2
+
3
+ export interface VscodeServer {
4
+ port: number;
5
+ basePath: string;
6
+ proc: Subprocess;
7
+ stop: () => void;
8
+ }
9
+
10
+ export interface LaunchOptions {
11
+ /** Folder to open in VS Code. */
12
+ dir: string;
13
+ /** Base path the server is mounted under, e.g. /vs/<peerId>. */
14
+ basePath: string;
15
+ /** Fixed port, or 0 to let VS Code pick a free one. */
16
+ port?: number;
17
+ }
18
+
19
+ /**
20
+ * Launch `code serve-web` for a directory under a given base path and wait
21
+ * until it answers. Mirrors the prior project's flag set
22
+ * (tmp/.../vscode-server.cjs): no connection token, license accepted.
23
+ */
24
+ export async function launchVscode(opts: LaunchOptions): Promise<VscodeServer> {
25
+ const port = opts.port && opts.port > 0 ? opts.port : await freePort();
26
+ const args = [
27
+ "serve-web",
28
+ "--host",
29
+ "127.0.0.1",
30
+ "--port",
31
+ String(port),
32
+ "--server-base-path",
33
+ opts.basePath,
34
+ "--default-folder",
35
+ opts.dir,
36
+ "--without-connection-token",
37
+ "--accept-server-license-terms",
38
+ ];
39
+
40
+ console.log(`[codehost] launching: code ${args.join(" ")}`);
41
+ const proc = spawn(["code", ...args], {
42
+ cwd: opts.dir,
43
+ stdout: "inherit",
44
+ stderr: "inherit",
45
+ });
46
+
47
+ const base = `http://127.0.0.1:${port}${opts.basePath}/`;
48
+ await waitForHttp(base, 30_000);
49
+ console.log(`[codehost] VS Code ready at ${base}`);
50
+
51
+ const stop = () => {
52
+ try {
53
+ proc.kill();
54
+ } catch {
55
+ // ignore
56
+ }
57
+ };
58
+ return { port, basePath: opts.basePath, proc, stop };
59
+ }
60
+
61
+ async function waitForHttp(url: string, timeoutMs: number): Promise<void> {
62
+ const deadline = Date.now() + timeoutMs;
63
+ while (Date.now() < deadline) {
64
+ try {
65
+ const res = await fetch(url, { redirect: "manual" });
66
+ if (res.status > 0) return;
67
+ } catch {
68
+ // not up yet
69
+ }
70
+ await Bun.sleep(300);
71
+ }
72
+ throw new Error(`VS Code did not become ready at ${url} within ${timeoutMs}ms`);
73
+ }
74
+
75
+ async function freePort(): Promise<number> {
76
+ // Bind to port 0 to get an OS-assigned free port, then release it.
77
+ const server = Bun.listen({
78
+ hostname: "127.0.0.1",
79
+ port: 0,
80
+ socket: { data() {}, open() {}, close() {} },
81
+ });
82
+ const port = server.port;
83
+ server.stop(true);
84
+ return port;
85
+ }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * xterm.js terminal wired to the PTY WebSocket at /ws.
3
+ *
4
+ * Features:
5
+ * - VS Code Dark/Light Modern themes with system auto-switch
6
+ * - OSC 7 CWD tracking → onCwdChange callback
7
+ * - OSC 11 background color query response
8
+ * - CJK double-width (Unicode11Addon)
9
+ * - Clickable URLs with wrapped-URL reconstruction
10
+ * - Auto-copy on selection; Cmd/Ctrl+C copies when text is selected
11
+ * - ResizeObserver for responsive fit
12
+ * - Auto-reconnect on unexpected disconnect (2 s)
13
+ * - Heartbeat ping/pong every 10 s to detect zombie sockets
14
+ */
15
+ import { ClipboardAddon } from "@xterm/addon-clipboard";
16
+ import { FitAddon } from "@xterm/addon-fit";
17
+ import { SearchAddon } from "@xterm/addon-search";
18
+ import { Unicode11Addon } from "@xterm/addon-unicode11";
19
+ import { WebLinksAddon } from "@xterm/addon-web-links";
20
+ import { Terminal as XTerm } from "@xterm/xterm";
21
+ import "@xterm/xterm/css/xterm.css";
22
+ import { CSSProperties, useEffect, useRef, useState } from "react";
23
+
24
+ const lightTheme = {
25
+ background: "#ffffff",
26
+ foreground: "#3b3b3b",
27
+ cursor: "#005fb8",
28
+ cursorAccent: "#ffffff",
29
+ selectionBackground: "#add6ff",
30
+ black: "#000000",
31
+ red: "#cd3131",
32
+ green: "#00bc00",
33
+ yellow: "#949800",
34
+ blue: "#0451a5",
35
+ magenta: "#bc05bc",
36
+ cyan: "#0598bc",
37
+ white: "#555555",
38
+ brightBlack: "#666666",
39
+ brightRed: "#cd3131",
40
+ brightGreen: "#14ce14",
41
+ brightYellow: "#b5ba00",
42
+ brightBlue: "#0451a5",
43
+ brightMagenta: "#bc05bc",
44
+ brightCyan: "#0598bc",
45
+ brightWhite: "#a5a5a5",
46
+ };
47
+
48
+ const darkTheme = {
49
+ background: "#1f1f1f",
50
+ foreground: "#cccccc",
51
+ cursor: "#aeafad",
52
+ cursorAccent: "#1f1f1f",
53
+ selectionBackground: "#264f78",
54
+ black: "#000000",
55
+ red: "#cd3131",
56
+ green: "#0dbc79",
57
+ yellow: "#e5e510",
58
+ blue: "#2472c8",
59
+ magenta: "#bc3fbc",
60
+ cyan: "#11a8cd",
61
+ white: "#e5e5e5",
62
+ brightBlack: "#666666",
63
+ brightRed: "#f14c4c",
64
+ brightGreen: "#23d18b",
65
+ brightYellow: "#f5f543",
66
+ brightBlue: "#3b8eea",
67
+ brightMagenta: "#d670d6",
68
+ brightCyan: "#29b8db",
69
+ brightWhite: "#e5e5e5",
70
+ };
71
+
72
+ function hexToOscRgb(hex: string): string {
73
+ const r = hex.slice(1, 3);
74
+ const g = hex.slice(3, 5);
75
+ const b = hex.slice(5, 7);
76
+ return `rgb:${r}${r}/${g}${g}/${b}${b}`;
77
+ }
78
+
79
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
80
+ const getTheme = () => (prefersDark.matches ? darkTheme : lightTheme);
81
+
82
+ interface TerminalProps {
83
+ wsUrl: string;
84
+ cwd?: string;
85
+ session?: string;
86
+ onCwdChange?: (cwd: string) => void;
87
+ initialCmd?: string;
88
+ style?: CSSProperties;
89
+ }
90
+
91
+ export function Terminal({ wsUrl, cwd, session, onCwdChange, initialCmd, style }: TerminalProps) {
92
+ const wrapperRef = useRef<HTMLDivElement>(null);
93
+ const containerRef = useRef<HTMLDivElement>(null);
94
+ const wsRef = useRef<WebSocket | null>(null);
95
+ const onCwdRef = useRef(onCwdChange);
96
+ onCwdRef.current = onCwdChange;
97
+
98
+ const [wsStatus, setWsStatus] = useState<"connecting" | "connected" | "reconnecting" | "ended">(
99
+ "connecting",
100
+ );
101
+
102
+ useEffect(() => {
103
+ if (!containerRef.current) return;
104
+ if (wsUrl === "/ws" && !cwd && !session) return;
105
+ let disposed = false;
106
+
107
+ const init = async () => {
108
+ if (disposed) return;
109
+
110
+ const term = new XTerm({
111
+ cursorBlink: true,
112
+ fontSize: 14,
113
+ lineHeight: 1.2,
114
+ allowProposedApi: true,
115
+ rightClickSelectsWord: true,
116
+ scrollback: 10000,
117
+ fontFamily:
118
+ "'Cascadia Code', 'JetBrains Mono', 'Fira Code', 'Consolas', 'Menlo', 'Monaco', 'Courier New', monospace",
119
+ theme: getTheme(),
120
+ linkHandler: {
121
+ activate(_event, text) {
122
+ window.open(text, "_blank", "noopener");
123
+ },
124
+ },
125
+ });
126
+
127
+ const fitAddon = new FitAddon();
128
+ const unicode11 = new Unicode11Addon();
129
+ const webLinks = new WebLinksAddon(((_e: MouseEvent, uri: string, range: { start: { y: number } }) => {
130
+ let fullUrl = uri;
131
+ try {
132
+ const buf = term.buffer.active;
133
+ const startRow = range.start.y + buf.viewportY;
134
+ for (let row = startRow + 1; row < buf.length; row++) {
135
+ const line = buf.getLine(row);
136
+ if (!line?.isWrapped) break;
137
+ fullUrl += line.translateToString(true);
138
+ }
139
+ fullUrl = fullUrl.replace(/[\s\x00-\x1f]+$/, "");
140
+ } catch { /* fallback */ }
141
+ window.open(fullUrl, "_blank", "noopener");
142
+ }) as never);
143
+ const searchAddon = new SearchAddon();
144
+ const clipboardAddon = new ClipboardAddon();
145
+
146
+ term.loadAddon(fitAddon);
147
+ term.loadAddon(unicode11);
148
+ term.loadAddon(webLinks);
149
+ term.loadAddon(searchAddon);
150
+ term.loadAddon(clipboardAddon);
151
+ term.unicode.activeVersion = "11";
152
+ term.open(containerRef.current!);
153
+
154
+ // Auto-switch theme on system preference change
155
+ const onThemeChange = () => {
156
+ term.options.theme = getTheme();
157
+ if (wrapperRef.current) wrapperRef.current.style.backgroundColor = getTheme().background;
158
+ wsRef.current?.send(`\x1b[?997;${prefersDark.matches ? "1" : "2"}h`);
159
+ };
160
+ prefersDark.addEventListener("change", onThemeChange);
161
+
162
+ // OSC 11: respond to background color query
163
+ term.parser.registerOscHandler(11, (data) => {
164
+ if (data === "?") {
165
+ const reply = `\x1b]11;${hexToOscRgb(getTheme().background)}\x1b\\`;
166
+ wsRef.current?.send(reply);
167
+ }
168
+ return true;
169
+ });
170
+
171
+ // Auto-copy on selection
172
+ term.onSelectionChange(() => {
173
+ const sel = term.getSelection();
174
+ if (sel) navigator.clipboard.writeText(sel).catch(() => {});
175
+ });
176
+
177
+ // Ctrl/Cmd+C copies selection; otherwise sends SIGINT
178
+ term.attachCustomKeyEventHandler((e) => {
179
+ if (e.type === "keydown" && (e.ctrlKey || e.metaKey) && e.key === "c" && term.hasSelection()) {
180
+ navigator.clipboard.writeText(term.getSelection()).catch(() => {});
181
+ term.clearSelection();
182
+ return false;
183
+ }
184
+ return true;
185
+ });
186
+
187
+ // Fit
188
+ let fitRaf = 0;
189
+ const debouncedFit = () => {
190
+ cancelAnimationFrame(fitRaf);
191
+ fitRaf = requestAnimationFrame(() => {
192
+ if (disposed) return;
193
+ const buf = term.buffer.active;
194
+ const wasAtBottom = buf.baseY + term.rows >= buf.length;
195
+ fitAddon.fit();
196
+ if (wasAtBottom) term.scrollToBottom();
197
+ });
198
+ };
199
+
200
+ await document.fonts.ready;
201
+ let stableCount = 0, lastH = 0;
202
+ for (let i = 0; i < 60; i++) {
203
+ await new Promise((r) => requestAnimationFrame(r));
204
+ if (disposed) { term.dispose(); return; }
205
+ const { clientHeight } = containerRef.current ?? {};
206
+ if (clientHeight && clientHeight === lastH) { if (++stableCount >= 3) break; }
207
+ else { stableCount = 0; lastH = clientHeight ?? 0; }
208
+ }
209
+ if (disposed) { term.dispose(); return; }
210
+ fitAddon.fit();
211
+ setTimeout(() => { if (!disposed) fitAddon.fit(); }, 300);
212
+ setTimeout(() => { if (!disposed) fitAddon.fit(); }, 800);
213
+
214
+ // WebSocket connection
215
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
216
+ const base =
217
+ wsUrl.startsWith("ws://") || wsUrl.startsWith("wss://")
218
+ ? wsUrl
219
+ : `${protocol}//${window.location.host}${wsUrl}`;
220
+ const params = new URLSearchParams({
221
+ cols: String(term.cols),
222
+ rows: String(term.rows),
223
+ ...(cwd && { cwd }),
224
+ ...(session && { session }),
225
+ });
226
+ const absUrl = `${base}?${params}`;
227
+
228
+ let ws: WebSocket | null = null;
229
+ const setWs = (w: WebSocket | null) => { ws = w; wsRef.current = w; };
230
+
231
+ term.onData((data) => { if (ws?.readyState === WebSocket.OPEN) ws.send(data); });
232
+ term.onResize(({ cols, rows }) => {
233
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "resize", cols, rows }));
234
+ });
235
+
236
+ let heartbeatInterval: number | undefined;
237
+ let pongTimer: number | undefined;
238
+ const stopHeartbeat = () => {
239
+ clearInterval(heartbeatInterval);
240
+ clearTimeout(pongTimer);
241
+ heartbeatInterval = pongTimer = undefined;
242
+ };
243
+ const startHeartbeat = () => {
244
+ stopHeartbeat();
245
+ heartbeatInterval = window.setInterval(() => {
246
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
247
+ ws.send(JSON.stringify({ type: "ping", t: Date.now() }));
248
+ pongTimer = window.setTimeout(() => { try { ws?.close(); } catch { /* */ } }, 8000);
249
+ }, 10000);
250
+ };
251
+
252
+ const connect = () => {
253
+ if (disposed) return;
254
+ setWs(new WebSocket(absUrl));
255
+ ws!.binaryType = "arraybuffer";
256
+
257
+ ws!.onopen = () => {
258
+ setWsStatus("connected");
259
+ debouncedFit();
260
+ ws!.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
261
+ if (initialCmd) ws!.send(initialCmd + "\n");
262
+ startHeartbeat();
263
+ };
264
+
265
+ ws!.onmessage = (e) => {
266
+ if (typeof e.data === "string") {
267
+ try {
268
+ const msg = JSON.parse(e.data);
269
+ if (msg.type === "pong") { if (pongTimer) { clearTimeout(pongTimer); pongTimer = undefined; } return; }
270
+ if (msg.type === "cwd" && msg.path) { onCwdRef.current?.(msg.path); return; }
271
+ } catch { /* not a control message */ }
272
+ term.write(e.data);
273
+ } else {
274
+ term.write(e.data instanceof ArrayBuffer ? new Uint8Array(e.data) : e.data);
275
+ }
276
+ };
277
+
278
+ ws!.onclose = (ev) => {
279
+ stopHeartbeat();
280
+ if (disposed) return;
281
+ if (ev.code === 1000 || ev.code === 1008) { setWsStatus("ended"); return; }
282
+ setWsStatus("reconnecting");
283
+ setTimeout(connect, 2000);
284
+ };
285
+
286
+ ws!.onerror = () => setWsStatus("reconnecting");
287
+ };
288
+
289
+ connect();
290
+
291
+ const observer = new ResizeObserver(debouncedFit);
292
+ if (wrapperRef.current) observer.observe(wrapperRef.current);
293
+
294
+ if (wrapperRef.current) wrapperRef.current.style.backgroundColor = getTheme().background;
295
+
296
+ return () => {
297
+ disposed = true;
298
+ stopHeartbeat();
299
+ cancelAnimationFrame(fitRaf);
300
+ observer.disconnect();
301
+ prefersDark.removeEventListener("change", onThemeChange);
302
+ ws?.close();
303
+ setWs(null);
304
+ term.dispose();
305
+ };
306
+ };
307
+
308
+ let cleanup: (() => void) | undefined;
309
+ init().then((fn) => { cleanup = fn; });
310
+ return () => { disposed = true; cleanup?.(); };
311
+ }, [wsUrl, cwd, session]);
312
+
313
+ return (
314
+ <div
315
+ ref={wrapperRef}
316
+ style={{ position: "relative", overflow: "hidden", ...style }}
317
+ >
318
+ <div ref={containerRef} style={{ position: "absolute", inset: 0 }} onContextMenu={(e) => e.preventDefault()} />
319
+ {wsStatus !== "connected" && (
320
+ <div
321
+ style={{
322
+ position: "absolute", top: 8, right: 12, padding: "4px 10px",
323
+ fontSize: 12, fontFamily: "monospace", borderRadius: 4, pointerEvents: "none", zIndex: 10,
324
+ background: wsStatus === "reconnecting" ? "#b58900" : wsStatus === "ended" ? "#586e75" : "#268bd2",
325
+ color: "#fff",
326
+ }}
327
+ >
328
+ {wsStatus === "reconnecting" ? "reconnecting…" : wsStatus === "ended" ? "session ended" : "connecting…"}
329
+ </div>
330
+ )}
331
+ </div>
332
+ );
333
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,12 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { Discovery } from "./web/discovery";
4
+ import "./style.css";
5
+
6
+ const root = document.getElementById("root");
7
+ if (!root) throw new Error("Root element not found");
8
+ createRoot(root).render(
9
+ <StrictMode>
10
+ <Discovery />
11
+ </StrictMode>,
12
+ );