codehost 0.1.0 → 0.3.1
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/.github/workflows/deploy.yml +30 -0
- package/.github/workflows/release.yaml +39 -0
- package/.releaserc.json +17 -0
- package/CHANGELOG.md +29 -0
- package/README.md +36 -5
- package/TODO.md +93 -0
- package/docs/vscode-cdn-proxy.md +117 -0
- package/package.json +11 -1
- package/public/_redirects +5 -0
- package/public/install.ps1 +43 -0
- package/public/install.sh +55 -0
- package/src/cli/commands/dev.ts +107 -0
- package/src/cli/commands/expose.ts +93 -0
- package/src/cli/commands/list.ts +2 -2
- package/src/cli/commands/serve.ts +35 -80
- package/src/cli/commands/setup.ts +99 -0
- package/src/cli/commands/stop.ts +2 -2
- package/src/cli/commands/update.ts +19 -0
- package/src/cli/config.ts +27 -0
- package/src/cli/daemonize.ts +59 -0
- package/src/cli/git.ts +53 -0
- package/src/cli/index.ts +8 -0
- package/src/cli/open-url.ts +47 -0
- package/src/cli/oxmgr-heal.ts +175 -0
- package/src/cli/oxmgr.ts +104 -36
- package/src/cli/rtc-daemon.ts +57 -1
- package/src/cli/run-server.ts +78 -0
- package/src/cli/tunnel.ts +30 -4
- package/src/cli/vscode-install.ts +222 -0
- package/src/cli/vscode.ts +8 -4
- package/src/shared/repo.ts +104 -0
- package/src/shared/signaling.ts +18 -2
- package/src/web/config.ts +44 -7
- package/src/web/discovery.tsx +131 -6
- package/src/web/history.ts +58 -0
- package/src/web/sw.ts +36 -1
- package/worker/index.ts +49 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
copyFileSync,
|
|
5
|
+
createWriteStream,
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import https from "node:https";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
|
|
18
|
+
// Self-heal oxmgr's native binary. Two real-world gaps:
|
|
19
|
+
// 1. Under bunx/bun (and `bun i -g`), install lifecycle scripts are skipped,
|
|
20
|
+
// so oxmgr's postinstall never downloads its prebuilt — the vendored
|
|
21
|
+
// binary is simply absent.
|
|
22
|
+
// 2. On older Linux distros, the downloaded `*-linux-gnu` prebuilt needs a
|
|
23
|
+
// newer glibc than the host has (GLIBC_x.y not found).
|
|
24
|
+
// Linux is healed by swapping in oxmgr's fully static `*-linux-musl` build (no
|
|
25
|
+
// libc dependency, also covers the missing case). Other platforms are healed by
|
|
26
|
+
// running oxmgr's own installer via the current runtime (bun) to fetch the
|
|
27
|
+
// right binary — no Node required. Mirrors the node-datachannel self-heal.
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
let healAttempted = false;
|
|
31
|
+
|
|
32
|
+
interface OxmgrInstall {
|
|
33
|
+
/** oxmgr package root. */
|
|
34
|
+
root: string;
|
|
35
|
+
/** Platform-correct vendored binary path. */
|
|
36
|
+
vendorBin: string;
|
|
37
|
+
version: string;
|
|
38
|
+
/** GitHub "owner/repo" the release assets live under. */
|
|
39
|
+
slug: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Repair oxmgr's binary. Idempotent within a process. */
|
|
43
|
+
export async function healOxmgr(): Promise<boolean> {
|
|
44
|
+
if (healAttempted) return false;
|
|
45
|
+
healAttempted = true;
|
|
46
|
+
|
|
47
|
+
const install = locateOxmgr();
|
|
48
|
+
if (!install) return false;
|
|
49
|
+
|
|
50
|
+
// Linux: portable musl static build covers both the glibc mismatch and a
|
|
51
|
+
// missing binary in one download.
|
|
52
|
+
if (process.platform === "linux") return swapMusl(install);
|
|
53
|
+
|
|
54
|
+
// Windows / macOS: run oxmgr's own installer to fetch the platform binary the
|
|
55
|
+
// skipped postinstall never downloaded.
|
|
56
|
+
return runInstaller(install);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Run oxmgr's bundled installer via the current runtime (bun) to fetch its
|
|
60
|
+
* native binary. Cross-platform; no Node needed. */
|
|
61
|
+
function runInstaller(install: OxmgrInstall): boolean {
|
|
62
|
+
const script = join(install.root, "scripts", "install.js");
|
|
63
|
+
if (!existsSync(script)) return false;
|
|
64
|
+
console.log("[codehost] fetching oxmgr's native binary…");
|
|
65
|
+
const r = spawnSync(process.execPath, [script], { cwd: install.root, stdio: "inherit" });
|
|
66
|
+
return r.status === 0 && existsSync(install.vendorBin);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Download + install oxmgr's static musl binary (Linux). */
|
|
70
|
+
async function swapMusl(install: OxmgrInstall): Promise<boolean> {
|
|
71
|
+
const target = process.arch === "x64" ? "x86_64-unknown-linux-musl" : process.arch === "arm64" ? "aarch64-unknown-linux-musl" : null;
|
|
72
|
+
if (!target) return false;
|
|
73
|
+
|
|
74
|
+
const archive = `oxmgr-v${install.version}-${target}.tar.gz`;
|
|
75
|
+
const base =
|
|
76
|
+
process.env.OXMGR_DIST_BASE ||
|
|
77
|
+
`https://github.com/${install.slug}/releases/download/v${install.version}`;
|
|
78
|
+
const url = `${base}/${archive}`;
|
|
79
|
+
|
|
80
|
+
const tmp = mkdtempSync(join(tmpdir(), "oxmgr-heal-"));
|
|
81
|
+
const archivePath = join(tmp, archive);
|
|
82
|
+
console.log("[codehost] fetching oxmgr's portable (musl) binary…");
|
|
83
|
+
try {
|
|
84
|
+
await download(url, archivePath);
|
|
85
|
+
const untar = spawnSync("tar", ["xzf", archivePath, "-C", tmp], { stdio: "ignore" });
|
|
86
|
+
if (untar.status !== 0) throw new Error("could not extract the archive (is `tar` installed?)");
|
|
87
|
+
const binary = findFile(tmp, "oxmgr");
|
|
88
|
+
if (!binary) throw new Error("musl archive did not contain an oxmgr binary");
|
|
89
|
+
// Unlink first so we can replace a binary that's currently executing (ETXTBSY).
|
|
90
|
+
rmSync(install.vendorBin, { force: true });
|
|
91
|
+
copyFileSync(binary, install.vendorBin);
|
|
92
|
+
chmodSync(install.vendorBin, 0o755);
|
|
93
|
+
console.log("[codehost] oxmgr repaired with its static musl build.");
|
|
94
|
+
return true;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`[codehost] automatic oxmgr repair failed: ${(err as Error).message}`);
|
|
97
|
+
return false;
|
|
98
|
+
} finally {
|
|
99
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Resolve oxmgr's on-disk install from codehost's own node_modules. */
|
|
104
|
+
function locateOxmgr(): OxmgrInstall | null {
|
|
105
|
+
let pkgPath: string;
|
|
106
|
+
try {
|
|
107
|
+
pkgPath = require.resolve("oxmgr/package.json");
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const root = dirname(pkgPath);
|
|
112
|
+
const vendorBin = join(root, "vendor", process.platform === "win32" ? "oxmgr.exe" : "oxmgr");
|
|
113
|
+
|
|
114
|
+
let pkg: { version?: string; repository?: { url?: string } };
|
|
115
|
+
try {
|
|
116
|
+
pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
if (!pkg.version) return null;
|
|
121
|
+
return { root, vendorBin, version: pkg.version, slug: oxmgrSlug(pkg) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** GitHub slug for oxmgr's releases, matching its own installer's resolution. */
|
|
125
|
+
function oxmgrSlug(pkg: { repository?: { url?: string } }): string {
|
|
126
|
+
if (process.env.OXMGR_NPM_REPOSITORY) return process.env.OXMGR_NPM_REPOSITORY;
|
|
127
|
+
const url = pkg.repository?.url ?? "";
|
|
128
|
+
const m = url.match(/github\.com[/:]([^/]+\/[^/.]+)(?:\.git)?$/i);
|
|
129
|
+
return m ? m[1] : "Vladimir-Urik/OxMgr";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Download a URL to a file, following GitHub's redirect to release-assets. */
|
|
133
|
+
function download(url: string, dest: string): Promise<void> {
|
|
134
|
+
return new Promise((resolvePromise, reject) => {
|
|
135
|
+
const file = createWriteStream(dest);
|
|
136
|
+
https
|
|
137
|
+
.get(url, (res) => {
|
|
138
|
+
const status = res.statusCode ?? 0;
|
|
139
|
+
if ([301, 302, 307, 308].includes(status)) {
|
|
140
|
+
const location = res.headers.location;
|
|
141
|
+
file.close();
|
|
142
|
+
rmSync(dest, { force: true });
|
|
143
|
+
if (!location) return reject(new Error(`redirect without location for ${url}`));
|
|
144
|
+
return download(location, dest).then(resolvePromise).catch(reject);
|
|
145
|
+
}
|
|
146
|
+
if (status !== 200) {
|
|
147
|
+
file.close();
|
|
148
|
+
rmSync(dest, { force: true });
|
|
149
|
+
return reject(new Error(`download failed (HTTP ${status})`));
|
|
150
|
+
}
|
|
151
|
+
res.pipe(file);
|
|
152
|
+
file.on("finish", () => file.close(() => resolvePromise()));
|
|
153
|
+
})
|
|
154
|
+
.on("error", (err) => {
|
|
155
|
+
file.close();
|
|
156
|
+
rmSync(dest, { force: true });
|
|
157
|
+
reject(err);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Depth-first search for a file named `name` under `dir`. */
|
|
163
|
+
function findFile(dir: string, name: string): string | null {
|
|
164
|
+
for (const entry of readdirSync(dir)) {
|
|
165
|
+
const full = join(dir, entry);
|
|
166
|
+
const st = statSync(full);
|
|
167
|
+
if (st.isDirectory()) {
|
|
168
|
+
const found = findFile(full, name);
|
|
169
|
+
if (found) return found;
|
|
170
|
+
} else if (entry === name) {
|
|
171
|
+
return full;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
package/src/cli/oxmgr.ts
CHANGED
|
@@ -1,16 +1,76 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
1
|
+
import { type SpawnSyncOptions, spawnSync } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { healOxmgr } from "./oxmgr-heal";
|
|
2
4
|
|
|
3
|
-
//
|
|
4
|
-
// `
|
|
5
|
-
//
|
|
5
|
+
// Wrapper around the `oxmgr` process manager (https://npmjs.com/package/oxmgr),
|
|
6
|
+
// used by `serve|dev|expose -d` to run the foreground command as a managed,
|
|
7
|
+
// restart-on-failure daemon.
|
|
8
|
+
//
|
|
9
|
+
// oxmgr is a *dependency* of codehost, and we invoke it via the current runtime
|
|
10
|
+
// (bun) using its resolved JS entry — never the bare `oxmgr` command. That
|
|
11
|
+
// avoids the Windows failure where `spawnSync("oxmgr")` can't resolve a PATH
|
|
12
|
+
// shim without the .exe/.cmd extension, and needs no Node and no global install.
|
|
6
13
|
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
|
|
16
|
+
/** Resolve oxmgr's JS CLI entry from codehost's own node_modules. */
|
|
17
|
+
function oxmgrEntry(): string | null {
|
|
18
|
+
try {
|
|
19
|
+
return require.resolve("oxmgr/bin/oxmgr.js");
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Run the oxmgr CLI via bun. Returns the exit status (1 if unresolvable). */
|
|
26
|
+
function ox(args: string[], opts: SpawnSyncOptions = {}): number {
|
|
27
|
+
const entry = oxmgrEntry();
|
|
28
|
+
if (!entry) return 1;
|
|
29
|
+
const r = spawnSync(process.execPath, [entry, ...args], opts);
|
|
30
|
+
return r.status ?? 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type OxmgrState = "ok" | "broken" | "missing";
|
|
34
|
+
|
|
35
|
+
/** Probe oxmgr: "missing" = not installed; "broken" = installed but its binary
|
|
36
|
+
* won't run (no vendored prebuilt yet, or a glibc/platform mismatch). */
|
|
37
|
+
function probeOxmgr(): OxmgrState {
|
|
38
|
+
const entry = oxmgrEntry();
|
|
39
|
+
if (!entry) return "missing";
|
|
40
|
+
const r = spawnSync(process.execPath, [entry, "--version"], { encoding: "utf8" });
|
|
41
|
+
return r.status === 0 ? "ok" : "broken";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Quick non-repairing probe (used where we only need a yes/no). */
|
|
7
45
|
export function hasOxmgr(): boolean {
|
|
8
|
-
|
|
9
|
-
|
|
46
|
+
return probeOxmgr() === "ok";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Ensure a runnable oxmgr, self-healing a broken/missing prebuilt (see
|
|
51
|
+
* oxmgr-heal.ts) — the common case under bunx/bun where install lifecycle
|
|
52
|
+
* scripts are skipped so oxmgr's binary was never downloaded. Returns true if
|
|
53
|
+
* oxmgr is usable afterwards.
|
|
54
|
+
*/
|
|
55
|
+
export async function ensureOxmgr(): Promise<boolean> {
|
|
56
|
+
const state = probeOxmgr();
|
|
57
|
+
if (state === "ok") return true;
|
|
58
|
+
if (state === "missing") {
|
|
59
|
+
console.error(MISSING_MSG);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
if (await healOxmgr()) return probeOxmgr() === "ok";
|
|
63
|
+
console.error(BROKEN_MSG);
|
|
64
|
+
return false;
|
|
10
65
|
}
|
|
11
66
|
|
|
12
67
|
const MISSING_MSG =
|
|
13
|
-
"[codehost] oxmgr not
|
|
68
|
+
"[codehost] oxmgr is not available. Reinstall codehost so its dependency is present " +
|
|
69
|
+
"(`bun add -g codehost` or `npm i -g codehost`), then retry with -d.";
|
|
70
|
+
|
|
71
|
+
const BROKEN_MSG =
|
|
72
|
+
"[codehost] oxmgr is installed but its native binary couldn't be fetched/repaired " +
|
|
73
|
+
"automatically (check network access). Foreground `codehost serve` (without -d) still works.";
|
|
14
74
|
|
|
15
75
|
/** Process name oxmgr will track this server under. */
|
|
16
76
|
export function daemonName(label: string): string {
|
|
@@ -24,41 +84,49 @@ export interface DaemonizeOptions {
|
|
|
24
84
|
cwd: string;
|
|
25
85
|
}
|
|
26
86
|
|
|
27
|
-
/** Start the foreground serve under oxmgr. Returns false if oxmgr is
|
|
28
|
-
export function startDaemon(opts: DaemonizeOptions): boolean {
|
|
29
|
-
if (!
|
|
30
|
-
console.error(MISSING_MSG);
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
87
|
+
/** Start the foreground serve under oxmgr. Returns false if oxmgr is unusable. */
|
|
88
|
+
export async function startDaemon(opts: DaemonizeOptions): Promise<boolean> {
|
|
89
|
+
if (!(await ensureOxmgr())) return false;
|
|
33
90
|
// Replace any previous instance with the same name.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
91
|
+
ox(["delete", opts.name], { stdio: "ignore" });
|
|
92
|
+
|
|
93
|
+
const ok =
|
|
94
|
+
ox(
|
|
95
|
+
["start", opts.command, "--name", opts.name, "--cwd", opts.cwd, "--restart", "on-failure"],
|
|
96
|
+
{ stdio: "inherit" },
|
|
97
|
+
) === 0;
|
|
98
|
+
if (ok) enableStartup();
|
|
99
|
+
return ok;
|
|
42
100
|
}
|
|
43
101
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Best-effort: install oxmgr's platform service (systemd `--user` / launchd /
|
|
104
|
+
* Task Scheduler) so the daemon — and the codehost process it manages, whose
|
|
105
|
+
* metadata oxmgr persists — comes back when the user logs in again. Idempotent
|
|
106
|
+
* and non-fatal: hosts without an init system just get a hint.
|
|
107
|
+
*/
|
|
108
|
+
function enableStartup(): void {
|
|
109
|
+
const ok = ox(["service", "--system", "auto", "install"], { stdio: "pipe" }) === 0;
|
|
110
|
+
if (ok) {
|
|
111
|
+
console.log("[codehost] login auto-start enabled (oxmgr service installed)");
|
|
112
|
+
} else {
|
|
113
|
+
console.log(
|
|
114
|
+
"[codehost] note: couldn't auto-enable login startup here; run oxmgr's `startup` " +
|
|
115
|
+
"integration on a systemd/launchd host to make it persist across logins.",
|
|
116
|
+
);
|
|
49
117
|
}
|
|
50
|
-
|
|
51
|
-
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `codehost list` -> oxmgr's process table. */
|
|
121
|
+
export async function listDaemons(): Promise<number> {
|
|
122
|
+
if (!(await ensureOxmgr())) return 1;
|
|
123
|
+
return ox(["list"], { stdio: "inherit" });
|
|
52
124
|
}
|
|
53
125
|
|
|
54
126
|
/** `codehost stop <name>` -> stop + delete the oxmgr process. */
|
|
55
|
-
export function stopDaemon(name: string): number {
|
|
56
|
-
if (!
|
|
57
|
-
console.error(MISSING_MSG);
|
|
58
|
-
return 1;
|
|
59
|
-
}
|
|
127
|
+
export async function stopDaemon(name: string): Promise<number> {
|
|
128
|
+
if (!(await ensureOxmgr())) return 1;
|
|
60
129
|
const full = name.startsWith("codehost-") ? name : daemonName(name);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return r.status ?? 0;
|
|
130
|
+
ox(["stop", full], { stdio: "inherit" });
|
|
131
|
+
return ox(["delete", full], { stdio: "inherit" });
|
|
64
132
|
}
|
package/src/cli/rtc-daemon.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { dirname } from "node:path";
|
|
1
3
|
import { createRequire } from "node:module";
|
|
2
4
|
import type {
|
|
3
5
|
DataChannel,
|
|
@@ -10,7 +12,61 @@ import { ICE_SERVERS, type RtcSignal } from "../shared/rtc";
|
|
|
10
12
|
// resolves correctly from the project's node_modules. So load it via require;
|
|
11
13
|
// the `import type` above is erased at build time and triggers no runtime load.
|
|
12
14
|
const require = createRequire(import.meta.url);
|
|
13
|
-
const ndc =
|
|
15
|
+
const ndc = loadNodeDataChannel();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Load the native node-datachannel addon, self-healing the common failure where
|
|
19
|
+
* its prebuilt `.node` was never fetched. That happens under `bunx codehost`:
|
|
20
|
+
* bunx skips install lifecycle scripts (and `trustedDependencies` only applies
|
|
21
|
+
* to `bun install`), so node-datachannel's `install` step — which downloads the
|
|
22
|
+
* binary via `prebuild-install` — never runs. On the first load failure we run
|
|
23
|
+
* that prebuild-install ourselves, then retry. A normal `bun add -g` install
|
|
24
|
+
* already has the binary, so this is a no-op there.
|
|
25
|
+
*/
|
|
26
|
+
function loadNodeDataChannel(): typeof import("node-datachannel") {
|
|
27
|
+
try {
|
|
28
|
+
return require("node-datachannel") as typeof import("node-datachannel");
|
|
29
|
+
} catch (firstErr) {
|
|
30
|
+
if (!fetchNodeDataChannelBinary()) throw nativeLoadError(firstErr);
|
|
31
|
+
try {
|
|
32
|
+
return require("node-datachannel") as typeof import("node-datachannel");
|
|
33
|
+
} catch (secondErr) {
|
|
34
|
+
throw nativeLoadError(secondErr);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Run node-datachannel's bundled `prebuild-install` to fetch the prebuilt
|
|
40
|
+
* binary. Returns true if it exited cleanly. */
|
|
41
|
+
function fetchNodeDataChannelBinary(): boolean {
|
|
42
|
+
let pkgDir: string;
|
|
43
|
+
let prebuildBin: string;
|
|
44
|
+
try {
|
|
45
|
+
pkgDir = dirname(require.resolve("node-datachannel/package.json"));
|
|
46
|
+
// prebuild-install is a dependency of node-datachannel; resolve its CLI
|
|
47
|
+
// entry from the package's own module scope.
|
|
48
|
+
prebuildBin = require.resolve("prebuild-install/bin.js", { paths: [pkgDir] });
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
console.log("[codehost] fetching node-datachannel native binary (prebuild-install)…");
|
|
53
|
+
const r = spawnSync(process.execPath, [prebuildBin, "-r", "napi"], {
|
|
54
|
+
cwd: pkgDir,
|
|
55
|
+
stdio: "inherit",
|
|
56
|
+
});
|
|
57
|
+
return r.status === 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function nativeLoadError(cause: unknown): Error {
|
|
61
|
+
return new Error(
|
|
62
|
+
"Failed to load the native WebRTC module (node-datachannel). Its prebuilt " +
|
|
63
|
+
"binary could not be fetched automatically. Install codehost globally so " +
|
|
64
|
+
"install scripts run — `bun add -g codehost` (or `npm i -g codehost`) — " +
|
|
65
|
+
"and ensure network access. If your platform has no prebuilt, a C++ " +
|
|
66
|
+
"toolchain + cmake is needed to build from source. " +
|
|
67
|
+
`(cause: ${(cause as Error)?.message ?? cause})`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
14
70
|
|
|
15
71
|
export interface RtcDaemonOptions {
|
|
16
72
|
/** Relay a signal to a viewer peer via the signaling channel. */
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type PeerMeta, newPeerId } from "../shared/signaling";
|
|
2
|
+
import { SignalingClient } from "../shared/signaling-client";
|
|
3
|
+
import { RtcDaemon } from "./rtc-daemon";
|
|
4
|
+
import { Tunnel } from "./tunnel";
|
|
5
|
+
|
|
6
|
+
export interface LaunchResult {
|
|
7
|
+
/** Local port to tunnel to. */
|
|
8
|
+
port: number;
|
|
9
|
+
/** Stop the launched process, if any. */
|
|
10
|
+
stop?: () => void;
|
|
11
|
+
/**
|
|
12
|
+
* Prefix the Tunnel should strip before forwarding (so an arbitrary server
|
|
13
|
+
* that doesn't know about /vs/<peerId> still gets clean paths). Left undefined
|
|
14
|
+
* for VS Code, which is launched with --server-base-path and wants the prefix.
|
|
15
|
+
*/
|
|
16
|
+
stripBasePath?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RunServerOptions {
|
|
20
|
+
token: string;
|
|
21
|
+
signal: string;
|
|
22
|
+
meta: PeerMeta;
|
|
23
|
+
/** One-line description for the startup log. */
|
|
24
|
+
label: string;
|
|
25
|
+
/** Prepare the local target to tunnel, given the /vs/<peerId> base path. */
|
|
26
|
+
launch: (basePath: string) => Promise<LaunchResult>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Foreground server loop shared by `serve`, `dev`, and `expose`: register in the
|
|
31
|
+
* signaling room with the given meta and bridge each viewer's data channel to a
|
|
32
|
+
* local server (VS Code for serve/dev, an arbitrary port for expose). Never
|
|
33
|
+
* resolves.
|
|
34
|
+
*/
|
|
35
|
+
export async function runServer(opts: RunServerOptions): Promise<never> {
|
|
36
|
+
const peerId = newPeerId();
|
|
37
|
+
const basePath = `/vs/${peerId}`;
|
|
38
|
+
|
|
39
|
+
console.log(`[codehost] ${opts.label}`);
|
|
40
|
+
console.log(`[codehost] room token: ${opts.token}`);
|
|
41
|
+
console.log(`[codehost] signaling: ${opts.signal}`);
|
|
42
|
+
|
|
43
|
+
const target = await opts.launch(basePath);
|
|
44
|
+
|
|
45
|
+
let rtc: RtcDaemon;
|
|
46
|
+
const client = new SignalingClient({
|
|
47
|
+
url: opts.signal,
|
|
48
|
+
token: opts.token,
|
|
49
|
+
role: "server",
|
|
50
|
+
peerId,
|
|
51
|
+
meta: opts.meta,
|
|
52
|
+
onOpen: () => console.log(`[codehost] registered as "${opts.meta.name}" (${peerId.slice(0, 8)})`),
|
|
53
|
+
onClose: () => console.log("[codehost] disconnected from signaling, reconnecting…"),
|
|
54
|
+
onSignal: (from, data) => rtc.handleSignal(from, data),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
rtc = new RtcDaemon({
|
|
58
|
+
sendSignal: (to, data) => client.sendSignal(to, data),
|
|
59
|
+
onChannel: (viewerId, channel) => {
|
|
60
|
+
console.log(`[codehost] viewer ${viewerId.slice(0, 8)} connected; bridging to :${target.port}`);
|
|
61
|
+
new Tunnel(channel, target.port, target.stripBasePath);
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
client.connect();
|
|
66
|
+
|
|
67
|
+
const shutdown = () => {
|
|
68
|
+
console.log("\n[codehost] shutting down");
|
|
69
|
+
rtc.closeAll();
|
|
70
|
+
client.close();
|
|
71
|
+
target.stop?.();
|
|
72
|
+
process.exit(0);
|
|
73
|
+
};
|
|
74
|
+
process.on("SIGINT", shutdown);
|
|
75
|
+
process.on("SIGTERM", shutdown);
|
|
76
|
+
|
|
77
|
+
return new Promise<never>(() => {});
|
|
78
|
+
}
|
package/src/cli/tunnel.ts
CHANGED
|
@@ -47,6 +47,13 @@ export class Tunnel {
|
|
|
47
47
|
constructor(
|
|
48
48
|
private channel: DataChannel,
|
|
49
49
|
private vscodePort: number,
|
|
50
|
+
/**
|
|
51
|
+
* Prefix to strip from incoming paths before forwarding to the local server.
|
|
52
|
+
* VS Code is launched with --server-base-path /vs/<peerId> so it WANTS the
|
|
53
|
+
* prefix (left undefined). An arbitrary exposed server (`codehost expose`)
|
|
54
|
+
* doesn't know it, so we strip `/vs/<peerId>` before proxying.
|
|
55
|
+
*/
|
|
56
|
+
private stripPrefix?: string,
|
|
50
57
|
) {
|
|
51
58
|
this.origin = `http://127.0.0.1:${vscodePort}`;
|
|
52
59
|
this.wsOrigin = `ws://127.0.0.1:${vscodePort}`;
|
|
@@ -58,6 +65,15 @@ export class Tunnel {
|
|
|
58
65
|
this.channel.onClosed(() => this.closeAll());
|
|
59
66
|
}
|
|
60
67
|
|
|
68
|
+
/** Map a tunneled path to the local server's path, stripping the base prefix. */
|
|
69
|
+
private localPath(path: string): string {
|
|
70
|
+
if (this.stripPrefix && path.startsWith(this.stripPrefix)) {
|
|
71
|
+
const rest = path.slice(this.stripPrefix.length);
|
|
72
|
+
return rest.startsWith("/") ? rest : `/${rest}`;
|
|
73
|
+
}
|
|
74
|
+
return path;
|
|
75
|
+
}
|
|
76
|
+
|
|
61
77
|
private async onFrame(data: Uint8Array): Promise<void> {
|
|
62
78
|
const { op, streamId, payload } = decodeFrame(data);
|
|
63
79
|
switch (op) {
|
|
@@ -99,16 +115,26 @@ export class Tunnel {
|
|
|
99
115
|
|
|
100
116
|
const { method, path, headers } = stream.head;
|
|
101
117
|
const reqHeaders = new Headers();
|
|
118
|
+
let forwardedHost = "";
|
|
102
119
|
for (const [k, v] of Object.entries(headers)) {
|
|
103
|
-
|
|
120
|
+
const lk = k.toLowerCase();
|
|
121
|
+
if (lk === "x-forwarded-host") {
|
|
122
|
+
forwardedHost = v;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!HOP_BY_HOP.has(lk)) reqHeaders.set(k, v);
|
|
104
126
|
}
|
|
105
|
-
|
|
127
|
+
// Present the browser's public host to VS Code so its client-side
|
|
128
|
+
// remoteAuthority points at codehost.dev (routes resource URLs back through
|
|
129
|
+
// the tunnel), not the unreachable 127.0.0.1:<port>. Falls back to the local
|
|
130
|
+
// host if the SW didn't forward one.
|
|
131
|
+
reqHeaders.set("host", forwardedHost || `127.0.0.1:${this.vscodePort}`);
|
|
106
132
|
|
|
107
133
|
const hasBody = method !== "GET" && method !== "HEAD" && stream.body.length > 0;
|
|
108
134
|
const body = hasBody ? concat(stream.body) : undefined;
|
|
109
135
|
|
|
110
136
|
try {
|
|
111
|
-
const res = await fetch(this.origin + path, {
|
|
137
|
+
const res = await fetch(this.origin + this.localPath(path), {
|
|
112
138
|
method,
|
|
113
139
|
headers: reqHeaders,
|
|
114
140
|
body: body as BodyInit | undefined,
|
|
@@ -151,7 +177,7 @@ export class Tunnel {
|
|
|
151
177
|
private openWs(streamId: number, info: { path: string; protocols?: string[] }): void {
|
|
152
178
|
let ws: WebSocket;
|
|
153
179
|
try {
|
|
154
|
-
ws = new WebSocket(this.wsOrigin + info.path, info.protocols);
|
|
180
|
+
ws = new WebSocket(this.wsOrigin + this.localPath(info.path), info.protocols);
|
|
155
181
|
} catch (err) {
|
|
156
182
|
void this.send(encodeJson(Op.WsOpenAck, streamId, { ok: false, error: String(err) }));
|
|
157
183
|
return;
|