codehost 0.1.1 → 0.4.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,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
- // 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.
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
- const r = spawnSync("oxmgr", ["--version"], { stdio: "ignore" });
9
- return r.status === 0;
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 found. Install it with `npm i -g oxmgr` (or `bun add -g oxmgr`), then retry with -d.";
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 missing. */
28
- export function startDaemon(opts: DaemonizeOptions): boolean {
29
- if (!hasOxmgr()) {
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
- 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;
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
- /** `codehost list` -> oxmgr's process table. */
45
- export function listDaemons(): number {
46
- if (!hasOxmgr()) {
47
- console.error(MISSING_MSG);
48
- return 1;
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
- const r = spawnSync("oxmgr", ["list"], { stdio: "inherit" });
51
- return r.status ?? 0;
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 (!hasOxmgr()) {
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
- spawnSync("oxmgr", ["stop", full], { stdio: "inherit" });
62
- const r = spawnSync("oxmgr", ["delete", full], { stdio: "inherit" });
63
- return r.status ?? 0;
130
+ ox(["stop", full], { stdio: "inherit" });
131
+ return ox(["delete", full], { stdio: "inherit" });
64
132
  }
@@ -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
+ }
@@ -0,0 +1,89 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ // Best-effort self-update, run right before a daemon is (re)spawned (see the top
6
+ // of launchServeDaemon). Re-launching `setup` or `serve -d` replaces the managed
7
+ // daemon (oxmgr delete + start), so upgrading the global package here means the
8
+ // fresh daemon process runs the new code — there's no in-place restart, so this
9
+ // never trips oxmgr's `on-failure` policy and never drops a live session
10
+ // mid-flight. A days-old daemon only updates on the next launcher run.
11
+
12
+ const REGISTRY_LATEST = "https://registry.npmjs.org/codehost/latest";
13
+ const FETCH_TIMEOUT_MS = 5_000;
14
+ const INSTALL_TIMEOUT_MS = 120_000;
15
+
16
+ /**
17
+ * Upgrade the global `codehost` to the latest published version if we're running
18
+ * from a real global install (`bun add -g` / `npm i -g`). A dev checkout or a
19
+ * `bunx` run is left untouched so we never clobber the user's global copy.
20
+ *
21
+ * Fully non-fatal: an offline registry, a slow network, or a failed `bun add`
22
+ * must never stop the server from launching — every failure path just logs and
23
+ * returns. Disable entirely with CODEHOST_NO_SELF_UPDATE=1.
24
+ */
25
+ export async function selfUpdate(): Promise<void> {
26
+ if (process.env.CODEHOST_NO_SELF_UPDATE === "1") return;
27
+ try {
28
+ if (!isGlobalInstall()) return; // dev checkout or bunx: don't touch the global
29
+
30
+ const installed = currentVersion();
31
+ if (!installed) return; // can't locate our own package.json — don't risk it
32
+
33
+ const latest = await fetchLatest();
34
+ if (!latest || latest === installed) return;
35
+
36
+ console.log(`[codehost] updating codehost ${installed} → ${latest}…`);
37
+ const r = spawnSync(process.execPath, ["add", "-g", `codehost@${latest}`], {
38
+ stdio: "inherit",
39
+ timeout: INSTALL_TIMEOUT_MS,
40
+ });
41
+ if (r.status === 0) {
42
+ console.log(`[codehost] updated to ${latest}; the new daemon will run it.`);
43
+ } else {
44
+ console.warn(`[codehost] self-update to ${latest} failed; continuing on ${installed}.`);
45
+ }
46
+ } catch (err) {
47
+ console.warn(`[codehost] self-update skipped: ${(err as Error).message}`);
48
+ }
49
+ }
50
+
51
+ /** Version from our own package.json (src/cli → package root is two up). */
52
+ function currentVersion(): string | null {
53
+ try {
54
+ const pkg = JSON.parse(
55
+ readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8"),
56
+ ) as { version?: unknown };
57
+ return typeof pkg.version === "string" ? pkg.version : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * True only when this file lives in a well-known *global* package directory we
65
+ * own. Conservative on purpose: anything unrecognized (dev repo, bunx cache,
66
+ * unusual layout) returns false so we skip rather than risk overwriting the
67
+ * wrong tree.
68
+ */
69
+ function isGlobalInstall(): boolean {
70
+ const dir = import.meta.dir.replace(/\\/g, "/");
71
+ return (
72
+ dir.includes("/.bun/install/global/node_modules/codehost/") || // bun add -g
73
+ dir.includes("/lib/node_modules/codehost/") // npm i -g (unix default prefix)
74
+ );
75
+ }
76
+
77
+ /** Latest published version per the npm registry, or null on any failure. */
78
+ async function fetchLatest(): Promise<string | null> {
79
+ try {
80
+ const res = await fetch(REGISTRY_LATEST, {
81
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
82
+ });
83
+ if (!res.ok) return null;
84
+ const data = (await res.json()) as { version?: unknown };
85
+ return typeof data.version === "string" ? data.version : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
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
- if (!HOP_BY_HOP.has(k.toLowerCase())) reqHeaders.set(k, v);
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
- reqHeaders.set("host", `127.0.0.1:${this.vscodePort}`);
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;
package/src/cli/vscode.ts CHANGED
@@ -24,6 +24,10 @@ export interface LaunchOptions {
24
24
  */
25
25
  export async function launchVscode(opts: LaunchOptions): Promise<VscodeServer> {
26
26
  const port = opts.port && opts.port > 0 ? opts.port : await freePort();
27
+ // NB: no `--default-folder` — current VS Code `serve-web` (≥1.x) doesn't
28
+ // accept it and exits with "unexpected argument". The workspace is opened by
29
+ // the browser instead, via the `?folder=` query the page appends to the
30
+ // iframe URL (see src/web/discovery.tsx). `cwd` below still roots the server.
27
31
  const args = [
28
32
  "serve-web",
29
33
  "--host",
@@ -32,8 +36,6 @@ export async function launchVscode(opts: LaunchOptions): Promise<VscodeServer> {
32
36
  String(port),
33
37
  "--server-base-path",
34
38
  opts.basePath,
35
- "--default-folder",
36
- opts.dir,
37
39
  "--without-connection-token",
38
40
  "--accept-server-license-terms",
39
41
  ];
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { toPosixPath } from "./repo";
3
+
4
+ describe("toPosixPath", () => {
5
+ test("Windows drive path -> POSIX drive form", () => {
6
+ expect(toPosixPath("C:\\ws")).toBe("/c/ws");
7
+ expect(toPosixPath("C:\\Users\\x")).toBe("/c/Users/x");
8
+ });
9
+
10
+ test("lowercases the drive letter", () => {
11
+ expect(toPosixPath("D:\\foo")).toBe("/d/foo");
12
+ expect(toPosixPath("c:\\ws")).toBe("/c/ws");
13
+ });
14
+
15
+ test("drive root collapses to /<letter> (no trailing slash)", () => {
16
+ expect(toPosixPath("C:\\")).toBe("/c");
17
+ expect(toPosixPath("C:")).toBe("/c");
18
+ });
19
+
20
+ test("forward-slash Windows paths normalize too", () => {
21
+ expect(toPosixPath("C:/ws")).toBe("/c/ws");
22
+ });
23
+
24
+ test("POSIX absolute paths are unchanged (mac/linux not broken)", () => {
25
+ expect(toPosixPath("/Users/sno/ws")).toBe("/Users/sno/ws");
26
+ expect(toPosixPath("/home/x/proj")).toBe("/home/x/proj");
27
+ expect(toPosixPath("/")).toBe("/");
28
+ });
29
+
30
+ test("already-normalized POSIX-drive path is a no-op", () => {
31
+ expect(toPosixPath("/c/ws")).toBe("/c/ws");
32
+ });
33
+
34
+ test("trims trailing backslashes/slashes on a drive path", () => {
35
+ expect(toPosixPath("C:\\ws\\")).toBe("/c/ws");
36
+ });
37
+ });