codehost 0.20.4 → 0.20.5

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,10 @@
1
+ ## [0.20.5](https://github.com/snomiao/codehost/compare/v0.20.4...v0.20.5) (2026-06-12)
2
+
3
+
4
+ ### Performance Improvements
5
+
6
+ * **tunnel:** dedicated bulk lane — HTTP bodies ride a second data channel so they can't HOL-block typing ([b14797b](https://github.com/snomiao/codehost/commit/b14797b5e4efb4e3f299ba9c3b78bdf360a9a064))
7
+
1
8
  ## [0.20.4](https://github.com/snomiao/codehost/compare/v0.20.3...v0.20.4) (2026-06-12)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.20.4",
3
+ "version": "0.20.5",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
package/src/shared/rtc.ts CHANGED
@@ -24,3 +24,11 @@ export type RtcSignal = SdpSignal | CandidateSignal;
24
24
 
25
25
  /** Label used for the control/tunnel data channel. */
26
26
  export const CHANNEL_LABEL = "codehost";
27
+ /**
28
+ * Second data channel for bulk HTTP bodies. Separate channel = separate SCTP
29
+ * stream, so multi-MB asset downloads no longer head-of-line block the
30
+ * interactive WS traffic (VS Code remote protocol, terminal) on
31
+ * CHANNEL_LABEL. The daemon spins up one Tunnel per incoming channel, so old
32
+ * daemons handle this unmodified; old browsers simply never open it.
33
+ */
34
+ export const BULK_CHANNEL_LABEL = "codehost-bulk";
@@ -15,9 +15,10 @@ import { TunnelClient, type TunnelLike, type TunnelWsHandlers, type TunnelWsHand
15
15
 
16
16
  type AnyMsg = Record<string, any>;
17
17
 
18
- /** Creates the RTCPeerConnection for a peer and resolves with its open channel.
18
+ /** Creates the RTCPeerConnection for a peer and resolves with its open
19
+ * interactive channel plus the (possibly still-connecting) bulk lane.
19
20
  * Provided by the UI (discovery.tsx) and invoked only when this tab is owner. */
20
- export type Establish = () => Promise<RTCDataChannel>;
21
+ export type Establish = () => Promise<{ channel: RTCDataChannel; bulk: RTCDataChannel | null }>;
21
22
 
22
23
  class ConnBroker {
23
24
  private port: MessagePort | null = null;
@@ -110,8 +111,8 @@ class ConnBroker {
110
111
  if (!establish) return;
111
112
  this.establishing.add(peerId);
112
113
  try {
113
- const channel = await establish();
114
- this.locals.set(peerId, new TunnelClient(channel));
114
+ const { channel, bulk } = await establish();
115
+ this.locals.set(peerId, new TunnelClient(channel, bulk));
115
116
  this.post({ t: "ready", peerId });
116
117
  this.resolveReady(peerId);
117
118
  } catch (err) {
@@ -431,7 +431,7 @@ export function Discovery() {
431
431
  // only invoked when we're the owner (or get promoted on failover); other
432
432
  // tabs reuse the owner's channel via a proxy, so they never open WebRTC.
433
433
  const establish = () =>
434
- new Promise<RTCDataChannel>((resolve, reject) => {
434
+ new Promise<{ channel: RTCDataChannel; bulk: RTCDataChannel | null }>((resolve, reject) => {
435
435
  const rtc = new RtcClient({
436
436
  sendSignal: (data: RtcSignal) => send(server.peerId, data),
437
437
  onState: (state) => {
@@ -439,7 +439,7 @@ export function Discovery() {
439
439
  },
440
440
  onOpen: (channel) => {
441
441
  clearTimeout(timer);
442
- resolve(channel);
442
+ resolve({ channel, bulk: rtc.bulkChannel });
443
443
  },
444
444
  onClose: () => setConnState((s) => (s === "connected" ? "idle" : s)),
445
445
  });
@@ -91,7 +91,7 @@ export class CodehostRoom {
91
91
  onOpen: (channel) => {
92
92
  clearTimeout(timer);
93
93
  this.dialFailedAt.delete(peerId);
94
- resolve(new TunnelClient(channel));
94
+ resolve(new TunnelClient(channel, rtc.bulkChannel));
95
95
  },
96
96
  onClose: drop,
97
97
  onState: (state) => {
@@ -1,4 +1,4 @@
1
- import { CHANNEL_LABEL, ICE_SERVERS, type RtcSignal } from "../shared/rtc";
1
+ import { BULK_CHANNEL_LABEL, CHANNEL_LABEL, ICE_SERVERS, type RtcSignal } from "../shared/rtc";
2
2
 
3
3
  export interface RtcClientOptions {
4
4
  /** Relay a signal to the server peer via the signaling channel. */
@@ -15,6 +15,7 @@ export interface RtcClientOptions {
15
15
  export class RtcClient {
16
16
  private pc: RTCPeerConnection;
17
17
  private channel: RTCDataChannel | null = null;
18
+ private bulk: RTCDataChannel | null = null;
18
19
 
19
20
  constructor(private opts: RtcClientOptions) {
20
21
  this.pc = new RTCPeerConnection({
@@ -36,7 +37,7 @@ export class RtcClient {
36
37
  };
37
38
  }
38
39
 
39
- /** Create the data channel + offer and kick off the handshake. */
40
+ /** Create the data channels + offer and kick off the handshake. */
40
41
  async start(): Promise<void> {
41
42
  const channel = this.pc.createDataChannel(CHANNEL_LABEL, { ordered: true });
42
43
  channel.binaryType = "arraybuffer";
@@ -44,6 +45,13 @@ export class RtcClient {
44
45
  channel.onopen = () => this.opts.onOpen?.(channel);
45
46
  channel.onclose = () => this.opts.onClose?.();
46
47
 
48
+ // Bulk lane for HTTP bodies (its own SCTP stream — no HOL with the
49
+ // interactive channel above). Senders fall back to the interactive channel
50
+ // until it opens, so nothing waits on it.
51
+ const bulk = this.pc.createDataChannel(BULK_CHANNEL_LABEL, { ordered: true });
52
+ bulk.binaryType = "arraybuffer";
53
+ this.bulk = bulk;
54
+
47
55
  const offer = await this.pc.createOffer();
48
56
  await this.pc.setLocalDescription(offer);
49
57
  this.opts.sendSignal({ kind: "offer", type: "offer", sdp: offer.sdp ?? "" });
@@ -69,6 +77,11 @@ export class RtcClient {
69
77
  return this.channel;
70
78
  }
71
79
 
80
+ /** The bulk lane (may still be CONNECTING when the interactive one opens). */
81
+ get bulkChannel(): RTCDataChannel | null {
82
+ return this.bulk;
83
+ }
84
+
72
85
  /**
73
86
  * Which ICE path the nominated candidate pair uses: "lan" when both ends
74
87
  * are host candidates (same network — traffic never leaves it), "p2p" for a
@@ -111,6 +124,11 @@ export class RtcClient {
111
124
  } catch {
112
125
  // ignore
113
126
  }
127
+ try {
128
+ this.bulk?.close();
129
+ } catch {
130
+ // ignore
131
+ }
114
132
  try {
115
133
  this.pc.close();
116
134
  } catch {
@@ -49,9 +49,24 @@ export class TunnelClient {
49
49
  private wsRx = new WsReassembler(); // reassembles daemon -> browser WS messages
50
50
  private textEncoder = new TextEncoder();
51
51
 
52
- constructor(private channel: RTCDataChannel) {
52
+ /**
53
+ * `channel` carries the interactive traffic (WebSocket frames — VS Code's
54
+ * remote protocol, terminals); `bulk`, when provided and open, carries HTTP
55
+ * request/response streams on its own SCTP stream so multi-MB asset bodies
56
+ * never head-of-line block a keystroke. The daemon runs one Tunnel per
57
+ * channel, so each lane answers on itself and no demuxing is needed beyond
58
+ * listening on both.
59
+ */
60
+ constructor(
61
+ private channel: RTCDataChannel,
62
+ private bulk: RTCDataChannel | null = null,
63
+ ) {
53
64
  channel.binaryType = "arraybuffer";
54
65
  channel.addEventListener("message", (ev) => this.onFrame(ev.data));
66
+ if (bulk) {
67
+ bulk.binaryType = "arraybuffer";
68
+ bulk.addEventListener("message", (ev) => this.onFrame(ev.data));
69
+ }
55
70
  }
56
71
 
57
72
  private allocId(): number {
@@ -171,11 +186,16 @@ export class TunnelClient {
171
186
  },
172
187
  });
173
188
 
174
- this.send(encodeJson(Op.HttpReq, streamId, { method, path, headers: reqHeaders }));
189
+ // HTTP rides the bulk lane. Pinned per request: every frame of this
190
+ // stream must hit the SAME channel — the daemon runs one Tunnel per
191
+ // channel, so a mid-request switch (e.g. bulk finishing its handshake)
192
+ // would strand the stream across two Tunnels.
193
+ const lane = this.bulk?.readyState === "open" ? this.bulk : this.channel;
194
+ this.sendOn(lane, encodeJson(Op.HttpReq, streamId, { method, path, headers: reqHeaders }));
175
195
  if (body && body.byteLength) {
176
- for (const part of chunk(body)) this.send(encodeFrame(Op.HttpReqBody, streamId, part));
196
+ for (const part of chunk(body)) this.sendOn(lane, encodeFrame(Op.HttpReqBody, streamId, part));
177
197
  }
178
- this.send(encodeFrame(Op.HttpReqEnd, streamId));
198
+ this.sendOn(lane, encodeFrame(Op.HttpReqEnd, streamId));
179
199
  });
180
200
  }
181
201
 
@@ -198,12 +218,17 @@ export class TunnelClient {
198
218
  };
199
219
  }
200
220
 
221
+ /** Interactive-channel send (WS frames, control). */
201
222
  private send(frame: Uint8Array): void {
202
- if (this.channel.readyState === "open") {
223
+ this.sendOn(this.channel, frame);
224
+ }
225
+
226
+ private sendOn(ch: RTCDataChannel, frame: Uint8Array): void {
227
+ if (ch.readyState === "open") {
203
228
  // Copy into a fresh ArrayBuffer-backed view to satisfy send()'s typing.
204
229
  const copy = new Uint8Array(frame.byteLength);
205
230
  copy.set(frame);
206
- this.channel.send(copy.buffer);
231
+ ch.send(copy.buffer);
207
232
  }
208
233
  }
209
234