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,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
|
+
);
|