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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sno
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# codehost
|
|
2
|
+
|
|
3
|
+
Run VS Code on any machine and reach it from **codehost.dev** over a direct
|
|
4
|
+
peer-to-peer **WebRTC** connection — no public ingress, no reverse proxy, no
|
|
5
|
+
port forwarding. The daemon behind your NAT and the browser tab connect
|
|
6
|
+
directly; a tiny Cloudflare Worker only brokers the handshake.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
Browser (codehost.dev) Your machine (codehost serve)
|
|
10
|
+
┌──────────────────────────┐ ┌──────────────────────────────┐
|
|
11
|
+
│ Discovery: list servers │ │ codehost CLI (yargs) │
|
|
12
|
+
│ Service Worker: /vs/<id>/* │ WebRTC │ node-datachannel peer │
|
|
13
|
+
│ ─────────────────────────▶│◀═ data channel ══▶│ HTTP/WS proxy → code serve-web │
|
|
14
|
+
│ <iframe> VS Code │ │ in your project directory │
|
|
15
|
+
└────────────┬─────────────┘ └───────────────┬──────────────┘y -c
|
|
16
|
+
│ WebSocket (signaling) │ WebSocket (signaling)
|
|
17
|
+
└───────────────────► Cloudflare Worker + DO ◄──────┘
|
|
18
|
+
per-token room: registry + SDP/ICE relay
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quickstart
|
|
22
|
+
|
|
23
|
+
On the machine you want to edit (needs the `code` CLI and [Bun](https://bun.sh)):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bunx codehost serve -d -t <token>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Then open **https://codehost.dev**, enter the same `<token>`, and your server
|
|
30
|
+
appears in the list. Click **Connect** — VS Code loads in the page, served
|
|
31
|
+
entirely over the peer-to-peer data channel.
|
|
32
|
+
|
|
33
|
+
The `<token>` is a shared secret: anyone with it can see and connect to the
|
|
34
|
+
servers in that room, so treat it like a password. It must be **at least 12
|
|
35
|
+
characters, contain no whitespace, and mix at least 3 of {lowercase, uppercase,
|
|
36
|
+
digits, symbols}** — the CLI, the web page, and the signaling Worker all reject
|
|
37
|
+
weaker tokens (e.g. `Str0ng-Token-99`).
|
|
38
|
+
|
|
39
|
+
## CLI
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
codehost serve [dir] -t <token> [options] # serve a directory (default: cwd)
|
|
43
|
+
codehost list # list daemonized servers (oxmgr)
|
|
44
|
+
codehost stop <name> # stop a daemonized server
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`serve` options:
|
|
48
|
+
|
|
49
|
+
| flag | default | meaning |
|
|
50
|
+
|------|---------|---------|
|
|
51
|
+
| `-t, --token` | (required) | room token shared with the codehost.dev page |
|
|
52
|
+
| `-d, --daemon` | `false` | run in the background under [oxmgr](https://www.npmjs.com/package/oxmgr) (auto-restart) |
|
|
53
|
+
| `--name` | hostname | display name shown on the page |
|
|
54
|
+
| `--signal` | `wss://signal.codehost.dev` | signaling server URL |
|
|
55
|
+
| `--port` | ephemeral | fixed port for the local `code serve-web` |
|
|
56
|
+
|
|
57
|
+
Daemon mode requires `oxmgr` (`npm i -g oxmgr`). Without `-d`, `serve` runs in
|
|
58
|
+
the foreground and stops on Ctrl-C.
|
|
59
|
+
|
|
60
|
+
## How it works
|
|
61
|
+
|
|
62
|
+
1. **Signaling** — `worker/` is a Cloudflare Worker whose Durable Object hosts
|
|
63
|
+
one room per token. Daemons register their metadata; the page lists the
|
|
64
|
+
room's servers; the DO relays WebRTC offer/answer/ICE between them. STUN-only
|
|
65
|
+
(no TURN yet), so strict/symmetric NATs may not connect.
|
|
66
|
+
2. **WebRTC** — the daemon uses `node-datachannel` (native, loaded via
|
|
67
|
+
`createRequire` so it resolves under Bun); the browser uses the standard
|
|
68
|
+
`RTCPeerConnection`. The browser is the offerer.
|
|
69
|
+
3. **Tunnel** — `code serve-web` runs on `127.0.0.1` under base path
|
|
70
|
+
`/vs/<peerId>`. The browser Service Worker intercepts `/vs/<peerId>/*` and
|
|
71
|
+
forwards each HTTP request to the page, which ships it over the data channel;
|
|
72
|
+
the daemon (`src/cli/tunnel.ts`) replays it against the local VS Code server
|
|
73
|
+
and streams the response back. VS Code's WebSockets are routed the same way
|
|
74
|
+
via an injected `window.WebSocket` shim inside the iframe.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
bun install
|
|
80
|
+
bun run dev # web terminal scaffold (legacy) on :5173 + :3001
|
|
81
|
+
bun run dev:signal # signaling Worker locally (wrangler dev on :8787)
|
|
82
|
+
bun run cli -- serve . -t test --signal ws://localhost:8787 # run the daemon
|
|
83
|
+
bun run typecheck # app + service worker + worker programs
|
|
84
|
+
bun run build # builds dist/public (app + /sw.js)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
In local dev the page (on `localhost`) auto-targets `ws://localhost:8787`;
|
|
88
|
+
override the signaling URL anytime via `localStorage["codehost.signal"]`.
|
|
89
|
+
|
|
90
|
+
## Deploy
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
bun run deploy:signal # cd worker && wrangler deploy (Durable Object signaling)
|
|
94
|
+
bun run deploy:pages # vite build && wrangler pages deploy dist/public --project-name codehost
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The signaling Worker is bound to a `signal.` subdomain of the zone; the page is
|
|
98
|
+
the `codehost` Pages project on the `codehost.dev` custom domain (Cloudflare
|
|
99
|
+
account `SNOLAB`). After deploying the Worker the first time, add its
|
|
100
|
+
`signal.codehost.dev` custom-domain route (commented in `worker/wrangler.jsonc`).
|
|
101
|
+
|
|
102
|
+
## Status / limitations
|
|
103
|
+
|
|
104
|
+
- Verified live end-to-end across two networks: a daemon behind one NAT and a
|
|
105
|
+
browser on `codehost.dev` (another network) connect over STUN, the VS Code
|
|
106
|
+
workbench loads in the iframe, the Explorer lists the real workspace, and
|
|
107
|
+
files open in the editor — all HTTP + WebSocket traffic over the data channel.
|
|
108
|
+
- Data-channel frames are capped at 16 KiB (the portable WebRTC limit); HTTP
|
|
109
|
+
bodies and WebSocket messages larger than that are fragmented and reassembled
|
|
110
|
+
(`src/shared/protocol.ts`).
|
|
111
|
+
- STUN-only: add Cloudflare Realtime TURN for strict/symmetric-NAT reachability.
|
|
112
|
+
- Token = bearer auth for now; no per-user identity yet.
|
package/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Codehost</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "codehost",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"codehost": "./src/cli/index.ts"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "concurrently \"bun run dev:server\" \"vite --port 5173\"",
|
|
10
|
+
"dev:server": "bun --watch src/server.ts",
|
|
11
|
+
"dev:signal": "cd worker && wrangler dev",
|
|
12
|
+
"cli": "bun src/cli/index.ts",
|
|
13
|
+
"build": "vite build && vite build --config vite.sw.config.ts",
|
|
14
|
+
"deploy:signal": "cd worker && wrangler deploy",
|
|
15
|
+
"deploy:pages": "vite build && wrangler pages deploy dist/public --project-name codehost",
|
|
16
|
+
"start": "bun src/server.ts",
|
|
17
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p src/web/tsconfig.sw.json && tsc --noEmit -p worker/tsconfig.json"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@xterm/addon-clipboard": "^0.2.0",
|
|
21
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
22
|
+
"@xterm/addon-search": "^0.15.0",
|
|
23
|
+
"@xterm/addon-unicode11": "^0.9.0",
|
|
24
|
+
"@xterm/addon-web-links": "^0.11.0",
|
|
25
|
+
"@xterm/headless": "^6.0.0",
|
|
26
|
+
"@xterm/xterm": "^6.0.0",
|
|
27
|
+
"bun-pty": "^0.4.8",
|
|
28
|
+
"hono": "^4.12.16",
|
|
29
|
+
"node-datachannel": "^0.32.3",
|
|
30
|
+
"react": "^19.1.1",
|
|
31
|
+
"react-dom": "^19.1.1",
|
|
32
|
+
"yargs": "^17.7.2"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "^1.3.0",
|
|
36
|
+
"@types/react": "^19.1.9",
|
|
37
|
+
"@types/react-dom": "^19.1.7",
|
|
38
|
+
"@types/yargs": "^17.0.33",
|
|
39
|
+
"@cloudflare/workers-types": "^4.20250101.0",
|
|
40
|
+
"wrangler": "^4.95.0",
|
|
41
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
42
|
+
"concurrently": "^9.2.1",
|
|
43
|
+
"portless": "^0.12.0",
|
|
44
|
+
"typescript": "^5",
|
|
45
|
+
"vite": "^8.0.10"
|
|
46
|
+
},
|
|
47
|
+
"trustedDependencies": [
|
|
48
|
+
"node-datachannel"
|
|
49
|
+
]
|
|
50
|
+
}
|
package/portless.json
ADDED
package/src/App.tsx
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Terminal } from "./components/Terminal";
|
|
3
|
+
|
|
4
|
+
export function App() {
|
|
5
|
+
const [cwd, setCwd] = useState(() => {
|
|
6
|
+
const params = new URLSearchParams(window.location.search);
|
|
7
|
+
return params.get("cwd") ?? "/";
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: "#1f1f1f" }}>
|
|
12
|
+
<header
|
|
13
|
+
style={{
|
|
14
|
+
display: "flex",
|
|
15
|
+
alignItems: "center",
|
|
16
|
+
gap: 8,
|
|
17
|
+
padding: "6px 12px",
|
|
18
|
+
background: "#2d2d2d",
|
|
19
|
+
borderBottom: "1px solid #3d3d3d",
|
|
20
|
+
flexShrink: 0,
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
<span style={{ color: "#cccccc", fontFamily: "monospace", fontSize: 13, fontWeight: 600 }}>
|
|
24
|
+
codehost
|
|
25
|
+
</span>
|
|
26
|
+
<span style={{ color: "#888", fontSize: 12 }}>|</span>
|
|
27
|
+
<CwdInput cwd={cwd} onChange={setCwd} />
|
|
28
|
+
</header>
|
|
29
|
+
<Terminal wsUrl="/ws" cwd={cwd} onCwdChange={setCwd} style={{ flex: 1, minHeight: 0 }} />
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function CwdInput({ cwd, onChange }: { cwd: string; onChange: (cwd: string) => void }) {
|
|
35
|
+
const [editing, setEditing] = useState(false);
|
|
36
|
+
const [draft, setDraft] = useState(cwd);
|
|
37
|
+
|
|
38
|
+
if (editing) {
|
|
39
|
+
return (
|
|
40
|
+
<form
|
|
41
|
+
onSubmit={(e) => {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
onChange(draft);
|
|
44
|
+
setEditing(false);
|
|
45
|
+
}}
|
|
46
|
+
style={{ flex: 1 }}
|
|
47
|
+
>
|
|
48
|
+
<input
|
|
49
|
+
autoFocus
|
|
50
|
+
value={draft}
|
|
51
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
52
|
+
onBlur={() => setEditing(false)}
|
|
53
|
+
onKeyDown={(e) => e.key === "Escape" && setEditing(false)}
|
|
54
|
+
style={{
|
|
55
|
+
width: "100%",
|
|
56
|
+
background: "#1f1f1f",
|
|
57
|
+
border: "1px solid #555",
|
|
58
|
+
color: "#ccc",
|
|
59
|
+
fontFamily: "monospace",
|
|
60
|
+
fontSize: 12,
|
|
61
|
+
padding: "2px 6px",
|
|
62
|
+
borderRadius: 3,
|
|
63
|
+
outline: "none",
|
|
64
|
+
}}
|
|
65
|
+
/>
|
|
66
|
+
</form>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<button
|
|
72
|
+
onClick={() => { setDraft(cwd); setEditing(true); }}
|
|
73
|
+
title="Click to change directory"
|
|
74
|
+
style={{
|
|
75
|
+
background: "none",
|
|
76
|
+
border: "none",
|
|
77
|
+
cursor: "pointer",
|
|
78
|
+
color: "#888",
|
|
79
|
+
fontFamily: "monospace",
|
|
80
|
+
fontSize: 12,
|
|
81
|
+
padding: "2px 4px",
|
|
82
|
+
borderRadius: 3,
|
|
83
|
+
textAlign: "left",
|
|
84
|
+
maxWidth: 500,
|
|
85
|
+
overflow: "hidden",
|
|
86
|
+
textOverflow: "ellipsis",
|
|
87
|
+
whiteSpace: "nowrap",
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
{cwd}
|
|
91
|
+
</button>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CommandModule } from "yargs";
|
|
2
|
+
import { listDaemons } from "../oxmgr";
|
|
3
|
+
|
|
4
|
+
export const listCommand: CommandModule = {
|
|
5
|
+
command: "list",
|
|
6
|
+
aliases: ["ls"],
|
|
7
|
+
describe: "List codehost servers running under oxmgr",
|
|
8
|
+
handler: () => {
|
|
9
|
+
process.exit(listDaemons());
|
|
10
|
+
},
|
|
11
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { CommandModule } from "yargs";
|
|
4
|
+
import { type PeerMeta, newPeerId } from "../../shared/signaling";
|
|
5
|
+
import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
|
|
6
|
+
import { SignalingClient } from "../../shared/signaling-client";
|
|
7
|
+
import { RtcDaemon } from "../rtc-daemon";
|
|
8
|
+
import { launchVscode } from "../vscode";
|
|
9
|
+
import { Tunnel } from "../tunnel";
|
|
10
|
+
import { daemonName, startDaemon } from "../oxmgr";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_SIGNAL_URL = "wss://signal.codehost.dev";
|
|
13
|
+
|
|
14
|
+
interface ServeArgs {
|
|
15
|
+
dir: string;
|
|
16
|
+
token: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
signal: string;
|
|
19
|
+
daemon: boolean;
|
|
20
|
+
port?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
24
|
+
command: "serve [dir]",
|
|
25
|
+
describe: "Serve VS Code from a directory and peer it to codehost.dev over WebRTC",
|
|
26
|
+
builder: (y) =>
|
|
27
|
+
y
|
|
28
|
+
.positional("dir", {
|
|
29
|
+
describe: "Directory to serve (defaults to cwd)",
|
|
30
|
+
type: "string",
|
|
31
|
+
default: ".",
|
|
32
|
+
})
|
|
33
|
+
.option("token", {
|
|
34
|
+
alias: "t",
|
|
35
|
+
describe: "Room token shared with the codehost.dev page",
|
|
36
|
+
type: "string",
|
|
37
|
+
demandOption: true,
|
|
38
|
+
})
|
|
39
|
+
.option("name", {
|
|
40
|
+
describe: "Display name for this server (defaults to hostname)",
|
|
41
|
+
type: "string",
|
|
42
|
+
})
|
|
43
|
+
.option("signal", {
|
|
44
|
+
describe: "Signaling server URL",
|
|
45
|
+
type: "string",
|
|
46
|
+
default: DEFAULT_SIGNAL_URL,
|
|
47
|
+
})
|
|
48
|
+
.option("daemon", {
|
|
49
|
+
alias: "d",
|
|
50
|
+
describe: "Run in the background under oxmgr",
|
|
51
|
+
type: "boolean",
|
|
52
|
+
default: false,
|
|
53
|
+
})
|
|
54
|
+
.option("port", {
|
|
55
|
+
describe: "Fixed port for the local VS Code server (default: ephemeral)",
|
|
56
|
+
type: "number",
|
|
57
|
+
}) as any,
|
|
58
|
+
handler: async (argv) => {
|
|
59
|
+
argv.token = argv.token.trim();
|
|
60
|
+
const check = validateToken(argv.token);
|
|
61
|
+
if (!check.ok) {
|
|
62
|
+
console.error(`[codehost] ${check.reason}`);
|
|
63
|
+
console.error(`[codehost] room token requires: ${TOKEN_REQUIREMENTS}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const dir = resolve(process.cwd(), argv.dir);
|
|
68
|
+
const host = hostname();
|
|
69
|
+
const meta: PeerMeta = {
|
|
70
|
+
name: argv.name ?? host,
|
|
71
|
+
cwd: dir,
|
|
72
|
+
host,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// `-d`: re-launch this same `serve` (without -d) under oxmgr, then exit.
|
|
76
|
+
if (argv.daemon) {
|
|
77
|
+
const label = argv.name ?? dir.split("/").pop() ?? host;
|
|
78
|
+
const name = daemonName(label);
|
|
79
|
+
const command = buildForegroundCommand(dir, argv);
|
|
80
|
+
console.log(`[codehost] starting daemon "${name}" via oxmgr`);
|
|
81
|
+
const ok = startDaemon({ name, command, cwd: dir });
|
|
82
|
+
if (ok) {
|
|
83
|
+
console.log(`[codehost] daemon started. View: codehost list · Stop: codehost stop ${name}`);
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// peerId is fixed up front so VS Code can be mounted under /vs/<peerId>,
|
|
90
|
+
// which the browser Service Worker uses to route requests to this daemon.
|
|
91
|
+
const peerId = newPeerId();
|
|
92
|
+
const basePath = `/vs/${peerId}`;
|
|
93
|
+
|
|
94
|
+
console.log(`[codehost] serving ${dir}`);
|
|
95
|
+
console.log(`[codehost] room token: ${argv.token}`);
|
|
96
|
+
console.log(`[codehost] signaling: ${argv.signal}`);
|
|
97
|
+
|
|
98
|
+
const vscode = await launchVscode({ dir, basePath, port: argv.port });
|
|
99
|
+
|
|
100
|
+
let rtc: RtcDaemon;
|
|
101
|
+
|
|
102
|
+
const client = new SignalingClient({
|
|
103
|
+
url: argv.signal,
|
|
104
|
+
token: argv.token,
|
|
105
|
+
role: "server",
|
|
106
|
+
peerId,
|
|
107
|
+
meta,
|
|
108
|
+
onOpen: () => console.log(`[codehost] registered as "${meta.name}" (${peerId.slice(0, 8)})`),
|
|
109
|
+
onClose: () => console.log("[codehost] disconnected from signaling, reconnecting…"),
|
|
110
|
+
onSignal: (from, data) => rtc.handleSignal(from, data),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
rtc = new RtcDaemon({
|
|
114
|
+
sendSignal: (to, data) => client.sendSignal(to, data),
|
|
115
|
+
// Each viewer's data channel is bridged to the local VS Code server.
|
|
116
|
+
onChannel: (viewerId, channel) => {
|
|
117
|
+
console.log(`[codehost] viewer ${viewerId.slice(0, 8)} connected; bridging to VS Code`);
|
|
118
|
+
new Tunnel(channel, vscode.port);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
client.connect();
|
|
123
|
+
|
|
124
|
+
const shutdown = () => {
|
|
125
|
+
console.log("\n[codehost] shutting down");
|
|
126
|
+
rtc.closeAll();
|
|
127
|
+
client.close();
|
|
128
|
+
vscode.stop();
|
|
129
|
+
process.exit(0);
|
|
130
|
+
};
|
|
131
|
+
process.on("SIGINT", shutdown);
|
|
132
|
+
process.on("SIGTERM", shutdown);
|
|
133
|
+
|
|
134
|
+
// Keep the process alive.
|
|
135
|
+
await new Promise<never>(() => {});
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Reconstruct the exact foreground `serve` invocation (without -d) for oxmgr to
|
|
141
|
+
* run. Uses the same runtime + entry script that launched us, so it works both
|
|
142
|
+
* for `bunx codehost` and local `bun src/cli/index.ts`.
|
|
143
|
+
*/
|
|
144
|
+
function buildForegroundCommand(dir: string, argv: ServeArgs): string {
|
|
145
|
+
const parts = [process.execPath, process.argv[1], "serve", dir, "-t", argv.token, "--signal", argv.signal];
|
|
146
|
+
if (argv.name) parts.push("--name", argv.name);
|
|
147
|
+
if (argv.port) parts.push("--port", String(argv.port));
|
|
148
|
+
return parts.map(quote).join(" ");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function quote(s: string): string {
|
|
152
|
+
return /[^A-Za-z0-9_\/:=.-]/.test(s) ? `'${s.replace(/'/g, `'\\''`)}'` : s;
|
|
153
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CommandModule } from "yargs";
|
|
2
|
+
import { stopDaemon } from "../oxmgr";
|
|
3
|
+
|
|
4
|
+
interface StopArgs {
|
|
5
|
+
name: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const stopCommand: CommandModule<{}, StopArgs> = {
|
|
9
|
+
command: "stop <name>",
|
|
10
|
+
describe: "Stop a daemonized codehost server (name from `codehost list`)",
|
|
11
|
+
builder: (y) =>
|
|
12
|
+
y.positional("name", {
|
|
13
|
+
describe: "Daemon name, e.g. codehost-myproject (or just the label)",
|
|
14
|
+
type: "string",
|
|
15
|
+
demandOption: true,
|
|
16
|
+
}) as any,
|
|
17
|
+
handler: (argv) => {
|
|
18
|
+
process.exit(stopDaemon(argv.name));
|
|
19
|
+
},
|
|
20
|
+
};
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import yargs from "yargs";
|
|
3
|
+
import { hideBin } from "yargs/helpers";
|
|
4
|
+
import { serveCommand } from "./commands/serve";
|
|
5
|
+
import { listCommand } from "./commands/list";
|
|
6
|
+
import { stopCommand } from "./commands/stop";
|
|
7
|
+
|
|
8
|
+
yargs(hideBin(process.argv))
|
|
9
|
+
.scriptName("codehost")
|
|
10
|
+
.usage("$0 <command> [options]")
|
|
11
|
+
.command(serveCommand)
|
|
12
|
+
.command(listCommand)
|
|
13
|
+
.command(stopCommand)
|
|
14
|
+
.demandCommand(1, "Specify a command, e.g. `codehost serve`")
|
|
15
|
+
.strict()
|
|
16
|
+
.help()
|
|
17
|
+
.alias("h", "help")
|
|
18
|
+
.version()
|
|
19
|
+
.parse();
|
package/src/cli/oxmgr.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// Thin wrapper around the `oxmgr` process manager (https://npmjs.com/package/oxmgr).
|
|
4
|
+
// `codehost serve -d` re-launches the foreground `serve` under oxmgr so it
|
|
5
|
+
// survives the shell and restarts on failure.
|
|
6
|
+
|
|
7
|
+
export function hasOxmgr(): boolean {
|
|
8
|
+
const r = spawnSync("oxmgr", ["--version"], { stdio: "ignore" });
|
|
9
|
+
return r.status === 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MISSING_MSG =
|
|
13
|
+
"[codehost] oxmgr not found. Install it with `npm i -g oxmgr` (or `bun add -g oxmgr`), then retry with -d.";
|
|
14
|
+
|
|
15
|
+
/** Process name oxmgr will track this server under. */
|
|
16
|
+
export function daemonName(label: string): string {
|
|
17
|
+
const slug = label.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
|
|
18
|
+
return `codehost-${slug || "server"}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DaemonizeOptions {
|
|
22
|
+
name: string; // oxmgr process name
|
|
23
|
+
command: string; // full inline command oxmgr should run (the foreground serve)
|
|
24
|
+
cwd: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Start the foreground serve under oxmgr. Returns false if oxmgr is missing. */
|
|
28
|
+
export function startDaemon(opts: DaemonizeOptions): boolean {
|
|
29
|
+
if (!hasOxmgr()) {
|
|
30
|
+
console.error(MISSING_MSG);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
// Replace any previous instance with the same name.
|
|
34
|
+
spawnSync("oxmgr", ["delete", opts.name], { stdio: "ignore" });
|
|
35
|
+
|
|
36
|
+
const r = spawnSync(
|
|
37
|
+
"oxmgr",
|
|
38
|
+
["start", opts.command, "--name", opts.name, "--cwd", opts.cwd, "--restart", "on-failure"],
|
|
39
|
+
{ stdio: "inherit" },
|
|
40
|
+
);
|
|
41
|
+
return r.status === 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `codehost list` -> oxmgr's process table. */
|
|
45
|
+
export function listDaemons(): number {
|
|
46
|
+
if (!hasOxmgr()) {
|
|
47
|
+
console.error(MISSING_MSG);
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
const r = spawnSync("oxmgr", ["list"], { stdio: "inherit" });
|
|
51
|
+
return r.status ?? 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `codehost stop <name>` -> stop + delete the oxmgr process. */
|
|
55
|
+
export function stopDaemon(name: string): number {
|
|
56
|
+
if (!hasOxmgr()) {
|
|
57
|
+
console.error(MISSING_MSG);
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
const full = name.startsWith("codehost-") ? name : daemonName(name);
|
|
61
|
+
spawnSync("oxmgr", ["stop", full], { stdio: "inherit" });
|
|
62
|
+
const r = spawnSync("oxmgr", ["delete", full], { stdio: "inherit" });
|
|
63
|
+
return r.status ?? 0;
|
|
64
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import type {
|
|
3
|
+
DataChannel,
|
|
4
|
+
PeerConnection as PeerConnectionT,
|
|
5
|
+
} from "node-datachannel";
|
|
6
|
+
import { ICE_SERVERS, type RtcSignal } from "../shared/rtc";
|
|
7
|
+
|
|
8
|
+
// node-datachannel is a native addon. Under Bun, the ESM `import` resolves the
|
|
9
|
+
// package to the global cache (where the prebuilt .node isn't), but `require`
|
|
10
|
+
// resolves correctly from the project's node_modules. So load it via require;
|
|
11
|
+
// the `import type` above is erased at build time and triggers no runtime load.
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const ndc = require("node-datachannel") as typeof import("node-datachannel");
|
|
14
|
+
|
|
15
|
+
export interface RtcDaemonOptions {
|
|
16
|
+
/** Relay a signal to a viewer peer via the signaling channel. */
|
|
17
|
+
sendSignal: (to: string, data: RtcSignal) => void;
|
|
18
|
+
/** Called when a viewer's data channel opens. */
|
|
19
|
+
onChannel: (viewerId: string, channel: DataChannel) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ViewerConn {
|
|
23
|
+
pc: PeerConnectionT;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Daemon-side WebRTC manager. The browser (viewer) is the offerer; for each
|
|
28
|
+
* viewer that sends an offer we create an answering PeerConnection and surface
|
|
29
|
+
* its data channel. STUN-only.
|
|
30
|
+
*/
|
|
31
|
+
export class RtcDaemon {
|
|
32
|
+
private viewers = new Map<string, ViewerConn>();
|
|
33
|
+
|
|
34
|
+
constructor(private opts: RtcDaemonOptions) {}
|
|
35
|
+
|
|
36
|
+
/** Route an inbound signaling payload from a viewer. */
|
|
37
|
+
handleSignal(from: string, data: unknown): void {
|
|
38
|
+
const sig = data as RtcSignal;
|
|
39
|
+
if (!sig || typeof sig !== "object") return;
|
|
40
|
+
|
|
41
|
+
if (sig.kind === "offer") {
|
|
42
|
+
this.acceptOffer(from, sig.sdp);
|
|
43
|
+
} else if (sig.kind === "candidate") {
|
|
44
|
+
const conn = this.viewers.get(from);
|
|
45
|
+
if (conn) {
|
|
46
|
+
try {
|
|
47
|
+
conn.pc.addRemoteCandidate(sig.candidate, sig.mid);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error(`[rtc] addRemoteCandidate failed for ${from.slice(0, 8)}:`, err);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private acceptOffer(viewerId: string, sdp: string): void {
|
|
56
|
+
// Replace any prior connection for this viewer (e.g. page reload).
|
|
57
|
+
this.dropViewer(viewerId);
|
|
58
|
+
|
|
59
|
+
const pc = new ndc.PeerConnection(`viewer-${viewerId.slice(0, 8)}`, {
|
|
60
|
+
iceServers: ICE_SERVERS,
|
|
61
|
+
});
|
|
62
|
+
this.viewers.set(viewerId, { pc });
|
|
63
|
+
|
|
64
|
+
pc.onLocalDescription((localSdp, type) => {
|
|
65
|
+
this.opts.sendSignal(viewerId, {
|
|
66
|
+
kind: type as "answer",
|
|
67
|
+
type: type as "answer",
|
|
68
|
+
sdp: localSdp,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
pc.onLocalCandidate((candidate, mid) => {
|
|
73
|
+
this.opts.sendSignal(viewerId, { kind: "candidate", candidate, mid });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
pc.onStateChange((state) => {
|
|
77
|
+
console.log(`[rtc] ${viewerId.slice(0, 8)} state: ${state}`);
|
|
78
|
+
if (state === "disconnected" || state === "failed" || state === "closed") {
|
|
79
|
+
this.dropViewer(viewerId);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
pc.onDataChannel((dc) => {
|
|
84
|
+
console.log(`[rtc] ${viewerId.slice(0, 8)} channel "${dc.getLabel()}" open`);
|
|
85
|
+
this.opts.onChannel(viewerId, dc);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
pc.setRemoteDescription(sdp, "offer");
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.error(`[rtc] setRemoteDescription failed for ${viewerId.slice(0, 8)}:`, err);
|
|
92
|
+
this.dropViewer(viewerId);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private dropViewer(viewerId: string): void {
|
|
97
|
+
const conn = this.viewers.get(viewerId);
|
|
98
|
+
if (!conn) return;
|
|
99
|
+
this.viewers.delete(viewerId);
|
|
100
|
+
try {
|
|
101
|
+
conn.pc.close();
|
|
102
|
+
} catch {
|
|
103
|
+
// ignore
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
closeAll(): void {
|
|
108
|
+
for (const id of [...this.viewers.keys()]) this.dropViewer(id);
|
|
109
|
+
try {
|
|
110
|
+
ndc.cleanup();
|
|
111
|
+
} catch {
|
|
112
|
+
// ignore
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|