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
package/src/server.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { serveStatic } from "hono/bun";
|
|
4
|
+
import { cors } from "hono/cors";
|
|
5
|
+
import {
|
|
6
|
+
getOrCreateSession,
|
|
7
|
+
handleWsClose,
|
|
8
|
+
handleWsMessage,
|
|
9
|
+
handleWsOpen,
|
|
10
|
+
listSessions,
|
|
11
|
+
sessionKeyForCwd,
|
|
12
|
+
validateCwd,
|
|
13
|
+
type WsData,
|
|
14
|
+
} from "./terminal-ws";
|
|
15
|
+
|
|
16
|
+
const PORT = parseInt(process.env.PORT ?? "3001");
|
|
17
|
+
const app = new Hono();
|
|
18
|
+
|
|
19
|
+
app.use("*", cors({ origin: "*" }));
|
|
20
|
+
|
|
21
|
+
// Health check
|
|
22
|
+
app.get("/api/health", (c) => c.json({ ok: true }));
|
|
23
|
+
|
|
24
|
+
// List terminal sessions
|
|
25
|
+
app.get("/api/sessions", (c) => c.json(listSessions()));
|
|
26
|
+
|
|
27
|
+
// Serve built frontend in production
|
|
28
|
+
app.use("*", serveStatic({ root: "./dist/public" }));
|
|
29
|
+
app.get("*", serveStatic({ path: "./dist/public/index.html" }));
|
|
30
|
+
|
|
31
|
+
Bun.serve<WsData>({
|
|
32
|
+
port: PORT,
|
|
33
|
+
|
|
34
|
+
fetch(req, server) {
|
|
35
|
+
const url = new URL(req.url);
|
|
36
|
+
|
|
37
|
+
// WebSocket upgrade for terminal
|
|
38
|
+
if (url.pathname === "/ws") {
|
|
39
|
+
const rawCwd = url.searchParams.get("cwd");
|
|
40
|
+
const cols = parseInt(url.searchParams.get("cols") ?? "80");
|
|
41
|
+
const rows = parseInt(url.searchParams.get("rows") ?? "24");
|
|
42
|
+
|
|
43
|
+
let cwd: string;
|
|
44
|
+
try {
|
|
45
|
+
cwd = validateCwd(rawCwd);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
48
|
+
return new Response(msg, { status: 400 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const sessionKey = sessionKeyForCwd(cwd);
|
|
52
|
+
getOrCreateSession(sessionKey, cwd, cols, rows);
|
|
53
|
+
|
|
54
|
+
const upgraded = server.upgrade(req, { data: { sessionKey } });
|
|
55
|
+
if (upgraded) return;
|
|
56
|
+
return new Response("WebSocket upgrade failed", { status: 426 });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return app.fetch(req);
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
websocket: {
|
|
63
|
+
open: handleWsOpen,
|
|
64
|
+
message: handleWsMessage,
|
|
65
|
+
close: handleWsClose,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
console.log(`Codehost server listening on http://localhost:${PORT}`);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Binary framing for multiplexing HTTP requests and WebSocket streams over a
|
|
2
|
+
// single WebRTC data channel. One data-channel message == one frame:
|
|
3
|
+
//
|
|
4
|
+
// byte 0 : opcode
|
|
5
|
+
// bytes 1..4 : streamId (uint32, big-endian)
|
|
6
|
+
// bytes 5.. : payload (raw bytes; JSON-encoded for control frames)
|
|
7
|
+
//
|
|
8
|
+
// The browser (Service Worker / WS shim, via the page) is the client; the
|
|
9
|
+
// daemon is the server proxying to the local `code serve-web` instance.
|
|
10
|
+
|
|
11
|
+
export enum Op {
|
|
12
|
+
// HTTP client -> server
|
|
13
|
+
HttpReq = 1, // JSON { method, path, headers }
|
|
14
|
+
HttpReqBody = 2, // raw bytes
|
|
15
|
+
HttpReqEnd = 3,
|
|
16
|
+
// HTTP server -> client
|
|
17
|
+
HttpResHead = 4, // JSON { status, statusText, headers }
|
|
18
|
+
HttpResBody = 5, // raw bytes
|
|
19
|
+
HttpResEnd = 6,
|
|
20
|
+
// WebSocket client -> server
|
|
21
|
+
WsOpen = 7, // JSON { path, protocols? }
|
|
22
|
+
WsText = 9, // utf-8 text
|
|
23
|
+
WsBin = 10, // raw bytes
|
|
24
|
+
WsClose = 11, // JSON { code?, reason? }
|
|
25
|
+
// WebSocket server -> client
|
|
26
|
+
WsOpenAck = 8, // JSON { ok, protocol? }
|
|
27
|
+
// either direction
|
|
28
|
+
Error = 12, // JSON { message }
|
|
29
|
+
// WebSocket continuation: raw bytes prepended to the next WsText/WsBin frame
|
|
30
|
+
// of the same stream, so a single WS message can span multiple frames.
|
|
31
|
+
WsCont = 13,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// WebRTC data-channel messages must stay small to be portable: 16 KiB is the
|
|
35
|
+
// largest size every WebRTC stack (libdatachannel, Chrome, Firefox) reliably
|
|
36
|
+
// accepts. A frame is [op:1][streamId:4][payload], so the payload budget is
|
|
37
|
+
// 16 KiB minus the 5-byte header.
|
|
38
|
+
export const FRAME_HEADER = 5;
|
|
39
|
+
export const MAX_FRAME = 16 * 1024;
|
|
40
|
+
/** Max payload bytes per frame; larger bodies/messages are split across frames. */
|
|
41
|
+
export const MAX_CHUNK = MAX_FRAME - FRAME_HEADER;
|
|
42
|
+
|
|
43
|
+
export interface HttpReqHead {
|
|
44
|
+
method: string;
|
|
45
|
+
path: string;
|
|
46
|
+
headers: Record<string, string>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface HttpResHead {
|
|
50
|
+
status: number;
|
|
51
|
+
statusText: string;
|
|
52
|
+
headers: Record<string, string>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const enc = new TextEncoder();
|
|
56
|
+
const dec = new TextDecoder();
|
|
57
|
+
|
|
58
|
+
export function encodeFrame(op: Op, streamId: number, payload?: Uint8Array): Uint8Array {
|
|
59
|
+
const len = payload?.byteLength ?? 0;
|
|
60
|
+
const buf = new Uint8Array(5 + len);
|
|
61
|
+
buf[0] = op;
|
|
62
|
+
new DataView(buf.buffer).setUint32(1, streamId >>> 0, false);
|
|
63
|
+
if (payload && len) buf.set(payload, 5);
|
|
64
|
+
return buf;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function encodeJson(op: Op, streamId: number, obj: unknown): Uint8Array {
|
|
68
|
+
return encodeFrame(op, streamId, enc.encode(JSON.stringify(obj)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function encodeText(op: Op, streamId: number, text: string): Uint8Array {
|
|
72
|
+
return encodeFrame(op, streamId, enc.encode(text));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface DecodedFrame {
|
|
76
|
+
op: Op;
|
|
77
|
+
streamId: number;
|
|
78
|
+
payload: Uint8Array;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function decodeFrame(data: ArrayBuffer | Uint8Array): DecodedFrame {
|
|
82
|
+
const u8 = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
83
|
+
const op = u8[0] as Op;
|
|
84
|
+
const streamId = new DataView(u8.buffer, u8.byteOffset, u8.byteLength).getUint32(1, false);
|
|
85
|
+
const payload = u8.subarray(5);
|
|
86
|
+
return { op, streamId, payload };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function payloadJson<T>(payload: Uint8Array): T {
|
|
90
|
+
return JSON.parse(dec.decode(payload)) as T;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function payloadText(payload: Uint8Array): string {
|
|
94
|
+
return dec.decode(payload);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Split a body into MAX_CHUNK-sized slices (copies, safe to transfer). */
|
|
98
|
+
export function* chunk(body: Uint8Array): Generator<Uint8Array> {
|
|
99
|
+
for (let off = 0; off < body.byteLength; off += MAX_CHUNK) {
|
|
100
|
+
yield body.slice(off, Math.min(off + MAX_CHUNK, body.byteLength));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function concatBytes(parts: Uint8Array[]): Uint8Array {
|
|
105
|
+
if (parts.length === 1) return parts[0];
|
|
106
|
+
const total = parts.reduce((n, p) => n + p.byteLength, 0);
|
|
107
|
+
const out = new Uint8Array(total);
|
|
108
|
+
let off = 0;
|
|
109
|
+
for (const p of parts) {
|
|
110
|
+
out.set(p, off);
|
|
111
|
+
off += p.byteLength;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Frames for one WebSocket message. Messages that fit in a single frame emit
|
|
118
|
+
* just a WsText/WsBin frame (back-compatible). Larger ones are split into
|
|
119
|
+
* WsCont frames carrying the leading bytes, terminated by the WsText/WsBin
|
|
120
|
+
* frame with the final bytes; the receiver concatenates them in order.
|
|
121
|
+
*/
|
|
122
|
+
export function* wsMessageFrames(
|
|
123
|
+
terminal: Op.WsText | Op.WsBin,
|
|
124
|
+
streamId: number,
|
|
125
|
+
payload: Uint8Array,
|
|
126
|
+
): Generator<Uint8Array> {
|
|
127
|
+
let off = 0;
|
|
128
|
+
while (payload.byteLength - off > MAX_CHUNK) {
|
|
129
|
+
yield encodeFrame(Op.WsCont, streamId, payload.subarray(off, off + MAX_CHUNK));
|
|
130
|
+
off += MAX_CHUNK;
|
|
131
|
+
}
|
|
132
|
+
yield encodeFrame(terminal, streamId, payload.subarray(off));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Reassembles WsCont + terminal frames back into whole WebSocket messages,
|
|
137
|
+
* keyed by streamId. Feed it every WsCont/WsText/WsBin payload; it returns the
|
|
138
|
+
* complete message bytes on a terminal frame, or null while buffering.
|
|
139
|
+
*/
|
|
140
|
+
export class WsReassembler {
|
|
141
|
+
private pending = new Map<number, Uint8Array[]>();
|
|
142
|
+
|
|
143
|
+
cont(streamId: number, payload: Uint8Array): void {
|
|
144
|
+
const buf = this.pending.get(streamId);
|
|
145
|
+
if (buf) buf.push(payload.slice());
|
|
146
|
+
else this.pending.set(streamId, [payload.slice()]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
finish(streamId: number, payload: Uint8Array): Uint8Array {
|
|
150
|
+
const buf = this.pending.get(streamId);
|
|
151
|
+
if (!buf) return payload;
|
|
152
|
+
this.pending.delete(streamId);
|
|
153
|
+
buf.push(payload);
|
|
154
|
+
return concatBytes(buf);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
drop(streamId: number): void {
|
|
158
|
+
this.pending.delete(streamId);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// WebRTC signal payloads carried inside the signaling relay's `data` field.
|
|
2
|
+
// The browser (viewer) is always the initiator/offerer; the daemon (server)
|
|
3
|
+
// answers. STUN-only for v1 (TURN can be added to ICE_SERVERS later).
|
|
4
|
+
|
|
5
|
+
export const ICE_SERVERS = [
|
|
6
|
+
"stun:stun.l.google.com:19302",
|
|
7
|
+
"stun:stun1.l.google.com:19302",
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export interface SdpSignal {
|
|
11
|
+
kind: "offer" | "answer";
|
|
12
|
+
type: "offer" | "answer";
|
|
13
|
+
sdp: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CandidateSignal {
|
|
17
|
+
kind: "candidate";
|
|
18
|
+
candidate: string;
|
|
19
|
+
/** sdpMid / media line id. */
|
|
20
|
+
mid: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type RtcSignal = SdpSignal | CandidateSignal;
|
|
24
|
+
|
|
25
|
+
/** Label used for the control/tunnel data channel. */
|
|
26
|
+
export const CHANNEL_LABEL = "codehost";
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ClientMessage,
|
|
3
|
+
type PeerInfo,
|
|
4
|
+
type PeerMeta,
|
|
5
|
+
type Role,
|
|
6
|
+
type ServerMessage,
|
|
7
|
+
newPeerId,
|
|
8
|
+
} from "./signaling";
|
|
9
|
+
|
|
10
|
+
export interface SignalingClientOptions {
|
|
11
|
+
/** Base signaling URL, e.g. wss://signal.codehost.dev */
|
|
12
|
+
url: string;
|
|
13
|
+
token: string;
|
|
14
|
+
role: Role;
|
|
15
|
+
meta?: PeerMeta;
|
|
16
|
+
peerId?: string;
|
|
17
|
+
onPeers?: (peers: PeerInfo[]) => void;
|
|
18
|
+
onSignal?: (from: string, data: unknown) => void;
|
|
19
|
+
onOpen?: () => void;
|
|
20
|
+
onClose?: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Thin WebSocket client for the signaling room. Runs unchanged in the browser
|
|
25
|
+
* and in Bun (both expose a global `WebSocket`). Auto-reconnects with backoff
|
|
26
|
+
* and re-sends `hello` on every (re)connect.
|
|
27
|
+
*/
|
|
28
|
+
export class SignalingClient {
|
|
29
|
+
readonly peerId: string;
|
|
30
|
+
private ws: WebSocket | null = null;
|
|
31
|
+
private closed = false;
|
|
32
|
+
private reconnectDelay = 1000;
|
|
33
|
+
private heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
34
|
+
|
|
35
|
+
constructor(private opts: SignalingClientOptions) {
|
|
36
|
+
this.peerId = opts.peerId ?? newPeerId();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
connect(): void {
|
|
40
|
+
this.closed = false;
|
|
41
|
+
this.open();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private roomUrl(): string {
|
|
45
|
+
const base = this.opts.url.replace(/\/+$/, "");
|
|
46
|
+
return `${base}/room/${encodeURIComponent(this.opts.token)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private open(): void {
|
|
50
|
+
const ws = new WebSocket(this.roomUrl());
|
|
51
|
+
this.ws = ws;
|
|
52
|
+
|
|
53
|
+
ws.onopen = () => {
|
|
54
|
+
this.reconnectDelay = 1000;
|
|
55
|
+
const hello: ClientMessage = {
|
|
56
|
+
type: "hello",
|
|
57
|
+
role: this.opts.role,
|
|
58
|
+
peerId: this.peerId,
|
|
59
|
+
...(this.opts.meta ? { meta: this.opts.meta } : {}),
|
|
60
|
+
};
|
|
61
|
+
ws.send(JSON.stringify(hello));
|
|
62
|
+
this.startHeartbeat();
|
|
63
|
+
this.opts.onOpen?.();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
ws.onmessage = (ev: MessageEvent) => {
|
|
67
|
+
let msg: ServerMessage;
|
|
68
|
+
try {
|
|
69
|
+
msg = JSON.parse(String(ev.data));
|
|
70
|
+
} catch {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (msg.type === "peers") this.opts.onPeers?.(msg.peers);
|
|
74
|
+
else if (msg.type === "signal") this.opts.onSignal?.(msg.from, msg.data);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
ws.onclose = () => {
|
|
78
|
+
this.stopHeartbeat();
|
|
79
|
+
this.opts.onClose?.();
|
|
80
|
+
if (!this.closed) this.scheduleReconnect();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
ws.onerror = () => {
|
|
84
|
+
try {
|
|
85
|
+
ws.close();
|
|
86
|
+
} catch {
|
|
87
|
+
// ignore
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Heartbeat keeps the room's liveness sweep from evicting us. At 10s, the DO
|
|
93
|
+
// (STALE_MS 35s) tolerates ~3 missed beats before treating us as gone — fast
|
|
94
|
+
// enough that a crashed peer stops showing as a phantom server within ~1 sweep.
|
|
95
|
+
private startHeartbeat(): void {
|
|
96
|
+
this.stopHeartbeat();
|
|
97
|
+
this.heartbeat = setInterval(() => {
|
|
98
|
+
try {
|
|
99
|
+
this.ws?.send(JSON.stringify({ type: "ping" }));
|
|
100
|
+
} catch {
|
|
101
|
+
// socket gone; onclose will reconnect
|
|
102
|
+
}
|
|
103
|
+
}, 10000);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private stopHeartbeat(): void {
|
|
107
|
+
if (this.heartbeat != null) {
|
|
108
|
+
clearInterval(this.heartbeat);
|
|
109
|
+
this.heartbeat = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private scheduleReconnect(): void {
|
|
114
|
+
const delay = this.reconnectDelay;
|
|
115
|
+
this.reconnectDelay = Math.min(delay * 2, 15000);
|
|
116
|
+
setTimeout(() => {
|
|
117
|
+
if (!this.closed) this.open();
|
|
118
|
+
}, delay);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
sendSignal(to: string, data: unknown): void {
|
|
122
|
+
const msg: ClientMessage = { type: "signal", to, data };
|
|
123
|
+
this.ws?.send(JSON.stringify(msg));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
close(): void {
|
|
127
|
+
this.closed = true;
|
|
128
|
+
this.stopHeartbeat();
|
|
129
|
+
try {
|
|
130
|
+
this.ws?.close();
|
|
131
|
+
} catch {
|
|
132
|
+
// ignore
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Signaling protocol shared by the browser, the CLI daemon, and the Cloudflare
|
|
2
|
+
// Worker / Durable Object. A "room" is keyed by the user's token; every member
|
|
3
|
+
// of a room can see the others and exchange WebRTC SDP/ICE via the relay.
|
|
4
|
+
|
|
5
|
+
export type Role = "server" | "viewer";
|
|
6
|
+
|
|
7
|
+
/** Metadata a `codehost serve` daemon advertises about itself. */
|
|
8
|
+
export interface PeerMeta {
|
|
9
|
+
/** Human label, defaults to hostname. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** Directory the VS Code instance is serving. */
|
|
12
|
+
cwd: string;
|
|
13
|
+
/** Hostname of the machine running the daemon. */
|
|
14
|
+
host: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PeerInfo {
|
|
18
|
+
peerId: string;
|
|
19
|
+
role: Role;
|
|
20
|
+
meta: PeerMeta | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---- Client -> Server ----
|
|
24
|
+
|
|
25
|
+
/** First message after connecting: identify role + (for servers) metadata. */
|
|
26
|
+
export interface HelloMessage {
|
|
27
|
+
type: "hello";
|
|
28
|
+
role: Role;
|
|
29
|
+
peerId: string;
|
|
30
|
+
meta?: PeerMeta;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Relay a WebRTC signal (offer / answer / ICE candidate) to another peer. */
|
|
34
|
+
export interface SignalMessage {
|
|
35
|
+
type: "signal";
|
|
36
|
+
to: string;
|
|
37
|
+
/** Opaque payload: { sdp } or { candidate }. */
|
|
38
|
+
data: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Liveness heartbeat. The room evicts members that stop sending these, so a
|
|
42
|
+
* hard-killed daemon (whose WebSocket lingers until the edge times it out)
|
|
43
|
+
* doesn't haunt the peer list as a dead server. */
|
|
44
|
+
export interface PingMessage {
|
|
45
|
+
type: "ping";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ClientMessage = HelloMessage | SignalMessage | PingMessage;
|
|
49
|
+
|
|
50
|
+
// ---- Server -> Client ----
|
|
51
|
+
|
|
52
|
+
/** Confirms connection and echoes the assigned peerId. */
|
|
53
|
+
export interface WelcomeMessage {
|
|
54
|
+
type: "welcome";
|
|
55
|
+
peerId: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Current room membership; sent on join and whenever it changes. */
|
|
59
|
+
export interface PeersMessage {
|
|
60
|
+
type: "peers";
|
|
61
|
+
peers: PeerInfo[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A signal relayed from another peer. */
|
|
65
|
+
export interface RelayedSignalMessage {
|
|
66
|
+
type: "signal";
|
|
67
|
+
from: string;
|
|
68
|
+
data: unknown;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type ServerMessage = WelcomeMessage | PeersMessage | RelayedSignalMessage;
|
|
72
|
+
|
|
73
|
+
export function newPeerId(): string {
|
|
74
|
+
return crypto.randomUUID();
|
|
75
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Room-token complexity policy, shared by the browser, the CLI daemon, and the
|
|
2
|
+
// Cloudflare Worker. The token is a bearer secret — anyone who has it can see
|
|
3
|
+
// and connect to every server in the room — so a guessable token is a real
|
|
4
|
+
// compromise. These rules are the single source of truth; all three entry
|
|
5
|
+
// points (CLI `serve`, the codehost.dev token form, and the Worker's /room
|
|
6
|
+
// route) validate against them so a weak token can't slip in from any side.
|
|
7
|
+
|
|
8
|
+
/** Minimum token length. Short tokens are brute-forceable bearer secrets. */
|
|
9
|
+
export const TOKEN_MIN_LENGTH = 12;
|
|
10
|
+
/** Upper bound, mostly to keep URLs and storage sane. */
|
|
11
|
+
export const TOKEN_MAX_LENGTH = 256;
|
|
12
|
+
/** How many of {lowercase, uppercase, digit, symbol} a token must mix. */
|
|
13
|
+
export const TOKEN_MIN_CHAR_CLASSES = 3;
|
|
14
|
+
|
|
15
|
+
/** Human-readable summary of the policy, for CLI errors and the UI hint. */
|
|
16
|
+
export const TOKEN_REQUIREMENTS =
|
|
17
|
+
`at least ${TOKEN_MIN_LENGTH} characters, no spaces, ` +
|
|
18
|
+
`mixing at least ${TOKEN_MIN_CHAR_CLASSES} of: lowercase, uppercase, digits, symbols`;
|
|
19
|
+
|
|
20
|
+
// Obviously weak tokens that technically pass the structural rules but are the
|
|
21
|
+
// first things an attacker tries. Compared case-insensitively after trimming.
|
|
22
|
+
const WEAK_TOKENS = new Set([
|
|
23
|
+
"changeme1234",
|
|
24
|
+
"password1234",
|
|
25
|
+
"codehost1234",
|
|
26
|
+
"letmein12345",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const CLASS_PATTERNS = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^a-zA-Z0-9]/];
|
|
30
|
+
|
|
31
|
+
export interface TokenCheck {
|
|
32
|
+
ok: boolean;
|
|
33
|
+
/** Present when `ok` is false: why the token was rejected. */
|
|
34
|
+
reason?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validate a room token against the complexity policy. Does not trim — callers
|
|
39
|
+
* should pass the already-trimmed token (a leading/trailing space is itself a
|
|
40
|
+
* footgun in a shared secret, so embedded whitespace is rejected outright).
|
|
41
|
+
*/
|
|
42
|
+
export function validateToken(token: string): TokenCheck {
|
|
43
|
+
if (token.length < TOKEN_MIN_LENGTH) {
|
|
44
|
+
return { ok: false, reason: `token must be at least ${TOKEN_MIN_LENGTH} characters` };
|
|
45
|
+
}
|
|
46
|
+
if (token.length > TOKEN_MAX_LENGTH) {
|
|
47
|
+
return { ok: false, reason: `token must be at most ${TOKEN_MAX_LENGTH} characters` };
|
|
48
|
+
}
|
|
49
|
+
if (/\s/.test(token)) {
|
|
50
|
+
return { ok: false, reason: "token must not contain whitespace" };
|
|
51
|
+
}
|
|
52
|
+
const classes = CLASS_PATTERNS.filter((re) => re.test(token)).length;
|
|
53
|
+
if (classes < TOKEN_MIN_CHAR_CLASSES) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
reason: `token must mix at least ${TOKEN_MIN_CHAR_CLASSES} of: lowercase, uppercase, digits, symbols`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (WEAK_TOKENS.has(token.toLowerCase())) {
|
|
60
|
+
return { ok: false, reason: "token is too common; choose a unique secret" };
|
|
61
|
+
}
|
|
62
|
+
return { ok: true };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Convenience: a strong random token that satisfies the policy. */
|
|
66
|
+
export function generateToken(): string {
|
|
67
|
+
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789-_";
|
|
68
|
+
const bytes = new Uint8Array(24);
|
|
69
|
+
crypto.getRandomValues(bytes);
|
|
70
|
+
let out = "";
|
|
71
|
+
for (const b of bytes) out += alphabet[b % alphabet.length];
|
|
72
|
+
// Guarantee class coverage regardless of how the random draw landed.
|
|
73
|
+
return `${out}aA1-`;
|
|
74
|
+
}
|