codehost 0.20.1 → 0.20.3

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/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.20.3](https://github.com/snomiao/codehost/compare/v0.20.2...v0.20.3) (2026-06-12)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **web:** stop wasting wide screens — workspace cards and checkout links flow into grid columns ([3e4b41c](https://github.com/snomiao/codehost/commit/3e4b41c70f111aeff015ba1e50ae687e98d9d5eb))
7
+
8
+ ## [0.20.2](https://github.com/snomiao/codehost/compare/v0.20.1...v0.20.2) (2026-06-11)
9
+
10
+
11
+ ### Performance Improvements
12
+
13
+ * **tunnel:** 64KB frames, gzip passthrough, immutable-asset SW cache, event-driven backpressure; ICE path badge ([da3647c](https://github.com/snomiao/codehost/commit/da3647c96ac1e323237e4743e7f0e4b1a83f741d))
14
+
1
15
  ## [0.20.1](https://github.com/snomiao/codehost/compare/v0.20.0...v0.20.1) (2026-06-11)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.20.1",
3
+ "version": "0.20.3",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli/tunnel.ts CHANGED
@@ -13,6 +13,13 @@ import {
13
13
 
14
14
  const textDecoder = new TextDecoder();
15
15
 
16
+ // Send-queue water marks. HIGH bounds how much data can sit ahead of an
17
+ // interactive message on the (single, ordered) channel — at 20 Mbps, 4 MB is
18
+ // already ~1.6 s of head-of-line latency, so resist raising it; LOW is where
19
+ // the bufferedAmountLow event resumes a paused sender.
20
+ const HIGH_WATER = 4 * 1024 * 1024;
21
+ const LOW_WATER = 1 * 1024 * 1024;
22
+
16
23
  // Hop-by-hop headers that must not be forwarded across the tunnel.
17
24
  const HOP_BY_HOP = new Set([
18
25
  "connection",
@@ -72,6 +79,12 @@ export class Tunnel {
72
79
  ) {
73
80
  this.origin = `http://127.0.0.1:${vscodePort}`;
74
81
  this.wsOrigin = `ws://127.0.0.1:${vscodePort}`;
82
+ try {
83
+ this.channel.setBufferedAmountLowThreshold(LOW_WATER);
84
+ this.channel.onBufferedAmountLow(() => this.drainWaiter?.());
85
+ } catch {
86
+ // older node-datachannel: the poll in waitForDrain still covers it
87
+ }
75
88
  this.channel.onMessage((msg) => {
76
89
  if (typeof msg === "string") return; // all frames are binary
77
90
  const buf = msg as Buffer;
@@ -148,6 +161,14 @@ export class Tunnel {
148
161
  const hasBody = method !== "GET" && method !== "HEAD" && stream.body.length > 0;
149
162
  const body = hasBody ? concat(stream.body) : undefined;
150
163
 
164
+ // A client that can inflate (TunnelClient sends this marker) gets the
165
+ // upstream's gzip bytes passed through UNTOUCHED — 3-4x fewer bytes over
166
+ // the channel for VS Code's JS/CSS. gzip only: the browser inflates with
167
+ // DecompressionStream, which has no brotli.
168
+ const wantsGzip = reqHeaders.get("x-codehost-accept-gzip") === "1";
169
+ reqHeaders.delete("x-codehost-accept-gzip");
170
+ if (wantsGzip) reqHeaders.set("accept-encoding", "gzip");
171
+
151
172
  try {
152
173
  const local = this.onLocal?.({ method, path, headers: reqHeaders, body });
153
174
  const res = local
@@ -157,6 +178,8 @@ export class Tunnel {
157
178
  headers: reqHeaders,
158
179
  body: body as BodyInit | undefined,
159
180
  redirect: "manual",
181
+ // Bun extension: don't auto-inflate — keep the wire bytes compressed.
182
+ ...(wantsGzip ? ({ decompress: false } as RequestInit) : {}),
160
183
  });
161
184
 
162
185
  const resHeaders: Record<string, string> = {};
@@ -240,15 +263,26 @@ export class Tunnel {
240
263
  return p;
241
264
  }
242
265
 
266
+ // Fires from onBufferedAmountLow so a paused sender resumes the moment the
267
+ // queue drains past LOW_WATER instead of on the next poll tick.
268
+ private drainWaiter: (() => void) | null = null;
269
+
243
270
  private waitForDrain(): Promise<void> {
244
- const HIGH = 8 * 1024 * 1024; // 8 MB
245
- if (!this.channel.isOpen() || this.channel.bufferedAmount() < HIGH) return Promise.resolve();
271
+ if (!this.channel.isOpen() || this.channel.bufferedAmount() < HIGH_WATER) return Promise.resolve();
246
272
  return new Promise((resolve) => {
247
- const tick = () => {
248
- if (!this.channel.isOpen() || this.channel.bufferedAmount() < HIGH) resolve();
249
- else setTimeout(tick, 10);
273
+ let settled = false;
274
+ const finish = () => {
275
+ if (settled) return;
276
+ settled = true;
277
+ clearInterval(timer);
278
+ this.drainWaiter = null;
279
+ resolve();
250
280
  };
251
- tick();
281
+ this.drainWaiter = finish;
282
+ // Safety poll in case the low event raced or isn't available.
283
+ const timer = setInterval(() => {
284
+ if (!this.channel.isOpen() || this.channel.bufferedAmount() < HIGH_WATER) finish();
285
+ }, 100);
252
286
  });
253
287
  }
254
288
 
@@ -31,12 +31,15 @@ export enum Op {
31
31
  WsCont = 13,
32
32
  }
33
33
 
34
- // WebRTC data-channel messages must stay small to be portable: 16 KiB is the
35
- // largest size every WebRTC stack (libdatachannel, Chrome, Firefox) reliably
36
- // accepts. A frame is [op:1][streamId:4][payload], so the payload budget is
37
- // 16 KiB minus the 5-byte header.
34
+ // Frame size vs JS overhead: every frame costs a promise hop + two copies on
35
+ // its way through the tunnel, so bigger frames = faster bulk transfer. Both
36
+ // stacks we pair (Chrome/Firefox <-> libdatachannel) negotiate an SCTP
37
+ // max-message-size of 256 KiB, so 64 KiB rides well inside every negotiated
38
+ // limit while cutting per-MB frame count 4x vs the old 16 KiB. Receivers
39
+ // accept any frame size (decodeFrame is length-agnostic), so mixed old/new
40
+ // peers interoperate. A frame is [op:1][streamId:4][payload].
38
41
  export const FRAME_HEADER = 5;
39
- export const MAX_FRAME = 16 * 1024;
42
+ export const MAX_FRAME = 64 * 1024;
40
43
  /** Max payload bytes per frame; larger bodies/messages are split across frames. */
41
44
  export const MAX_CHUNK = MAX_FRAME - FRAME_HEADER;
42
45
 
@@ -193,6 +193,9 @@ export function Discovery() {
193
193
  // client carries the peer's signaling and it's the token Share/history record.
194
194
  const [activePeerId, setActivePeerId] = useState<string | null>(null);
195
195
  const [connState, setConnState] = useState<ConnState>("idle");
196
+ // ICE path of the live session ("lan" | "p2p"); null when unknown or when
197
+ // this tab rides another tab's connection via the broker.
198
+ const [connPath, setConnPath] = useState<"lan" | "p2p" | null>(null);
196
199
  const [iframeSrc, setIframeSrc] = useState<string | null>(null);
197
200
  // Streamed setup.sh output shown while connState === "provisioning".
198
201
  const [provisionLog, setProvisionLog] = useState("");
@@ -455,6 +458,22 @@ export function Discovery() {
455
458
 
456
459
  await connBroker.connect(server.peerId, establish);
457
460
 
461
+ // Show which ICE path got nominated (owner tab only — a proxied tab has
462
+ // no RTCPeerConnection of its own). ICE may re-nominate just after the
463
+ // channel opens, so sample again shortly.
464
+ setConnPath(null);
465
+ // (assertion: TS narrows the ref to null from the reset above and can't
466
+ // see that `establish` re-assigned it)
467
+ const rtcForPath = rtcRef.current as RtcClient | null;
468
+ if (rtcForPath) {
469
+ const sample = () =>
470
+ void rtcForPath.selectedPath().then((p) => {
471
+ if (rtcRef.current === rtcForPath && p) setConnPath(p);
472
+ });
473
+ sample();
474
+ setTimeout(sample, 3000);
475
+ }
476
+
458
477
  // For a repo deep link, ask the daemon to provision (run .codehost/setup.sh
459
478
  // and hand back the authoritative workspace path) before opening. Streams
460
479
  // the log under the "provisioning" state. Daemons without the route (older
@@ -624,6 +643,7 @@ export function Discovery() {
624
643
  rtcRef.current = null;
625
644
  if (activePeerRef.current) connBroker.disconnect(activePeerRef.current);
626
645
  setIframeSrc(null);
646
+ setConnPath(null);
627
647
  setActivePeerId(null);
628
648
  activePeerRef.current = null;
629
649
  activeRoomRef.current = null;
@@ -795,6 +815,18 @@ export function Discovery() {
795
815
  activeServer?.meta?.name ??
796
816
  activePeerId?.slice(0, 8)}
797
817
  </span>
818
+ {connPath && (
819
+ <span
820
+ style={styles.dim}
821
+ title={
822
+ connPath === "lan"
823
+ ? "direct LAN path — both ends on the same network"
824
+ : "direct peer-to-peer path (NAT traversed)"
825
+ }
826
+ >
827
+ {connPath === "lan" ? "⚡LAN" : "🌐p2p"}
828
+ </span>
829
+ )}
798
830
  <span style={{ flex: 1 }} />
799
831
  <button
800
832
  style={styles.shareBtn}
@@ -830,6 +862,9 @@ export function Discovery() {
830
862
  </header>
831
863
 
832
864
  <main style={styles.main}>
865
+ {/* Inputs stay at a readable measure; the workspace grid below uses
866
+ the full width. */}
867
+ <div style={styles.controls}>
833
868
  {resolving && (
834
869
  <p style={{ color: "#dcb67a", marginBottom: 12 }}>
835
870
  Looking for <code style={styles.code}>{resolving}</code> in your rooms…{" "}
@@ -931,6 +966,7 @@ export function Discovery() {
931
966
  {ghError && <p style={styles.tokenError}>{ghError}</p>}
932
967
  </>
933
968
  )}
969
+ </div>
934
970
 
935
971
  <div style={styles.listHead}>
936
972
  <h2 style={styles.h2}>Workspaces</h2>
@@ -1074,7 +1110,11 @@ const styles: Record<string, React.CSSProperties> = {
1074
1110
  brand: { fontFamily: "monospace", fontWeight: 700, color: "#fff" },
1075
1111
  dim: { color: "#888", fontSize: 12 },
1076
1112
  status: { fontSize: 12 },
1077
- main: { flex: 1, overflow: "auto", padding: "20px 24px", maxWidth: 760, width: "100%", margin: "0 auto", boxSizing: "border-box" },
1113
+ // Wide cap + per-host card GRID below: a 4K monitor gets several columns of
1114
+ // workspaces instead of one skinny 760px strip. Inputs keep a readable
1115
+ // measure via `controls`.
1116
+ main: { flex: 1, overflow: "auto", padding: "20px 24px", maxWidth: 1560, width: "100%", margin: "0 auto", boxSizing: "border-box" },
1117
+ controls: { maxWidth: 760 },
1078
1118
  tokenForm: { display: "flex", alignItems: "center", gap: 8, marginBottom: 8 },
1079
1119
  tokenHint: { margin: "0 0 20px", fontSize: 12, color: "#888" },
1080
1120
  tokenError: { margin: "0 0 20px", fontSize: 12, color: "#f48771" },
@@ -1106,13 +1146,22 @@ const styles: Record<string, React.CSSProperties> = {
1106
1146
  border: "1px solid #3d3d3d", background: "transparent", color: "#9aa4af", cursor: "pointer",
1107
1147
  },
1108
1148
  idLine: { fontFamily: "monospace", fontSize: 11, color: "#666", marginTop: 6 },
1109
- wsRow: { display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 3, marginTop: 8 },
1149
+ // Workspace links flow into columns on wide screens (a busy root daemon can
1150
+ // advertise 50+ checkouts — a single column wasted the whole viewport).
1151
+ wsRow: {
1152
+ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
1153
+ gap: "2px 14px", marginTop: 8,
1154
+ },
1110
1155
  wsLink: {
1111
1156
  fontFamily: "monospace", fontSize: 12, padding: "2px 0", border: "none", background: "transparent",
1112
1157
  color: "#75beff", cursor: "pointer", textAlign: "left",
1113
1158
  },
1114
1159
  code: { background: "#252525", padding: "2px 6px", borderRadius: 4, fontFamily: "monospace", fontSize: 12 },
1115
- list: { listStyle: "none", margin: "0 0 14px", padding: 0, display: "flex", flexDirection: "column", gap: 8 },
1160
+ list: {
1161
+ listStyle: "none", margin: "0 0 14px", padding: 0,
1162
+ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(360px, 1fr))", gap: 8,
1163
+ alignItems: "start",
1164
+ },
1116
1165
  hostHead: { display: "flex", alignItems: "baseline", gap: 10, margin: "0 0 8px" },
1117
1166
  hostName: { fontSize: 13, fontWeight: 600, color: "#dcdcaa", fontFamily: "monospace" },
1118
1167
  agentRow: { display: "flex", flexWrap: "wrap", gap: 6, margin: "0 0 8px" },
@@ -69,6 +69,42 @@ export class RtcClient {
69
69
  return this.channel;
70
70
  }
71
71
 
72
+ /**
73
+ * Which ICE path the nominated candidate pair uses: "lan" when both ends
74
+ * are host candidates (same network — traffic never leaves it), "p2p" for a
75
+ * NAT-traversed direct path, null while undetermined. Surfaced in the UI so
76
+ * "it feels slow" reports come with the path attached.
77
+ */
78
+ async selectedPath(): Promise<"lan" | "p2p" | null> {
79
+ try {
80
+ const stats = await this.pc.getStats();
81
+ let pairId: string | null = null;
82
+ stats.forEach((s) => {
83
+ if (s.type === "transport" && s.selectedCandidatePairId) pairId = s.selectedCandidatePairId;
84
+ });
85
+ let pair: RTCIceCandidatePairStats | null = null;
86
+ stats.forEach((s) => {
87
+ if (pairId ? s.id === pairId : s.type === "candidate-pair" && s.state === "succeeded" && s.nominated) {
88
+ pair = s as RTCIceCandidatePairStats;
89
+ }
90
+ });
91
+ if (!pair) return null;
92
+ const { localCandidateId, remoteCandidateId } = pair as RTCIceCandidatePairStats;
93
+ let lan = true;
94
+ let found = 0;
95
+ stats.forEach((s) => {
96
+ if (s.id === localCandidateId || s.id === remoteCandidateId) {
97
+ found++;
98
+ if ((s as { candidateType?: string }).candidateType !== "host") lan = false;
99
+ }
100
+ });
101
+ if (found < 2) return null;
102
+ return lan ? "lan" : "p2p";
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
72
108
  close(): void {
73
109
  try {
74
110
  this.channel?.close();
package/src/web/sw.ts CHANGED
@@ -10,6 +10,12 @@ const sw = self as unknown as ServiceWorkerGlobalScope;
10
10
 
11
11
  const VS_PREFIX = /^\/vs\/([^/]+)(\/.*)?$/;
12
12
  const CDN_CACHE = "codehost-cdn-v1";
13
+ const VS_STATIC_CACHE = "codehost-vs-static-v1";
14
+ // VS Code's own immutable assets: /stable-<commit>/static/** is content-
15
+ // addressed by the commit hash and identical across daemons, so it's cached
16
+ // once per browser instead of crossing the WebRTC tunnel on every load. The
17
+ // cache key strips the per-process /vs/<peerId> prefix.
18
+ const VS_STATIC = /^\/(stable-[0-9a-f]{40})\/static\//;
13
19
 
14
20
  sw.addEventListener("install", () => sw.skipWaiting());
15
21
  sw.addEventListener("activate", (e) => e.waitUntil(sw.clients.claim()));
@@ -35,10 +41,39 @@ sw.addEventListener("fetch", (event: FetchEvent) => {
35
41
  const m = url.pathname.match(VS_PREFIX);
36
42
  if (!m) return; // let the network/Pages handle the discovery app itself
37
43
  const peerId = m[1];
44
+ const rest = (m[2] ?? "/") + url.search;
45
+
46
+ if (event.request.method === "GET" && !event.request.headers.has("range") && VS_STATIC.test(rest)) {
47
+ event.respondWith(cachedStatic(event.request, peerId, rest));
48
+ return;
49
+ }
38
50
 
39
51
  event.respondWith(proxyOverTunnel(event.request, peerId));
40
52
  });
41
53
 
54
+ /** Cache-first for the immutable VS Code static assets; on a cache miss the
55
+ * tunnel fills it, and assets of other (older) commits are evicted. */
56
+ async function cachedStatic(request: Request, peerId: string, rest: string): Promise<Response> {
57
+ const key = `${sw.location.origin}/__codehost/vs-static${rest}`;
58
+ const cache = await caches.open(VS_STATIC_CACHE);
59
+ const hit = await cache.match(key);
60
+ if (hit) return hit;
61
+ const res = await proxyOverTunnel(request, peerId);
62
+ if (res.status === 200) {
63
+ void cache.put(key, res.clone()).catch(() => {});
64
+ void evictOtherCommits(cache, rest).catch(() => {});
65
+ }
66
+ return res;
67
+ }
68
+
69
+ async function evictOtherCommits(cache: Cache, rest: string): Promise<void> {
70
+ const commit = rest.match(VS_STATIC)?.[1];
71
+ if (!commit) return;
72
+ for (const req of await cache.keys()) {
73
+ if (!new URL(req.url).pathname.includes(`/${commit}/`)) void cache.delete(req);
74
+ }
75
+ }
76
+
42
77
  /**
43
78
  * Fetch an allow-listed VS Code CDN asset through the signaling Worker's /cdn
44
79
  * route (which adds CORS), caching the result so each asset crosses to the
@@ -118,14 +118,31 @@ export class TunnelClient {
118
118
  },
119
119
  });
120
120
 
121
+ // Tell the daemon we can inflate: it then passes the upstream's gzip
122
+ // bytes through untouched (3-4x fewer bytes over the channel) and we
123
+ // decompress here, once, for every consumer.
124
+ const reqHeaders =
125
+ typeof DecompressionStream !== "undefined"
126
+ ? { ...headers, "x-codehost-accept-gzip": "1" }
127
+ : headers;
128
+
121
129
  this.https.set(streamId, {
122
130
  onHead: (h) => {
123
131
  head = h;
132
+ const resHeaders = new Headers(h.headers);
133
+ let bodyStream: ReadableStream<Uint8Array> = stream;
134
+ if (resHeaders.get("content-encoding") === "gzip") {
135
+ bodyStream = stream.pipeThrough(
136
+ new DecompressionStream("gzip") as unknown as ReadableWritablePair<Uint8Array, Uint8Array>,
137
+ );
138
+ resHeaders.delete("content-encoding");
139
+ resHeaders.delete("content-length");
140
+ }
124
141
  resolve(
125
- new Response(stream, {
142
+ new Response(bodyStream, {
126
143
  status: h.status === 204 || h.status === 304 ? h.status : h.status,
127
144
  statusText: h.statusText,
128
- headers: h.headers,
145
+ headers: resHeaders,
129
146
  }),
130
147
  );
131
148
  },
@@ -154,7 +171,7 @@ export class TunnelClient {
154
171
  },
155
172
  });
156
173
 
157
- this.send(encodeJson(Op.HttpReq, streamId, { method, path, headers }));
174
+ this.send(encodeJson(Op.HttpReq, streamId, { method, path, headers: reqHeaders }));
158
175
  if (body && body.byteLength) {
159
176
  for (const part of chunk(body)) this.send(encodeFrame(Op.HttpReqBody, streamId, part));
160
177
  }