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,190 @@
1
+ import {
2
+ type HttpResHead,
3
+ Op,
4
+ WsReassembler,
5
+ chunk,
6
+ decodeFrame,
7
+ encodeFrame,
8
+ encodeJson,
9
+ payloadJson,
10
+ payloadText,
11
+ wsMessageFrames,
12
+ } from "../shared/protocol";
13
+
14
+ // Browser-side end of the tunnel. Owns the WebRTC data channel and multiplexes
15
+ // HTTP requests (driven by the Service Worker) and WebSocket connections
16
+ // (driven by the in-page TunnelWebSocket shim) over it by streamId.
17
+
18
+ interface HttpWaiter {
19
+ onHead: (head: HttpResHead) => void;
20
+ onBody: (chunk: Uint8Array) => void;
21
+ onEnd: () => void;
22
+ onError: (message: string) => void;
23
+ }
24
+
25
+ export interface TunnelWsHandlers {
26
+ onOpenAck: (ok: boolean, protocol?: string) => void;
27
+ onText: (text: string) => void;
28
+ onBin: (data: Uint8Array) => void;
29
+ onClose: (code: number, reason: string) => void;
30
+ }
31
+
32
+ export interface TunnelWsHandle {
33
+ sendText: (text: string) => void;
34
+ sendBin: (data: Uint8Array) => void;
35
+ close: (code?: number, reason?: string) => void;
36
+ }
37
+
38
+ /** The subset of a tunnel the Service Worker glue and WS shim depend on. Both
39
+ * the local {@link TunnelClient} and the cross-tab proxy implement it. */
40
+ export interface TunnelLike {
41
+ fetch(method: string, path: string, headers: Record<string, string>, body?: Uint8Array): Promise<Response>;
42
+ openWs(path: string, protocols: string[] | undefined, handlers: TunnelWsHandlers): TunnelWsHandle;
43
+ }
44
+
45
+ export class TunnelClient {
46
+ private nextStreamId = 1;
47
+ private https = new Map<number, HttpWaiter>();
48
+ private wss = new Map<number, TunnelWsHandlers>();
49
+ private wsRx = new WsReassembler(); // reassembles daemon -> browser WS messages
50
+ private textEncoder = new TextEncoder();
51
+
52
+ constructor(private channel: RTCDataChannel) {
53
+ channel.binaryType = "arraybuffer";
54
+ channel.addEventListener("message", (ev) => this.onFrame(ev.data));
55
+ }
56
+
57
+ private allocId(): number {
58
+ const id = this.nextStreamId;
59
+ this.nextStreamId = (this.nextStreamId + 1) >>> 0 || 1;
60
+ return id;
61
+ }
62
+
63
+ private onFrame(data: ArrayBuffer | string): void {
64
+ if (typeof data === "string") return;
65
+ const { op, streamId, payload } = decodeFrame(data);
66
+ switch (op) {
67
+ case Op.HttpResHead:
68
+ this.https.get(streamId)?.onHead(payloadJson<HttpResHead>(payload));
69
+ break;
70
+ case Op.HttpResBody:
71
+ this.https.get(streamId)?.onBody(payload.slice());
72
+ break;
73
+ case Op.HttpResEnd:
74
+ this.https.get(streamId)?.onEnd();
75
+ this.https.delete(streamId);
76
+ break;
77
+ case Op.Error: {
78
+ const waiter = this.https.get(streamId);
79
+ if (waiter) {
80
+ waiter.onError(payloadJson<{ message: string }>(payload).message);
81
+ this.https.delete(streamId);
82
+ }
83
+ break;
84
+ }
85
+ case Op.WsOpenAck: {
86
+ const info = payloadJson<{ ok: boolean; protocol?: string }>(payload);
87
+ this.wss.get(streamId)?.onOpenAck(info.ok, info.protocol);
88
+ break;
89
+ }
90
+ case Op.WsCont:
91
+ this.wsRx.cont(streamId, payload);
92
+ break;
93
+ case Op.WsText:
94
+ this.wss.get(streamId)?.onText(payloadText(this.wsRx.finish(streamId, payload)));
95
+ break;
96
+ case Op.WsBin:
97
+ this.wss.get(streamId)?.onBin(this.wsRx.finish(streamId, payload).slice());
98
+ break;
99
+ case Op.WsClose: {
100
+ const info = payloadJson<{ code?: number; reason?: string }>(payload);
101
+ this.wsRx.drop(streamId);
102
+ this.wss.get(streamId)?.onClose(info.code ?? 1000, info.reason ?? "");
103
+ this.wss.delete(streamId);
104
+ break;
105
+ }
106
+ }
107
+ }
108
+
109
+ /** Perform an HTTP request over the tunnel; resolves to a Response. */
110
+ fetch(method: string, path: string, headers: Record<string, string>, body?: Uint8Array): Promise<Response> {
111
+ const streamId = this.allocId();
112
+ return new Promise<Response>((resolve, reject) => {
113
+ let head: HttpResHead | null = null;
114
+ let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
115
+ const stream = new ReadableStream<Uint8Array>({
116
+ start: (c) => {
117
+ controller = c;
118
+ },
119
+ });
120
+
121
+ this.https.set(streamId, {
122
+ onHead: (h) => {
123
+ head = h;
124
+ resolve(
125
+ new Response(stream, {
126
+ status: h.status === 204 || h.status === 304 ? h.status : h.status,
127
+ statusText: h.statusText,
128
+ headers: h.headers,
129
+ }),
130
+ );
131
+ },
132
+ onBody: (b) => controller?.enqueue(b),
133
+ onEnd: () => {
134
+ try {
135
+ controller?.close();
136
+ } catch {
137
+ // already closed
138
+ }
139
+ if (!head) reject(new Error("stream ended before head"));
140
+ },
141
+ onError: (msg) => {
142
+ try {
143
+ controller?.error(new Error(msg));
144
+ } catch {
145
+ // ignore
146
+ }
147
+ if (!head) reject(new Error(msg));
148
+ },
149
+ });
150
+
151
+ this.send(encodeJson(Op.HttpReq, streamId, { method, path, headers }));
152
+ if (body && body.byteLength) {
153
+ for (const part of chunk(body)) this.send(encodeFrame(Op.HttpReqBody, streamId, part));
154
+ }
155
+ this.send(encodeFrame(Op.HttpReqEnd, streamId));
156
+ });
157
+ }
158
+
159
+ /** Open a WebSocket stream over the tunnel; returns its streamId + a sender. */
160
+ openWs(path: string, protocols: string[] | undefined, handlers: TunnelWsHandlers) {
161
+ const streamId = this.allocId();
162
+ this.wss.set(streamId, handlers);
163
+ this.send(encodeJson(Op.WsOpen, streamId, { path, protocols }));
164
+ return {
165
+ sendText: (text: string) => {
166
+ for (const f of wsMessageFrames(Op.WsText, streamId, this.textEncoder.encode(text))) this.send(f);
167
+ },
168
+ sendBin: (data: Uint8Array) => {
169
+ for (const f of wsMessageFrames(Op.WsBin, streamId, data)) this.send(f);
170
+ },
171
+ close: (code?: number, reason?: string) => {
172
+ this.send(encodeJson(Op.WsClose, streamId, { code, reason }));
173
+ this.wss.delete(streamId);
174
+ },
175
+ };
176
+ }
177
+
178
+ private send(frame: Uint8Array): void {
179
+ if (this.channel.readyState === "open") {
180
+ // Copy into a fresh ArrayBuffer-backed view to satisfy send()'s typing.
181
+ const copy = new Uint8Array(frame.byteLength);
182
+ copy.set(frame);
183
+ this.channel.send(copy.buffer);
184
+ }
185
+ }
186
+
187
+ get ready(): boolean {
188
+ return this.channel.readyState === "open";
189
+ }
190
+ }
@@ -0,0 +1,63 @@
1
+ import { connBroker } from "./conn-broker";
2
+ import { makeTunnelWebSocket } from "./tunnel-websocket";
3
+
4
+ // Page-side glue between the Service Worker and the connection broker.
5
+ // - Registers the SW and starts the broker (SharedWorker coordination).
6
+ // - Answers the SW's `tunnel-fetch` messages by running the request over the
7
+ // right tunnel for the peer (local channel if this tab owns it, else a proxy
8
+ // to the owner tab) and streaming the response back through the port.
9
+ // - Exposes window.__codehostMakeWS so the VS Code iframe's injected bootstrap
10
+ // can install a WebSocket shim bound to the right peer (same-origin access).
11
+
12
+ declare global {
13
+ interface Window {
14
+ __codehostMakeWS?: (peerId: string, basePath: string) => unknown;
15
+ }
16
+ }
17
+
18
+ export async function registerTunnelHost(): Promise<void> {
19
+ connBroker.init();
20
+
21
+ if (!("serviceWorker" in navigator)) {
22
+ console.warn("[codehost] no service worker support; VS Code tunneling unavailable");
23
+ return;
24
+ }
25
+
26
+ navigator.serviceWorker.addEventListener("message", onSwMessage);
27
+
28
+ window.__codehostMakeWS = (peerId: string, basePath: string) =>
29
+ makeTunnelWebSocket(connBroker.tunnelFor(peerId), basePath);
30
+
31
+ // Built to /sw.js at the web root (see vite.sw.config.ts) so its scope is "/".
32
+ await navigator.serviceWorker.register("/sw.js", { type: "module", scope: "/" });
33
+ await navigator.serviceWorker.ready;
34
+ }
35
+
36
+ function onSwMessage(event: MessageEvent): void {
37
+ const msg = event.data;
38
+ if (msg?.type !== "tunnel-fetch") return;
39
+ const port = event.ports[0];
40
+
41
+ void connBroker
42
+ .tunnelFor(msg.peerId)
43
+ .fetch(msg.method, msg.path, msg.headers, msg.body ? new Uint8Array(msg.body) : undefined)
44
+ .then(async (res) => {
45
+ const headers: Record<string, string> = {};
46
+ res.headers.forEach((v, k) => (headers[k] = v));
47
+ port.postMessage({ type: "head", status: res.status, statusText: res.statusText, headers });
48
+ if (res.body) {
49
+ const reader = res.body.getReader();
50
+ for (;;) {
51
+ const { done, value } = await reader.read();
52
+ if (done) break;
53
+ // Transfer the underlying buffer to avoid a copy.
54
+ const buf = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
55
+ port.postMessage({ type: "body", chunk: buf }, [buf]);
56
+ }
57
+ }
58
+ port.postMessage({ type: "end" });
59
+ })
60
+ .catch((err) => {
61
+ port.postMessage({ type: "error", message: String(err) });
62
+ });
63
+ }
@@ -0,0 +1,113 @@
1
+ import type { TunnelLike, TunnelWsHandle } from "./tunnel-client";
2
+
3
+ // Minimal WebSocket implementation backed by a TunnelClient stream. Installed
4
+ // into the VS Code iframe as `window.WebSocket` so the workbench's socket
5
+ // connections traverse the WebRTC data channel instead of the network.
6
+ //
7
+ // Only the surface VS Code uses is implemented: readyState, binaryType, send,
8
+ // close, and the onopen/onmessage/onclose/onerror + addEventListener events.
9
+
10
+ type Listener = (ev: any) => void;
11
+
12
+ export function makeTunnelWebSocket(client: TunnelLike, basePath: string) {
13
+ return class TunnelWebSocket implements Partial<WebSocket> {
14
+ static readonly CONNECTING = 0;
15
+ static readonly OPEN = 1;
16
+ static readonly CLOSING = 2;
17
+ static readonly CLOSED = 3;
18
+ readonly CONNECTING = 0;
19
+ readonly OPEN = 1;
20
+ readonly CLOSING = 2;
21
+ readonly CLOSED = 3;
22
+
23
+ url: string;
24
+ readyState = 0;
25
+ binaryType: BinaryType = "blob";
26
+ protocol = "";
27
+
28
+ onopen: Listener | null = null;
29
+ onmessage: Listener | null = null;
30
+ onclose: Listener | null = null;
31
+ onerror: Listener | null = null;
32
+
33
+ private listeners = new Map<string, Set<Listener>>();
34
+ private handle: TunnelWsHandle;
35
+
36
+ constructor(url: string | URL, protocols?: string | string[]) {
37
+ this.url = String(url);
38
+ const u = new URL(this.url, self.location.href);
39
+ // Forward only the path+query relative to the server base path.
40
+ let path = u.pathname + u.search;
41
+ if (basePath && path.startsWith(basePath)) path = path.slice(basePath.length) || "/";
42
+ const protoList = protocols ? (Array.isArray(protocols) ? protocols : [protocols]) : undefined;
43
+
44
+ this.handle = client.openWs(basePath + path, protoList, {
45
+ onOpenAck: (ok, protocol) => {
46
+ if (!ok) {
47
+ this.fail();
48
+ return;
49
+ }
50
+ this.readyState = 1;
51
+ this.protocol = protocol ?? "";
52
+ this.dispatch("open", {});
53
+ },
54
+ onText: (text) => this.dispatch("message", { data: text }),
55
+ onBin: (data) => this.dispatch("message", { data: this.wrapBinary(data) }),
56
+ onClose: (code, reason) => {
57
+ this.readyState = 3;
58
+ this.dispatch("close", { code, reason, wasClean: code === 1000 });
59
+ },
60
+ });
61
+ }
62
+
63
+ private wrapBinary(data: Uint8Array): ArrayBuffer | Blob {
64
+ if (this.binaryType === "arraybuffer") {
65
+ return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
66
+ }
67
+ return new Blob([data as BlobPart]);
68
+ }
69
+
70
+ send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void {
71
+ if (this.readyState !== 1) return;
72
+ if (typeof data === "string") {
73
+ this.handle.sendText(data);
74
+ } else if (data instanceof Blob) {
75
+ void data.arrayBuffer().then((b) => this.handle.sendBin(new Uint8Array(b)));
76
+ } else if (ArrayBuffer.isView(data)) {
77
+ this.handle.sendBin(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
78
+ } else {
79
+ this.handle.sendBin(new Uint8Array(data as ArrayBuffer));
80
+ }
81
+ }
82
+
83
+ close(code?: number, reason?: string): void {
84
+ if (this.readyState === 3) return;
85
+ this.readyState = 2;
86
+ this.handle.close(code, reason);
87
+ this.readyState = 3;
88
+ this.dispatch("close", { code: code ?? 1000, reason: reason ?? "", wasClean: true });
89
+ }
90
+
91
+ private fail(): void {
92
+ this.readyState = 3;
93
+ this.dispatch("error", {});
94
+ this.dispatch("close", { code: 1006, reason: "tunnel open failed", wasClean: false });
95
+ }
96
+
97
+ addEventListener(type: string, listener: Listener): void {
98
+ if (!this.listeners.has(type)) this.listeners.set(type, new Set());
99
+ this.listeners.get(type)!.add(listener);
100
+ }
101
+
102
+ removeEventListener(type: string, listener: Listener): void {
103
+ this.listeners.get(type)?.delete(listener);
104
+ }
105
+
106
+ private dispatch(type: string, init: Record<string, unknown>): void {
107
+ const ev = { type, target: this, ...init };
108
+ const handler = (this as any)[`on${type}`] as Listener | null;
109
+ handler?.call(this, ev);
110
+ this.listeners.get(type)?.forEach((l) => l.call(this, ev));
111
+ }
112
+ };
113
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "types": ["bun-types"],
10
+ "paths": {}
11
+ },
12
+ "include": ["src/**/*", "vite.config.ts"],
13
+ "exclude": ["src/web/sw.ts"]
14
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ port: 5173,
8
+ proxy: {
9
+ "/api": "http://localhost:3001",
10
+ "/ws": { target: "ws://localhost:3001", ws: true },
11
+ },
12
+ },
13
+ build: {
14
+ outDir: "dist/public",
15
+ emptyOutDir: true,
16
+ },
17
+ });
@@ -0,0 +1,20 @@
1
+ import { defineConfig } from "vite";
2
+
3
+ // Builds the Service Worker as a single, unhashed file at the web root
4
+ // (dist/public/sw.js) so it registers with scope "/" and can intercept
5
+ // /vs/<peerId>/* across the whole origin. Run after the main `vite build`
6
+ // so it doesn't wipe the app assets (emptyOutDir: false).
7
+ export default defineConfig({
8
+ build: {
9
+ outDir: "dist/public",
10
+ emptyOutDir: false,
11
+ lib: {
12
+ entry: "src/web/sw.ts",
13
+ formats: ["es"],
14
+ fileName: () => "sw.js",
15
+ },
16
+ rollupOptions: {
17
+ output: { entryFileNames: "sw.js" },
18
+ },
19
+ },
20
+ });
@@ -0,0 +1,47 @@
1
+ import { validateToken } from "../src/shared/token";
2
+ import { Room } from "./room";
3
+
4
+ export { Room };
5
+
6
+ interface Env {
7
+ ROOM: DurableObjectNamespace;
8
+ }
9
+
10
+ const CORS = {
11
+ "Access-Control-Allow-Origin": "*",
12
+ "Access-Control-Allow-Methods": "GET,OPTIONS",
13
+ "Access-Control-Allow-Headers": "*",
14
+ };
15
+
16
+ export default {
17
+ async fetch(request: Request, env: Env): Promise<Response> {
18
+ const url = new URL(request.url);
19
+
20
+ if (request.method === "OPTIONS") {
21
+ return new Response(null, { headers: CORS });
22
+ }
23
+
24
+ // GET /room/:token -> WebSocket upgrade routed to the per-token DO.
25
+ const match = url.pathname.match(/^\/room\/([^/]+)\/?$/);
26
+ if (match) {
27
+ const token = decodeURIComponent(match[1]);
28
+ // Authoritative gate: reject weak tokens here so a patched CLI/browser
29
+ // can't open a room with a guessable bearer secret.
30
+ const check = validateToken(token);
31
+ if (!check.ok) {
32
+ return new Response(`weak token: ${check.reason}`, { status: 400, headers: CORS });
33
+ }
34
+ const id = env.ROOM.idFromName(token);
35
+ const stub = env.ROOM.get(id);
36
+ return stub.fetch(request);
37
+ }
38
+
39
+ if (url.pathname === "/" || url.pathname === "/health") {
40
+ return new Response(JSON.stringify({ ok: true, service: "codehost-signal" }), {
41
+ headers: { "content-type": "application/json", ...CORS },
42
+ });
43
+ }
44
+
45
+ return new Response("not found", { status: 404, headers: CORS });
46
+ },
47
+ };
package/worker/room.ts ADDED
@@ -0,0 +1,179 @@
1
+ import type {
2
+ ClientMessage,
3
+ PeerInfo,
4
+ PeerMeta,
5
+ Role,
6
+ ServerMessage,
7
+ } from "../src/shared/signaling";
8
+
9
+ interface Attachment {
10
+ peerId: string;
11
+ role: Role;
12
+ meta: PeerMeta | null;
13
+ /** Wall-clock ms of the last message from this socket (hello / ping / signal). */
14
+ lastSeen: number;
15
+ }
16
+
17
+ /** How often the room scans for dead sockets, and how long a socket may go
18
+ * silent before eviction. Clients heartbeat every ~10s; allow ~3 misses, so a
19
+ * crashed peer drops out within ~35-50s instead of lingering as a phantom. */
20
+ const SWEEP_MS = 15_000;
21
+ const STALE_MS = 35_000;
22
+
23
+ /**
24
+ * One Durable Object instance per token-room. Holds the live WebSocket
25
+ * connections, keeps a registry of who is present, and relays WebRTC signals
26
+ * between peers. Uses the WebSocket Hibernation API so idle rooms cost nothing.
27
+ */
28
+ export class Room implements DurableObject {
29
+ private state: DurableObjectState;
30
+
31
+ constructor(state: DurableObjectState) {
32
+ this.state = state;
33
+ }
34
+
35
+ async fetch(request: Request): Promise<Response> {
36
+ if (request.headers.get("Upgrade") !== "websocket") {
37
+ return new Response("expected websocket", { status: 426 });
38
+ }
39
+ const pair = new WebSocketPair();
40
+ const [client, server] = Object.values(pair);
41
+ // Hibernatable accept: the DO can be evicted between messages and revived
42
+ // on the next event, with serializeAttachment surviving across hibernation.
43
+ this.state.acceptWebSocket(server);
44
+ return new Response(null, { status: 101, webSocket: client });
45
+ }
46
+
47
+ async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise<void> {
48
+ let msg: ClientMessage;
49
+ try {
50
+ msg = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw));
51
+ } catch {
52
+ return;
53
+ }
54
+
55
+ if (msg.type === "hello") {
56
+ const att: Attachment = {
57
+ peerId: msg.peerId,
58
+ role: msg.role,
59
+ meta: msg.meta ?? null,
60
+ lastSeen: Date.now(),
61
+ };
62
+ ws.serializeAttachment(att);
63
+ this.send(ws, { type: "welcome", peerId: msg.peerId });
64
+ this.broadcastPeers();
65
+ void this.ensureSweep();
66
+ return;
67
+ }
68
+
69
+ if (msg.type === "ping") {
70
+ this.touch(ws);
71
+ return;
72
+ }
73
+
74
+ if (msg.type === "signal") {
75
+ const att = this.touch(ws);
76
+ if (!att) return;
77
+ const target = this.findByPeerId(msg.to);
78
+ if (target) {
79
+ this.send(target, { type: "signal", from: att.peerId, data: msg.data });
80
+ }
81
+ return;
82
+ }
83
+ }
84
+
85
+ /** Periodic sweep: evict sockets that stopped heart-beating. Covers daemons
86
+ * killed with `kill -9`, whose WebSocket lingers in the room until the edge
87
+ * notices the dead TCP connection — otherwise they show as phantom servers. */
88
+ async alarm(): Promise<void> {
89
+ const now = Date.now();
90
+ let evicted = false;
91
+ for (const ws of this.state.getWebSockets()) {
92
+ const att = this.attachment(ws);
93
+ if (att && now - att.lastSeen > STALE_MS) {
94
+ try {
95
+ ws.close(1001, "stale");
96
+ } catch {
97
+ // already closing
98
+ }
99
+ evicted = true;
100
+ }
101
+ }
102
+ if (evicted) this.broadcastPeers();
103
+ // Keep sweeping while anyone is connected; let idle rooms go quiet.
104
+ if (this.state.getWebSockets().length > 0) {
105
+ await this.state.storage.setAlarm(now + SWEEP_MS);
106
+ }
107
+ }
108
+
109
+ async webSocketClose(ws: WebSocket): Promise<void> {
110
+ try {
111
+ ws.close();
112
+ } catch {
113
+ // already closing
114
+ }
115
+ this.broadcastPeers();
116
+ }
117
+
118
+ async webSocketError(ws: WebSocket): Promise<void> {
119
+ this.broadcastPeers();
120
+ }
121
+
122
+ // ---- helpers ----
123
+
124
+ private attachment(ws: WebSocket): Attachment | null {
125
+ return (ws.deserializeAttachment() as Attachment | null) ?? null;
126
+ }
127
+
128
+ /** Refresh a socket's liveness timestamp; returns its attachment if known. */
129
+ private touch(ws: WebSocket): Attachment | null {
130
+ const att = this.attachment(ws);
131
+ if (!att) return null;
132
+ att.lastSeen = Date.now();
133
+ ws.serializeAttachment(att);
134
+ return att;
135
+ }
136
+
137
+ /** Arm the sweep alarm if one isn't already pending. */
138
+ private async ensureSweep(): Promise<void> {
139
+ if ((await this.state.storage.getAlarm()) == null) {
140
+ await this.state.storage.setAlarm(Date.now() + SWEEP_MS);
141
+ }
142
+ }
143
+
144
+ private findByPeerId(peerId: string): WebSocket | null {
145
+ for (const ws of this.state.getWebSockets()) {
146
+ if (this.attachment(ws)?.peerId === peerId) return ws;
147
+ }
148
+ return null;
149
+ }
150
+
151
+ private peerList(): PeerInfo[] {
152
+ const peers: PeerInfo[] = [];
153
+ for (const ws of this.state.getWebSockets()) {
154
+ const att = this.attachment(ws);
155
+ if (att) peers.push({ peerId: att.peerId, role: att.role, meta: att.meta });
156
+ }
157
+ return peers;
158
+ }
159
+
160
+ private broadcastPeers(): void {
161
+ const message: ServerMessage = { type: "peers", peers: this.peerList() };
162
+ const payload = JSON.stringify(message);
163
+ for (const ws of this.state.getWebSockets()) {
164
+ try {
165
+ ws.send(payload);
166
+ } catch {
167
+ // socket gone; will be cleaned up on close
168
+ }
169
+ }
170
+ }
171
+
172
+ private send(ws: WebSocket, message: ServerMessage): void {
173
+ try {
174
+ ws.send(JSON.stringify(message));
175
+ } catch {
176
+ // ignore
177
+ }
178
+ }
179
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ESNext"],
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "types": ["@cloudflare/workers-types"]
11
+ },
12
+ "include": ["**/*.ts", "../src/shared/signaling.ts", "../src/shared/token.ts"]
13
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "node_modules/wrangler/config-schema.json",
3
+ "name": "codehost-signal",
4
+ "main": "index.ts",
5
+ "compatibility_date": "2025-01-01",
6
+ "account_id": "0beef4cd2d2da6befa47d8d149d6e157",
7
+ "observability": { "enabled": true },
8
+ "durable_objects": {
9
+ "bindings": [{ "name": "ROOM", "class_name": "Room" }]
10
+ },
11
+ // SQLite-backed Durable Objects are available on the Workers free plan.
12
+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }],
13
+ // The page derives its signaling URL as wss://signal.<page host>, so the
14
+ // Worker is served on signal.codehost.dev via a custom-domain route. This is
15
+ // already provisioned in Cloudflare; re-reconciling it needs a Zone-scoped
16
+ // token, so it's left out of routine deploys (uncomment for first setup).
17
+ // "routes": [{ "pattern": "signal.codehost.dev", "custom_domain": true }]
18
+ }