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