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,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY session manager for the web terminal.
|
|
3
|
+
*
|
|
4
|
+
* Each session wraps a bun-pty shell process with a 1MB replay buffer.
|
|
5
|
+
* Sessions persist across WebSocket disconnects so reconnecting clients
|
|
6
|
+
* get the full output history.
|
|
7
|
+
*
|
|
8
|
+
* Adapted from snomiao/sno-codehost terminal-ws-lib.ts.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { ServerWebSocket } from "bun";
|
|
15
|
+
|
|
16
|
+
const MAX_BUFFER_BYTES = 1024 * 1024; // 1 MB replay buffer
|
|
17
|
+
const MIN_COLS = 10;
|
|
18
|
+
const MIN_ROWS = 2;
|
|
19
|
+
|
|
20
|
+
// OSC 7: shell reports CWD after each prompt
|
|
21
|
+
const OSC7_RE = /\x1b\]7;file:\/\/[^/]*(\/.+?)(?:\x07|\x1b\\)/;
|
|
22
|
+
const OSC7_MARKER = 0x1b;
|
|
23
|
+
const textDecoder = new TextDecoder();
|
|
24
|
+
|
|
25
|
+
function extractOsc7Cwd(data: Uint8Array): string | null {
|
|
26
|
+
if (!data.includes(OSC7_MARKER)) return null;
|
|
27
|
+
const text = textDecoder.decode(data);
|
|
28
|
+
if (!text.includes("\x1b]7;")) return null;
|
|
29
|
+
const m = text.match(OSC7_RE);
|
|
30
|
+
if (!m) return null;
|
|
31
|
+
try {
|
|
32
|
+
return decodeURIComponent(m[1]);
|
|
33
|
+
} catch {
|
|
34
|
+
return m[1];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Set up a temp ZDOTDIR that emits OSC 7 on every zsh prompt
|
|
39
|
+
const ZDOTDIR = join(tmpdir(), "codehost-term-zshrc");
|
|
40
|
+
mkdirSync(ZDOTDIR, { recursive: true });
|
|
41
|
+
writeFileSync(join(ZDOTDIR, ".zshenv"), `[[ -f "$HOME/.zshenv" ]] && source "$HOME/.zshenv"\n`);
|
|
42
|
+
writeFileSync(join(ZDOTDIR, ".zprofile"), `[[ -f "$HOME/.zprofile" ]] && source "$HOME/.zprofile"\n`);
|
|
43
|
+
writeFileSync(
|
|
44
|
+
join(ZDOTDIR, ".zshrc"),
|
|
45
|
+
`[[ -f "$HOME/.zshrc" ]] && source "$HOME/.zshrc"
|
|
46
|
+
_codehost_report_cwd() { printf '\\033]7;file://%s%s\\007' "\${HOST:-localhost}" "$PWD"; }
|
|
47
|
+
precmd_functions+=(_codehost_report_cwd)
|
|
48
|
+
`,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
interface PtyHandle {
|
|
52
|
+
write(data: string | Uint8Array): void;
|
|
53
|
+
resize(cols: number, rows: number): void;
|
|
54
|
+
kill(): void;
|
|
55
|
+
onData(cb: (data: Uint8Array) => void): void;
|
|
56
|
+
onExit(cb: () => void): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function spawnPty(cmd: string, cols: number, rows: number, cwd: string): PtyHandle {
|
|
60
|
+
const { spawn: ptySpawn } = require("bun-pty");
|
|
61
|
+
const pty = ptySpawn(cmd, [], {
|
|
62
|
+
cols,
|
|
63
|
+
rows,
|
|
64
|
+
cwd,
|
|
65
|
+
env: {
|
|
66
|
+
...process.env,
|
|
67
|
+
TERM: "xterm-256color",
|
|
68
|
+
COLORTERM: "truecolor",
|
|
69
|
+
LANG: "C.UTF-8",
|
|
70
|
+
LC_ALL: "C.UTF-8",
|
|
71
|
+
ZDOTDIR,
|
|
72
|
+
PROMPT_COMMAND: `printf '\\033]7;file://%s%s\\007' "$HOSTNAME" "$PWD"${process.env.PROMPT_COMMAND ? `; ${process.env.PROMPT_COMMAND}` : ""}`,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
write(data) {
|
|
77
|
+
pty.write(typeof data === "string" ? data : textDecoder.decode(data));
|
|
78
|
+
},
|
|
79
|
+
resize(c, r) {
|
|
80
|
+
pty.resize(c, r);
|
|
81
|
+
},
|
|
82
|
+
kill() {
|
|
83
|
+
pty.kill();
|
|
84
|
+
},
|
|
85
|
+
onData(cb) {
|
|
86
|
+
pty.onData((str: string) => cb(new TextEncoder().encode(str)));
|
|
87
|
+
},
|
|
88
|
+
onExit(cb) {
|
|
89
|
+
pty.onExit(cb);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface Session {
|
|
95
|
+
pty: PtyHandle;
|
|
96
|
+
buffer: Uint8Array[];
|
|
97
|
+
bufferBytes: number;
|
|
98
|
+
clients: Set<ServerWebSocket<WsData>>;
|
|
99
|
+
cols: number;
|
|
100
|
+
rows: number;
|
|
101
|
+
cwd: string;
|
|
102
|
+
exited: boolean;
|
|
103
|
+
startedAt: number;
|
|
104
|
+
lastActivity: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type WsData = { sessionKey: string };
|
|
108
|
+
|
|
109
|
+
const sessions = new Map<string, Session>();
|
|
110
|
+
|
|
111
|
+
const resizeTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
112
|
+
|
|
113
|
+
function bufferPush(session: Session, chunk: Uint8Array) {
|
|
114
|
+
session.buffer.push(chunk);
|
|
115
|
+
session.bufferBytes += chunk.length;
|
|
116
|
+
while (session.bufferBytes > MAX_BUFFER_BYTES && session.buffer.length > 1) {
|
|
117
|
+
session.bufferBytes -= session.buffer.shift()!.length;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function debouncedResize(sessionKey: string) {
|
|
122
|
+
clearTimeout(resizeTimers.get(sessionKey));
|
|
123
|
+
resizeTimers.set(
|
|
124
|
+
sessionKey,
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
const s = sessions.get(sessionKey);
|
|
127
|
+
if (!s || s.clients.size === 0) return;
|
|
128
|
+
s.pty.resize(s.cols, s.rows);
|
|
129
|
+
}, 50),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getOrCreateSession(sessionKey: string, cwd: string, cols: number, rows: number): Session {
|
|
134
|
+
let session = sessions.get(sessionKey);
|
|
135
|
+
if (session && session.exited) {
|
|
136
|
+
session.pty.kill();
|
|
137
|
+
sessions.delete(sessionKey);
|
|
138
|
+
session = undefined;
|
|
139
|
+
}
|
|
140
|
+
if (session) return session;
|
|
141
|
+
|
|
142
|
+
const shell = process.env.SHELL ?? "bash";
|
|
143
|
+
const pty = spawnPty(shell, Math.max(MIN_COLS, cols), Math.max(MIN_ROWS, rows), cwd);
|
|
144
|
+
const s: Session = {
|
|
145
|
+
pty,
|
|
146
|
+
buffer: [],
|
|
147
|
+
bufferBytes: 0,
|
|
148
|
+
clients: new Set(),
|
|
149
|
+
cols: Math.max(MIN_COLS, cols),
|
|
150
|
+
rows: Math.max(MIN_ROWS, rows),
|
|
151
|
+
cwd,
|
|
152
|
+
exited: false,
|
|
153
|
+
startedAt: Date.now(),
|
|
154
|
+
lastActivity: Date.now(),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
pty.onData((data) => {
|
|
158
|
+
s.lastActivity = Date.now();
|
|
159
|
+
|
|
160
|
+
// Answer terminal queries so the PTY never blocks waiting for xterm.js
|
|
161
|
+
const text = textDecoder.decode(data);
|
|
162
|
+
if (text.includes("\x1b[c") || text.includes("\x1b[0c")) pty.write("\x1b[?1;2c");
|
|
163
|
+
if (text.includes("\x1b[6n")) pty.write("\x1b[1;1R");
|
|
164
|
+
|
|
165
|
+
bufferPush(s, data);
|
|
166
|
+
|
|
167
|
+
const newCwd = extractOsc7Cwd(data);
|
|
168
|
+
const cwdMsg =
|
|
169
|
+
newCwd && newCwd !== s.cwd ? JSON.stringify({ type: "cwd", path: newCwd }) : null;
|
|
170
|
+
if (cwdMsg) s.cwd = newCwd!;
|
|
171
|
+
|
|
172
|
+
for (const client of s.clients) {
|
|
173
|
+
client.send(data);
|
|
174
|
+
if (cwdMsg) client.send(cwdMsg);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
pty.onExit(() => {
|
|
179
|
+
s.exited = true;
|
|
180
|
+
const msg = new TextEncoder().encode("\r\n\x1b[33m[session ended]\x1b[0m\r\n");
|
|
181
|
+
bufferPush(s, msg);
|
|
182
|
+
for (const client of s.clients) client.send(msg);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
sessions.set(sessionKey, s);
|
|
186
|
+
return s;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function sessionKeyForCwd(cwd: string): string {
|
|
190
|
+
return "s_" + createHash("sha256").update(cwd).digest("hex").slice(0, 12);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function validateCwd(raw: string | null): string {
|
|
194
|
+
if (!raw) throw new Error("cwd is required");
|
|
195
|
+
const resolved = raw.replace(/\\/g, "/");
|
|
196
|
+
const isAbsolute = resolved.startsWith("/") || /^[A-Za-z]:\//.test(resolved);
|
|
197
|
+
if (!isAbsolute || resolved.includes("/../")) throw new Error(`invalid cwd: ${raw}`);
|
|
198
|
+
if (!existsSync(resolved)) mkdirSync(resolved, { recursive: true });
|
|
199
|
+
return resolved;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function handleWsOpen(ws: ServerWebSocket<WsData>) {
|
|
203
|
+
const sessionKey = ws.data.sessionKey;
|
|
204
|
+
const session = sessions.get(sessionKey);
|
|
205
|
+
if (!session) return;
|
|
206
|
+
session.clients.add(ws);
|
|
207
|
+
for (const chunk of session.buffer) ws.send(chunk);
|
|
208
|
+
if (!session.exited) debouncedResize(sessionKey);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function handleWsMessage(ws: ServerWebSocket<WsData>, message: string | Buffer) {
|
|
212
|
+
const session = sessions.get(ws.data.sessionKey);
|
|
213
|
+
if (!session || session.exited) return;
|
|
214
|
+
|
|
215
|
+
if (typeof message === "string") {
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(message);
|
|
218
|
+
if (parsed.type === "resize" && parsed.cols && parsed.rows) {
|
|
219
|
+
session.cols = Math.max(MIN_COLS, parsed.cols);
|
|
220
|
+
session.rows = Math.max(MIN_ROWS, parsed.rows);
|
|
221
|
+
debouncedResize(ws.data.sessionKey);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (parsed.type === "ping") {
|
|
225
|
+
ws.send(JSON.stringify({ type: "pong", t: parsed.t ?? Date.now() }));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// not JSON — treat as raw stdin
|
|
230
|
+
}
|
|
231
|
+
session.pty.write(message);
|
|
232
|
+
} else {
|
|
233
|
+
session.pty.write(new Uint8Array(message));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function handleWsClose(ws: ServerWebSocket<WsData>) {
|
|
238
|
+
const session = sessions.get(ws.data.sessionKey);
|
|
239
|
+
if (!session) return;
|
|
240
|
+
session.clients.delete(ws);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function listSessions() {
|
|
244
|
+
return Array.from(sessions.entries()).map(([key, s]) => ({
|
|
245
|
+
key,
|
|
246
|
+
cwd: s.cwd,
|
|
247
|
+
cols: s.cols,
|
|
248
|
+
rows: s.rows,
|
|
249
|
+
clients: s.clients.size,
|
|
250
|
+
bufferBytes: s.bufferBytes,
|
|
251
|
+
startedAt: s.startedAt,
|
|
252
|
+
lastActivity: s.lastActivity,
|
|
253
|
+
exited: s.exited,
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Default signaling endpoint. In production the page is served from
|
|
2
|
+
// codehost.dev and talks to the Worker on signal.codehost.dev; in local dev
|
|
3
|
+
// (vite on :5173) it talks to `wrangler dev` on :8787. Override either with
|
|
4
|
+
// localStorage key "codehost.signal".
|
|
5
|
+
|
|
6
|
+
function defaultSignalUrl(): string {
|
|
7
|
+
if (typeof window === "undefined") return "wss://signal.codehost.dev";
|
|
8
|
+
const { hostname, protocol } = window.location;
|
|
9
|
+
const isLocal = hostname === "localhost" || hostname === "127.0.0.1";
|
|
10
|
+
if (isLocal) return "ws://localhost:8787";
|
|
11
|
+
const wsProto = protocol === "https:" ? "wss:" : "ws:";
|
|
12
|
+
return `${wsProto}//signal.${hostname.replace(/^www\./, "")}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getSignalUrl(): string {
|
|
16
|
+
if (typeof localStorage !== "undefined") {
|
|
17
|
+
const override = localStorage.getItem("codehost.signal");
|
|
18
|
+
if (override) return override;
|
|
19
|
+
}
|
|
20
|
+
return defaultSignalUrl();
|
|
21
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { TunnelClient, type TunnelLike, type TunnelWsHandlers, type TunnelWsHandle } from "./tunnel-client";
|
|
2
|
+
|
|
3
|
+
// Per-tab connection broker. Talks to the SharedWorker (conn-shared-worker.ts)
|
|
4
|
+
// so that all tabs of this origin share ONE WebRTC data channel per server:
|
|
5
|
+
//
|
|
6
|
+
// - Exactly one tab is the "owner" of a peerId; it holds the RTCPeerConnection
|
|
7
|
+
// + data channel (RTCPeerConnection is Window-only) wrapped in a TunnelClient.
|
|
8
|
+
// - Other tabs get a ProxyTunnelClient that routes fetch()/openWs() calls to
|
|
9
|
+
// the owner through the SharedWorker and streams the answers back.
|
|
10
|
+
// - If the owner tab goes away, the SharedWorker promotes another tab, which
|
|
11
|
+
// re-establishes the connection (failover).
|
|
12
|
+
//
|
|
13
|
+
// tunnelFor(peerId) returns the right TunnelLike for this tab transparently, so
|
|
14
|
+
// the Service Worker glue and the WS shim don't know or care who owns what.
|
|
15
|
+
|
|
16
|
+
type AnyMsg = Record<string, any>;
|
|
17
|
+
|
|
18
|
+
/** Creates the RTCPeerConnection for a peer and resolves with its open channel.
|
|
19
|
+
* Provided by the UI (discovery.tsx) and invoked only when this tab is owner. */
|
|
20
|
+
export type Establish = () => Promise<RTCDataChannel>;
|
|
21
|
+
|
|
22
|
+
class ConnBroker {
|
|
23
|
+
private port: MessagePort | null = null;
|
|
24
|
+
private tabId = 0;
|
|
25
|
+
private supported = false;
|
|
26
|
+
|
|
27
|
+
private locals = new Map<string, TunnelClient>(); // peerId -> owner-held channel
|
|
28
|
+
private proxies = new Map<string, ProxyTunnelClient>(); // peerId -> cross-tab proxy
|
|
29
|
+
private establishers = new Map<string, Establish>(); // peerId -> how to (re)connect
|
|
30
|
+
private establishing = new Set<string>(); // peerIds whose channel is opening
|
|
31
|
+
private remoteReady = new Set<string>(); // peerIds served by a remote owner
|
|
32
|
+
private readyWaiters = new Map<string, Array<{ resolve: () => void; reject: (e: unknown) => void }>>();
|
|
33
|
+
private pending = new Map<number, (payload: AnyMsg) => void>(); // requester: callId -> sink
|
|
34
|
+
private ownerWs = new Map<string, TunnelWsHandle>(); // owner: `${tab}:${callId}` -> ws
|
|
35
|
+
private callSeq = 1;
|
|
36
|
+
private lostCb: ((peerId: string) => void) | null = null;
|
|
37
|
+
|
|
38
|
+
init(): void {
|
|
39
|
+
try {
|
|
40
|
+
const worker = new SharedWorker(new URL("./conn-shared-worker.ts", import.meta.url), {
|
|
41
|
+
type: "module",
|
|
42
|
+
name: "codehost-conn",
|
|
43
|
+
});
|
|
44
|
+
this.port = worker.port;
|
|
45
|
+
this.port.onmessage = (e: MessageEvent) => this.onMessage(e.data);
|
|
46
|
+
this.port.start();
|
|
47
|
+
this.post({ t: "hello" });
|
|
48
|
+
setInterval(() => this.post({ t: "ping" }), 4000);
|
|
49
|
+
addEventListener("pagehide", () => this.post({ t: "bye" }));
|
|
50
|
+
this.supported = true;
|
|
51
|
+
(globalThis as Record<string, unknown>).__connBroker = this; // debug handle
|
|
52
|
+
|
|
53
|
+
} catch (err) {
|
|
54
|
+
// No SharedWorker (or blocked): fall back to single-tab ownership.
|
|
55
|
+
console.warn("[codehost] SharedWorker unavailable; per-tab connections", err);
|
|
56
|
+
this.supported = false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Notified when a remote owner vanished, so the UI can reload the iframe. */
|
|
61
|
+
onLost(cb: (peerId: string) => void): void {
|
|
62
|
+
this.lostCb = cb;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Establish (or attach to) the shared connection for a server. Resolves once
|
|
66
|
+
* the tunnel is usable from this tab (local channel open, or owner ready). */
|
|
67
|
+
async connect(peerId: string, establish: Establish): Promise<void> {
|
|
68
|
+
this.establishers.set(peerId, establish);
|
|
69
|
+
if (!this.supported || !this.port) {
|
|
70
|
+
await this.becomeOwner(peerId);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const ready = this.waitReady(peerId);
|
|
74
|
+
this.post({ t: "acquire", peerId });
|
|
75
|
+
// If the SharedWorker never assigns a role (silent/unsupported), don't hang
|
|
76
|
+
// forever — open a direct connection so this tab still works on its own.
|
|
77
|
+
const fallback = setTimeout(() => {
|
|
78
|
+
if (!this.locals.has(peerId) && !this.remoteReady.has(peerId) && !this.establishing.has(peerId)) {
|
|
79
|
+
console.warn("[codehost] broker coordination timed out; opening direct connection");
|
|
80
|
+
void this.becomeOwner(peerId);
|
|
81
|
+
}
|
|
82
|
+
}, 2500);
|
|
83
|
+
await ready;
|
|
84
|
+
clearTimeout(fallback);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
tunnelFor(peerId: string): TunnelLike {
|
|
88
|
+
const local = this.locals.get(peerId);
|
|
89
|
+
if (local) return local;
|
|
90
|
+
let proxy = this.proxies.get(peerId);
|
|
91
|
+
if (!proxy) {
|
|
92
|
+
proxy = new ProxyTunnelClient(peerId, this);
|
|
93
|
+
this.proxies.set(peerId, proxy);
|
|
94
|
+
}
|
|
95
|
+
return proxy;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
disconnect(peerId: string): void {
|
|
99
|
+
this.post({ t: "release", peerId });
|
|
100
|
+
this.locals.delete(peerId);
|
|
101
|
+
this.proxies.delete(peerId);
|
|
102
|
+
this.establishers.delete(peerId);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- ownership ----
|
|
106
|
+
|
|
107
|
+
private async becomeOwner(peerId: string): Promise<void> {
|
|
108
|
+
if (this.locals.has(peerId) || this.establishing.has(peerId)) return;
|
|
109
|
+
const establish = this.establishers.get(peerId);
|
|
110
|
+
if (!establish) return;
|
|
111
|
+
this.establishing.add(peerId);
|
|
112
|
+
try {
|
|
113
|
+
const channel = await establish();
|
|
114
|
+
this.locals.set(peerId, new TunnelClient(channel));
|
|
115
|
+
this.post({ t: "ready", peerId });
|
|
116
|
+
this.resolveReady(peerId);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error("[codehost] failed to establish owner connection", err);
|
|
119
|
+
this.rejectReady(peerId, err); // unblock connect() so the UI shows failure
|
|
120
|
+
} finally {
|
|
121
|
+
this.establishing.delete(peerId);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private waitReady(peerId: string): Promise<void> {
|
|
126
|
+
if (this.locals.has(peerId)) return Promise.resolve();
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const arr = this.readyWaiters.get(peerId) ?? [];
|
|
129
|
+
arr.push({ resolve, reject });
|
|
130
|
+
this.readyWaiters.set(peerId, arr);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private resolveReady(peerId: string): void {
|
|
135
|
+
const arr = this.readyWaiters.get(peerId);
|
|
136
|
+
if (!arr) return;
|
|
137
|
+
this.readyWaiters.delete(peerId);
|
|
138
|
+
for (const w of arr) w.resolve();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private rejectReady(peerId: string, err: unknown): void {
|
|
142
|
+
const arr = this.readyWaiters.get(peerId);
|
|
143
|
+
if (!arr) return;
|
|
144
|
+
this.readyWaiters.delete(peerId);
|
|
145
|
+
for (const w of arr) w.reject(err);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- SharedWorker wire ----
|
|
149
|
+
|
|
150
|
+
private post(msg: AnyMsg): void {
|
|
151
|
+
this.port?.postMessage(msg);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private onMessage(msg: AnyMsg): void {
|
|
155
|
+
switch (msg.t) {
|
|
156
|
+
case "welcome":
|
|
157
|
+
this.tabId = msg.tabId;
|
|
158
|
+
break;
|
|
159
|
+
case "role":
|
|
160
|
+
if (msg.owner) void this.becomeOwner(msg.peerId);
|
|
161
|
+
break; // non-owner waits for "ready"
|
|
162
|
+
case "ready":
|
|
163
|
+
if (!this.locals.has(msg.peerId)) this.remoteReady.add(msg.peerId);
|
|
164
|
+
this.resolveReady(msg.peerId);
|
|
165
|
+
break;
|
|
166
|
+
case "promoted": // failover: we are the new owner
|
|
167
|
+
this.remoteReady.delete(msg.peerId);
|
|
168
|
+
this.locals.delete(msg.peerId);
|
|
169
|
+
void this.becomeOwner(msg.peerId);
|
|
170
|
+
break;
|
|
171
|
+
case "owner-gone": // a remote owner left; drop proxy + ask UI to reload
|
|
172
|
+
this.remoteReady.delete(msg.peerId);
|
|
173
|
+
this.proxies.delete(msg.peerId);
|
|
174
|
+
this.lostCb?.(msg.peerId);
|
|
175
|
+
break;
|
|
176
|
+
case "rpc":
|
|
177
|
+
this.serveRpc(msg); // owner side
|
|
178
|
+
break;
|
|
179
|
+
case "rpc-reply":
|
|
180
|
+
this.pending.get(msg.callId)?.(msg.payload); // requester side
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- requester side (used by ProxyTunnelClient) ----
|
|
186
|
+
|
|
187
|
+
nextCall(): number {
|
|
188
|
+
return this.callSeq++;
|
|
189
|
+
}
|
|
190
|
+
registerCall(callId: number, sink: (payload: AnyMsg) => void): void {
|
|
191
|
+
this.pending.set(callId, sink);
|
|
192
|
+
}
|
|
193
|
+
endCall(callId: number): void {
|
|
194
|
+
this.pending.delete(callId);
|
|
195
|
+
}
|
|
196
|
+
sendRpc(peerId: string, callId: number, payload: AnyMsg): void {
|
|
197
|
+
this.post({ t: "rpc", peerId, callId, payload });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- owner side: run a routed call against the local TunnelClient ----
|
|
201
|
+
|
|
202
|
+
private serveRpc(msg: AnyMsg): void {
|
|
203
|
+
const local = this.locals.get(msg.peerId);
|
|
204
|
+
const reply = (payload: AnyMsg) =>
|
|
205
|
+
this.post({ t: "rpc-reply", peerId: msg.peerId, callId: msg.callId, toTabId: msg.fromTabId, payload });
|
|
206
|
+
if (!local) {
|
|
207
|
+
reply({ op: "error", message: "owner has no channel" });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const p = msg.payload as AnyMsg;
|
|
211
|
+
const wsKey = `${msg.fromTabId}:${msg.callId}`;
|
|
212
|
+
switch (p.op) {
|
|
213
|
+
case "fetch":
|
|
214
|
+
void local
|
|
215
|
+
.fetch(p.method, p.path, p.headers, p.body ? new Uint8Array(p.body) : undefined)
|
|
216
|
+
.then(async (res) => {
|
|
217
|
+
const headers: Record<string, string> = {};
|
|
218
|
+
res.headers.forEach((v, k) => (headers[k] = v));
|
|
219
|
+
reply({ op: "head", status: res.status, statusText: res.statusText, headers });
|
|
220
|
+
if (res.body) {
|
|
221
|
+
const reader = res.body.getReader();
|
|
222
|
+
for (;;) {
|
|
223
|
+
const { done, value } = await reader.read();
|
|
224
|
+
if (done) break;
|
|
225
|
+
reply({ op: "body", chunk: toBuffer(value) });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
reply({ op: "end" });
|
|
229
|
+
})
|
|
230
|
+
.catch((err) => reply({ op: "error", message: String(err) }));
|
|
231
|
+
break;
|
|
232
|
+
case "ws-open": {
|
|
233
|
+
const handle = local.openWs(p.path, p.protocols, {
|
|
234
|
+
onOpenAck: (ok, protocol) => reply({ op: "ws-openack", ok, protocol }),
|
|
235
|
+
onText: (text) => reply({ op: "ws-rtext", data: text }),
|
|
236
|
+
onBin: (data) => reply({ op: "ws-rbin", data: toBuffer(data) }),
|
|
237
|
+
onClose: (code, reason) => {
|
|
238
|
+
reply({ op: "ws-rclose", code, reason });
|
|
239
|
+
this.ownerWs.delete(wsKey);
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
this.ownerWs.set(wsKey, handle);
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case "ws-text":
|
|
246
|
+
this.ownerWs.get(wsKey)?.sendText(p.data);
|
|
247
|
+
break;
|
|
248
|
+
case "ws-bin":
|
|
249
|
+
this.ownerWs.get(wsKey)?.sendBin(new Uint8Array(p.data));
|
|
250
|
+
break;
|
|
251
|
+
case "ws-close":
|
|
252
|
+
this.ownerWs.get(wsKey)?.close(p.code, p.reason);
|
|
253
|
+
this.ownerWs.delete(wsKey);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A TunnelLike that forwards every call to the owner tab via the SharedWorker. */
|
|
260
|
+
class ProxyTunnelClient implements TunnelLike {
|
|
261
|
+
constructor(
|
|
262
|
+
private peerId: string,
|
|
263
|
+
private broker: ConnBroker,
|
|
264
|
+
) {}
|
|
265
|
+
|
|
266
|
+
fetch(method: string, path: string, headers: Record<string, string>, body?: Uint8Array): Promise<Response> {
|
|
267
|
+
const callId = this.broker.nextCall();
|
|
268
|
+
return new Promise<Response>((resolve, reject) => {
|
|
269
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
270
|
+
let settled = false;
|
|
271
|
+
const stream = new ReadableStream<Uint8Array>({ start: (c) => (controller = c) });
|
|
272
|
+
this.broker.registerCall(callId, (payload) => {
|
|
273
|
+
switch (payload.op) {
|
|
274
|
+
case "head":
|
|
275
|
+
settled = true;
|
|
276
|
+
resolve(new Response(stream, { status: payload.status, statusText: payload.statusText, headers: payload.headers }));
|
|
277
|
+
break;
|
|
278
|
+
case "body":
|
|
279
|
+
controller?.enqueue(new Uint8Array(payload.chunk));
|
|
280
|
+
break;
|
|
281
|
+
case "end":
|
|
282
|
+
try {
|
|
283
|
+
controller?.close();
|
|
284
|
+
} catch {
|
|
285
|
+
/* already closed */
|
|
286
|
+
}
|
|
287
|
+
this.broker.endCall(callId);
|
|
288
|
+
break;
|
|
289
|
+
case "error":
|
|
290
|
+
if (!settled) reject(new Error(payload.message));
|
|
291
|
+
else
|
|
292
|
+
try {
|
|
293
|
+
controller?.error(new Error(payload.message));
|
|
294
|
+
} catch {
|
|
295
|
+
/* ignore */
|
|
296
|
+
}
|
|
297
|
+
this.broker.endCall(callId);
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
this.broker.sendRpc(this.peerId, callId, { op: "fetch", method, path, headers, body: body ? toBuffer(body) : undefined });
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
openWs(path: string, protocols: string[] | undefined, handlers: TunnelWsHandlers): TunnelWsHandle {
|
|
306
|
+
const callId = this.broker.nextCall();
|
|
307
|
+
this.broker.registerCall(callId, (payload) => {
|
|
308
|
+
switch (payload.op) {
|
|
309
|
+
case "ws-openack":
|
|
310
|
+
handlers.onOpenAck(payload.ok, payload.protocol);
|
|
311
|
+
break;
|
|
312
|
+
case "ws-rtext":
|
|
313
|
+
handlers.onText(payload.data);
|
|
314
|
+
break;
|
|
315
|
+
case "ws-rbin":
|
|
316
|
+
handlers.onBin(new Uint8Array(payload.data));
|
|
317
|
+
break;
|
|
318
|
+
case "ws-rclose":
|
|
319
|
+
handlers.onClose(payload.code ?? 1000, payload.reason ?? "");
|
|
320
|
+
this.broker.endCall(callId);
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
this.broker.sendRpc(this.peerId, callId, { op: "ws-open", path, protocols });
|
|
325
|
+
return {
|
|
326
|
+
sendText: (text) => this.broker.sendRpc(this.peerId, callId, { op: "ws-text", data: text }),
|
|
327
|
+
sendBin: (data) => this.broker.sendRpc(this.peerId, callId, { op: "ws-bin", data: toBuffer(data) }),
|
|
328
|
+
close: (code, reason) => {
|
|
329
|
+
this.broker.sendRpc(this.peerId, callId, { op: "ws-close", code, reason });
|
|
330
|
+
this.broker.endCall(callId);
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Copy a view's bytes into a standalone ArrayBuffer (safe to structured-clone). */
|
|
337
|
+
function toBuffer(view: Uint8Array): ArrayBuffer {
|
|
338
|
+
return view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) as ArrayBuffer;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export const connBroker = new ConnBroker();
|