codehost 0.18.0 → 0.18.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 +14 -0
- package/package.json +1 -1
- package/src/shared/signaling-client.ts +78 -1
- package/src/web/tunnel-client.ts +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [0.18.2](https://github.com/snomiao/codehost/compare/v0.18.1...v0.18.2) (2026-06-11)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **signaling:** recover instantly when a throttled tab wakes (visibility/focus/online) ([d459132](https://github.com/snomiao/codehost/commit/d45913267f053ee1fee062bae550cfe60a193207))
|
|
7
|
+
|
|
8
|
+
## [0.18.1](https://github.com/snomiao/codehost/compare/v0.18.0...v0.18.1) (2026-06-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **signaling:** abort connect attempts stuck in CONNECTING; guard tunnel stream enqueue ([b3ae0d1](https://github.com/snomiao/codehost/commit/b3ae0d11299a195b8d09e42355cd14a3b09b8d2f))
|
|
14
|
+
|
|
1
15
|
# [0.18.0](https://github.com/snomiao/codehost/compare/v0.17.0...v0.18.0) (2026-06-11)
|
|
2
16
|
|
|
3
17
|
|
package/package.json
CHANGED
|
@@ -37,6 +37,12 @@ export interface CloseInfo {
|
|
|
37
37
|
* open/close cycle becomes a sub-second reconnect storm. */
|
|
38
38
|
const STABLE_MS = 10_000;
|
|
39
39
|
|
|
40
|
+
/** Abort a connect attempt that hasn't opened by this deadline. Observed in the
|
|
41
|
+
* field (Chrome, page-load burst): a socket can sit in CONNECTING for minutes
|
|
42
|
+
* and never fire close — so without this, no retry ever runs, even though a
|
|
43
|
+
* freshly-created socket to the same room opens instantly. */
|
|
44
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
45
|
+
|
|
40
46
|
/**
|
|
41
47
|
* Thin WebSocket client for the signaling room. Runs unchanged in the browser
|
|
42
48
|
* and in Bun (both expose a global `WebSocket`). Auto-reconnects with backoff
|
|
@@ -47,6 +53,7 @@ export class SignalingClient {
|
|
|
47
53
|
private ws: WebSocket | null = null;
|
|
48
54
|
private closed = false;
|
|
49
55
|
private reconnectDelay = 1000;
|
|
56
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
50
57
|
private heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
51
58
|
/** Fires STABLE_MS after a socket opens; only then is the backoff reset. */
|
|
52
59
|
private stableTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -59,9 +66,53 @@ export class SignalingClient {
|
|
|
59
66
|
|
|
60
67
|
connect(): void {
|
|
61
68
|
this.closed = false;
|
|
69
|
+
this.attachWakeListeners();
|
|
62
70
|
this.open();
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
// ---- background-tab recovery -------------------------------------------
|
|
74
|
+
// Chrome throttles timers in hidden tabs to minutes, so the backoff retry
|
|
75
|
+
// (and the connect-timeout abort) may be arbitrarily far away even though a
|
|
76
|
+
// fresh socket would connect in milliseconds. When the tab becomes visible /
|
|
77
|
+
// focused / back online, recover NOW instead of waiting for a timer.
|
|
78
|
+
|
|
79
|
+
private onWake = (): void => {
|
|
80
|
+
if (this.closed) return;
|
|
81
|
+
const state = this.ws?.readyState;
|
|
82
|
+
if (state === 1 /* OPEN */) return;
|
|
83
|
+
if (state === 0 /* CONNECTING */) {
|
|
84
|
+
// Stuck handshake: abort — onclose reschedules, and timers run normally
|
|
85
|
+
// now that the tab is active.
|
|
86
|
+
try {
|
|
87
|
+
this.ws?.close();
|
|
88
|
+
} catch {
|
|
89
|
+
// ignore
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Closed and waiting out the (throttled) backoff: skip the wait.
|
|
94
|
+
if (this.reconnectTimer != null) {
|
|
95
|
+
this.clearReconnectTimer();
|
|
96
|
+
this.open();
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
private attachWakeListeners(): void {
|
|
101
|
+
const doc = (globalThis as { document?: EventTarget }).document;
|
|
102
|
+
doc?.addEventListener("visibilitychange", this.onWake);
|
|
103
|
+
const win = (globalThis as { window?: EventTarget }).window;
|
|
104
|
+
win?.addEventListener("focus", this.onWake);
|
|
105
|
+
win?.addEventListener("online", this.onWake);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private detachWakeListeners(): void {
|
|
109
|
+
const doc = (globalThis as { document?: EventTarget }).document;
|
|
110
|
+
doc?.removeEventListener("visibilitychange", this.onWake);
|
|
111
|
+
const win = (globalThis as { window?: EventTarget }).window;
|
|
112
|
+
win?.removeEventListener("focus", this.onWake);
|
|
113
|
+
win?.removeEventListener("online", this.onWake);
|
|
114
|
+
}
|
|
115
|
+
|
|
65
116
|
private roomUrl(): string {
|
|
66
117
|
const base = this.opts.url.replace(/\/+$/, "");
|
|
67
118
|
return `${base}/room/${encodeURIComponent(this.opts.token)}`;
|
|
@@ -71,7 +122,21 @@ export class SignalingClient {
|
|
|
71
122
|
const ws = new WebSocket(this.roomUrl());
|
|
72
123
|
this.ws = ws;
|
|
73
124
|
|
|
125
|
+
// A stuck CONNECTING socket never fires close on its own — abort it so the
|
|
126
|
+
// normal onclose -> backoff -> retry path takes over.
|
|
127
|
+
const connectTimer = setTimeout(() => {
|
|
128
|
+
if (ws.readyState === 0 /* CONNECTING */) {
|
|
129
|
+
try {
|
|
130
|
+
ws.close();
|
|
131
|
+
} catch {
|
|
132
|
+
// closing an unopened socket may throw in some runtimes — the
|
|
133
|
+
// onerror/onclose path still runs
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}, CONNECT_TIMEOUT_MS);
|
|
137
|
+
|
|
74
138
|
ws.onopen = () => {
|
|
139
|
+
clearTimeout(connectTimer);
|
|
75
140
|
this.openedAt = Date.now();
|
|
76
141
|
// Don't reset the backoff yet — only once the socket proves stable (see
|
|
77
142
|
// STABLE_MS). A handshake-then-drop network never reaches this timer, so
|
|
@@ -103,6 +168,7 @@ export class SignalingClient {
|
|
|
103
168
|
};
|
|
104
169
|
|
|
105
170
|
ws.onclose = (ev) => {
|
|
171
|
+
clearTimeout(connectTimer);
|
|
106
172
|
this.clearStableTimer();
|
|
107
173
|
this.stopHeartbeat();
|
|
108
174
|
const ms = this.openedAt ? Date.now() - this.openedAt : 0;
|
|
@@ -151,11 +217,20 @@ export class SignalingClient {
|
|
|
151
217
|
private scheduleReconnect(): void {
|
|
152
218
|
const delay = this.reconnectDelay;
|
|
153
219
|
this.reconnectDelay = Math.min(delay * 2, 15000);
|
|
154
|
-
|
|
220
|
+
this.clearReconnectTimer();
|
|
221
|
+
this.reconnectTimer = setTimeout(() => {
|
|
222
|
+
this.reconnectTimer = null;
|
|
155
223
|
if (!this.closed) this.open();
|
|
156
224
|
}, delay);
|
|
157
225
|
}
|
|
158
226
|
|
|
227
|
+
private clearReconnectTimer(): void {
|
|
228
|
+
if (this.reconnectTimer != null) {
|
|
229
|
+
clearTimeout(this.reconnectTimer);
|
|
230
|
+
this.reconnectTimer = null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
159
234
|
sendSignal(to: string, data: unknown): void {
|
|
160
235
|
const msg: ClientMessage = { type: "signal", to, data };
|
|
161
236
|
this.ws?.send(JSON.stringify(msg));
|
|
@@ -173,6 +248,8 @@ export class SignalingClient {
|
|
|
173
248
|
|
|
174
249
|
close(): void {
|
|
175
250
|
this.closed = true;
|
|
251
|
+
this.detachWakeListeners();
|
|
252
|
+
this.clearReconnectTimer();
|
|
176
253
|
this.stopHeartbeat();
|
|
177
254
|
this.clearStableTimer();
|
|
178
255
|
try {
|
package/src/web/tunnel-client.ts
CHANGED
|
@@ -129,7 +129,13 @@ export class TunnelClient {
|
|
|
129
129
|
}),
|
|
130
130
|
);
|
|
131
131
|
},
|
|
132
|
-
onBody: (b) =>
|
|
132
|
+
onBody: (b) => {
|
|
133
|
+
try {
|
|
134
|
+
controller?.enqueue(b);
|
|
135
|
+
} catch {
|
|
136
|
+
// stream already closed/cancelled (consumer went away mid-body)
|
|
137
|
+
}
|
|
138
|
+
},
|
|
133
139
|
onEnd: () => {
|
|
134
140
|
try {
|
|
135
141
|
controller?.close();
|