codehost 0.1.1 → 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/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
+ }
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,104 @@
1
+ // Shared helpers for GitHub-shaped deep links and matching them to a daemon.
2
+ // Used by the web resolver (src/web) and conceptually mirrors the daemon's
3
+ // repo identity (src/cli/git.ts).
4
+
5
+ import type { PeerInfo, PeerMeta } from "./signaling";
6
+
7
+ export const DEFAULT_LAYOUT = "{owner}/{repo}/tree/{branch}";
8
+
9
+ export interface RepoTarget {
10
+ provider: "gh";
11
+ owner: string;
12
+ name: string;
13
+ /** Branch from the deep link, if present. */
14
+ branch?: string;
15
+ }
16
+
17
+ /** A direct folder mount address: `/dev/<absolute-fs-path>`. */
18
+ export interface DevTarget {
19
+ path: string;
20
+ }
21
+
22
+ export type DeepLink =
23
+ | { type: "repo"; target: RepoTarget }
24
+ | { type: "dev"; target: DevTarget }
25
+ | null;
26
+
27
+ /**
28
+ * Parse a deep-link pathname:
29
+ * /gh/<owner>/<repo> -> repo target (default branch)
30
+ * /gh/<owner>/<repo>/tree/<branch> -> repo target (branch; may contain slashes)
31
+ * /dev/<fs-path> -> direct folder mount
32
+ * Anything else -> null (normal app).
33
+ */
34
+ export function parseDeepLink(pathname: string): DeepLink {
35
+ const clean = pathname.replace(/\/+$/, "");
36
+ const gh = clean.match(/^\/gh\/([^/]+)\/([^/]+)(?:\/tree\/(.+))?$/);
37
+ if (gh) {
38
+ return {
39
+ type: "repo",
40
+ target: { provider: "gh", owner: gh[1], name: gh[2], branch: gh[3] },
41
+ };
42
+ }
43
+ const dev = clean.match(/^\/dev\/(.+)$/);
44
+ if (dev) {
45
+ return { type: "dev", target: { path: `/${dev[1].replace(/^\/+/, "")}` } };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /** Normalized repo key, e.g. "gh/owner/repo". */
51
+ export function repoKey(t: Pick<RepoTarget, "owner" | "name">): string {
52
+ return `gh/${t.owner}/${t.name}`;
53
+ }
54
+
55
+ /** Fill a layout template from a repo target (default branch -> "main"). */
56
+ export function fillLayout(layout: string, t: RepoTarget): string {
57
+ return layout
58
+ .replace(/\{owner\}/g, t.owner)
59
+ .replace(/\{repo\}/g, t.name)
60
+ .replace(/\{branch\}/g, t.branch || "main");
61
+ }
62
+
63
+ export interface Resolution {
64
+ peerId: string;
65
+ /** Folder to open via ?folder= (root kind); undefined opens the repo as-is. */
66
+ folder?: string;
67
+ }
68
+
69
+ /**
70
+ * Pick the best live server for a repo deep link. Prefers an exact `repo`
71
+ * daemon; otherwise falls back to a `root` daemon that can open the subfolder.
72
+ * Returns null if nothing matches.
73
+ */
74
+ export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Resolution | null {
75
+ const key = repoKey(target);
76
+ const repoMatch = servers.find(
77
+ (s) => s.meta?.kind !== "root" && s.meta?.repo === key && branchOk(s.meta, target),
78
+ );
79
+ if (repoMatch) return { peerId: repoMatch.peerId };
80
+
81
+ const root = servers.find((s) => s.meta?.kind === "root");
82
+ if (root && root.meta) {
83
+ const folder = `${trimSlash(root.meta.cwd)}/${fillLayout(root.meta.layout || DEFAULT_LAYOUT, target)}`;
84
+ return { peerId: root.peerId, folder };
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /** Pick a `dev`/repo server whose served cwd matches a /dev/<path> target. */
90
+ export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolution | null {
91
+ const want = trimSlash(target.path);
92
+ const hit = servers.find((s) => s.meta && trimSlash(s.meta.cwd) === want);
93
+ return hit ? { peerId: hit.peerId } : null;
94
+ }
95
+
96
+ function branchOk(meta: PeerMeta, target: RepoTarget): boolean {
97
+ // No branch requested, or the server doesn't report one -> accept; else exact.
98
+ if (!target.branch || !meta.branch) return true;
99
+ return meta.branch === target.branch;
100
+ }
101
+
102
+ function trimSlash(p: string): string {
103
+ return p.replace(/\/+$/, "");
104
+ }
@@ -4,14 +4,30 @@
4
4
 
5
5
  export type Role = "server" | "viewer";
6
6
 
7
- /** Metadata a `codehost serve` daemon advertises about itself. */
7
+ /** Metadata a `codehost serve`/`dev` daemon advertises about itself. */
8
8
  export interface PeerMeta {
9
9
  /** Human label, defaults to hostname. */
10
10
  name: string;
11
- /** Directory the VS Code instance is serving. */
11
+ /** Directory the VS Code instance is serving (the repo dir, or the root). */
12
12
  cwd: string;
13
13
  /** Hostname of the machine running the daemon. */
14
14
  host: string;
15
+ /**
16
+ * "repo": serves a single folder (`codehost dev`), git-identified when possible.
17
+ * "root": serves a workspace root (`codehost serve`) whose repos live under it.
18
+ * Absent is treated as "repo" for backward compatibility.
19
+ */
20
+ kind?: "repo" | "root";
21
+ /** repo kind: normalized identity, e.g. "gh/snomiao/codehost". */
22
+ repo?: string;
23
+ /** repo kind: current branch, e.g. "main". */
24
+ branch?: string;
25
+ /**
26
+ * root kind: on-disk layout of repos under `cwd`, as a template filled from a
27
+ * deep link — default "{owner}/{repo}/tree/{branch}". The opened folder is
28
+ * `cwd + "/" + fill(layout, target)`.
29
+ */
30
+ layout?: string;
15
31
  }
16
32
 
17
33
  export interface PeerInfo {
package/src/web/config.ts CHANGED
@@ -3,19 +3,56 @@
3
3
  // (vite on :5173) it talks to `wrangler dev` on :8787. Override either with
4
4
  // localStorage key "codehost.signal".
5
5
 
6
+ /** The signaling host for a given page host, e.g. signal.codehost.dev. */
7
+ function signalHost(hostname: string): string {
8
+ return `signal.${hostname.replace(/^www\./, "")}`;
9
+ }
10
+
6
11
  function defaultSignalUrl(): string {
7
- if (typeof window === "undefined") return "wss://signal.codehost.dev";
8
- const { hostname, protocol } = window.location;
12
+ // Read location off globalThis so this module also type-checks in the Service
13
+ // Worker build (webworker lib, no `window`). In a worker there's no signaling
14
+ // anyway; getSignalUrl is page-only and tree-shaken out of the SW bundle.
15
+ const loc = (globalThis as { location?: { hostname: string; protocol: string } }).location;
16
+ if (!loc) return "wss://signal.codehost.dev";
17
+ const { hostname, protocol } = loc;
9
18
  const isLocal = hostname === "localhost" || hostname === "127.0.0.1";
10
19
  if (isLocal) return "ws://localhost:8787";
11
20
  const wsProto = protocol === "https:" ? "wss:" : "ws:";
12
- return `${wsProto}//signal.${hostname.replace(/^www\./, "")}`;
21
+ return `${wsProto}//${signalHost(hostname)}`;
13
22
  }
14
23
 
15
24
  export function getSignalUrl(): string {
16
- if (typeof localStorage !== "undefined") {
17
- const override = localStorage.getItem("codehost.signal");
18
- if (override) return override;
19
- }
25
+ const ls = (globalThis as { localStorage?: { getItem(k: string): string | null } }).localStorage;
26
+ const override = ls?.getItem("codehost.signal");
27
+ if (override) return override;
20
28
  return defaultSignalUrl();
21
29
  }
30
+
31
+ // --- VS Code CDN proxy ---------------------------------------------------
32
+ // Microsoft's VS Code product CDN sends no CORS headers, so the workbench's
33
+ // cross-origin fetches (e.g. https://main.vscode-cdn.net/extensions/chat.json)
34
+ // are blocked when VS Code runs inside our iframe. The Service Worker rewrites
35
+ // those requests to the signaling Worker's /cdn route, which re-serves them
36
+ // with Access-Control-Allow-Origin: *. See docs/vscode-cdn-proxy.md.
37
+
38
+ /** Hostname suffix for the CDN we proxy. The Worker is the authoritative gate;
39
+ * this lets the SW know which cross-origin requests to rewrite. */
40
+ export const VSCODE_CDN_SUFFIX = ".vscode-cdn.net";
41
+
42
+ export function isProxiableCdnHost(hostname: string): boolean {
43
+ return hostname.endsWith(VSCODE_CDN_SUFFIX);
44
+ }
45
+
46
+ /**
47
+ * HTTPS base of the signaling host for the current page, e.g.
48
+ * https://signal.codehost.dev. The SW rewrites blocked CDN requests to
49
+ * `${base}/cdn/<host>/<path>`. Derived from the live host (never hardcoded) so a
50
+ * self-hoster serving the page + Worker on their own domain is proxied by their
51
+ * own Worker at signal.<their-domain>.
52
+ */
53
+ export function cdnProxyBase(hostname: string, protocol: string): string {
54
+ const isLocal = hostname === "localhost" || hostname === "127.0.0.1";
55
+ if (isLocal) return "http://localhost:8787";
56
+ const httpProto = protocol === "https:" ? "https:" : "http:";
57
+ return `${httpProto}//${signalHost(hostname)}`;
58
+ }