codehost 0.20.1 → 0.20.2
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 +7 -0
- package/package.json +1 -1
- package/src/cli/tunnel.ts +40 -6
- package/src/shared/protocol.ts +8 -5
- package/src/web/discovery.tsx +32 -0
- package/src/web/rtc-client.ts +36 -0
- package/src/web/sw.ts +35 -0
- package/src/web/tunnel-client.ts +20 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [0.20.2](https://github.com/snomiao/codehost/compare/v0.20.1...v0.20.2) (2026-06-11)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Performance Improvements
|
|
5
|
+
|
|
6
|
+
* **tunnel:** 64KB frames, gzip passthrough, immutable-asset SW cache, event-driven backpressure; ICE path badge ([da3647c](https://github.com/snomiao/codehost/commit/da3647c96ac1e323237e4743e7f0e4b1a83f741d))
|
|
7
|
+
|
|
1
8
|
## [0.20.1](https://github.com/snomiao/codehost/compare/v0.20.0...v0.20.1) (2026-06-11)
|
|
2
9
|
|
|
3
10
|
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
|
package/src/shared/protocol.ts
CHANGED
|
@@ -31,12 +31,15 @@ export enum Op {
|
|
|
31
31
|
WsCont = 13,
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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 =
|
|
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
|
|
package/src/web/discovery.tsx
CHANGED
|
@@ -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}
|
package/src/web/rtc-client.ts
CHANGED
|
@@ -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
|
package/src/web/tunnel-client.ts
CHANGED
|
@@ -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(
|
|
142
|
+
new Response(bodyStream, {
|
|
126
143
|
status: h.status === 204 || h.status === 304 ? h.status : h.status,
|
|
127
144
|
statusText: h.statusText,
|
|
128
|
-
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
|
}
|