codehost 0.20.0 → 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 +14 -0
- package/package.json +1 -1
- package/src/cli/commands/serve.ts +19 -6
- package/src/cli/plugins/agent-yes.test.ts +37 -1
- package/src/cli/plugins/agent-yes.ts +41 -2
- package/src/cli/run-server.ts +19 -7
- package/src/cli/tunnel.ts +40 -6
- package/src/shared/protocol.ts +8 -5
- package/src/shared/signaling-client.ts +53 -13
- package/src/web/discovery.tsx +40 -0
- package/src/web/room-client.ts +16 -1
- package/src/web/rtc-client.ts +36 -0
- package/src/web/sw.ts +35 -0
- package/src/web/tunnel-client.ts +20 -3
- package/worker/room.ts +8 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
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
|
+
|
|
8
|
+
## [0.20.1](https://github.com/snomiao/codehost/compare/v0.20.0...v0.20.1) (2026-06-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **signaling:** tame the room-DO request storm; live agent titles on the sidepanel ([d39dceb](https://github.com/snomiao/codehost/commit/d39dceb2c7e3cd47dcb17f099aa8e0d37d3a7e13))
|
|
14
|
+
|
|
1
15
|
# [0.20.0](https://github.com/snomiao/codehost/compare/v0.19.0...v0.20.0) (2026-06-11)
|
|
2
16
|
|
|
3
17
|
|
package/package.json
CHANGED
|
@@ -146,16 +146,26 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
|
146
146
|
// so the advertised list and the provisioned paths agree.
|
|
147
147
|
const layout = readCodehostConfig(dir).workspace || DEFAULT_LAYOUT;
|
|
148
148
|
const plugins = [agentYesPlugin()].filter((p) => p != null);
|
|
149
|
+
// buildMeta runs every AGENTS_META_POLL_MS so live agent titles propagate
|
|
150
|
+
// (the room only sees a push when something changed). The filesystem walk
|
|
151
|
+
// for checkouts is the expensive part — memoize it; registered workspaces
|
|
152
|
+
// and agents are cheap reads and stay fresh on every call.
|
|
153
|
+
const WORKSPACE_WALK_TTL_MS = 30_000;
|
|
154
|
+
let wsWalk: { at: number; list: ReturnType<typeof enumerateWorkspaces> } | null = null;
|
|
149
155
|
const buildMeta = (): PeerMeta => {
|
|
150
156
|
// Layout-enumerated checkouts plus directories other `codehost dev` runs
|
|
151
157
|
// registered with this host daemon (git-identified best-effort).
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
+
if (!wsWalk || Date.now() - wsWalk.at > WORKSPACE_WALK_TTL_MS) {
|
|
159
|
+
const list = enumerateWorkspaces(dir, layout);
|
|
160
|
+
// The config dir itself is editable from the site (rendered as ⚙, opens
|
|
161
|
+
// in the editor) — advertised so its /host/<host>/<path> link resolves.
|
|
162
|
+
const configDir = join(dir, ".codehost");
|
|
163
|
+
if (existsSync(configDir)) {
|
|
164
|
+
list.push({ path: toPosixPath(configDir), config: true });
|
|
165
|
+
}
|
|
166
|
+
wsWalk = { at: Date.now(), list };
|
|
158
167
|
}
|
|
168
|
+
const workspaces = [...wsWalk.list];
|
|
159
169
|
for (const w of readRegisteredWorkspaces()) {
|
|
160
170
|
const path = toPosixPath(w.path);
|
|
161
171
|
if (workspaces.some((x) => x.path === path)) continue;
|
|
@@ -193,6 +203,9 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
|
193
203
|
signal: argv.signal,
|
|
194
204
|
meta: buildMeta(),
|
|
195
205
|
refreshMeta: buildMeta,
|
|
206
|
+
// Fast poll so agents' self-set titles go live on the site sidepanel;
|
|
207
|
+
// per tick it's pid-liveness checks + log-tail stat()s (see liveTitle).
|
|
208
|
+
metaRefreshMs: 3_000,
|
|
196
209
|
watchFiles: [workspacesFile()],
|
|
197
210
|
plugins,
|
|
198
211
|
label: `serving workspace root ${dir}`,
|
|
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { agentYesPlugin, readAgents } from "./agent-yes";
|
|
5
|
+
import { agentYesPlugin, liveTitle, readAgents } from "./agent-yes";
|
|
6
6
|
import { routePlugins, withPluginMeta } from "./types";
|
|
7
7
|
|
|
8
8
|
function makeAyDir(lines: object[]): string {
|
|
@@ -34,6 +34,42 @@ describe("readAgents", () => {
|
|
|
34
34
|
test("missing registry -> empty", () => {
|
|
35
35
|
expect(readAgents(mkdtempSync(join(tmpdir(), "codehost-ay-empty-")))).toEqual([]);
|
|
36
36
|
});
|
|
37
|
+
|
|
38
|
+
test("live OSC title from the log tail beats the launch prompt", () => {
|
|
39
|
+
const dir = mkdtempSync(join(tmpdir(), "codehost-ay-title-"));
|
|
40
|
+
const log = join(dir, "agent.raw.log");
|
|
41
|
+
writeFileSync(log, "boot\x1b]2;first title\x07work work\x1b]0;renamed by agent\x07tail");
|
|
42
|
+
writeFileSync(
|
|
43
|
+
join(dir, "pids.jsonl"),
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
pid: process.pid,
|
|
46
|
+
cli: "claude",
|
|
47
|
+
prompt: "the launch prompt",
|
|
48
|
+
cwd: "/tmp/x",
|
|
49
|
+
log_file: log,
|
|
50
|
+
status: "active",
|
|
51
|
+
}) + "\n",
|
|
52
|
+
);
|
|
53
|
+
expect(readAgents(dir)[0].title).toBe("renamed by agent");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("liveTitle", () => {
|
|
58
|
+
test("returns the LAST OSC 0/2 title, cached by (size, mtime)", () => {
|
|
59
|
+
const dir = mkdtempSync(join(tmpdir(), "codehost-ay-osc-"));
|
|
60
|
+
const log = join(dir, "x.log");
|
|
61
|
+
writeFileSync(log, "\x1b]0;one\x07...\x1b]2;two\x1b\\rest");
|
|
62
|
+
expect(liveTitle(log)).toBe("two");
|
|
63
|
+
expect(liveTitle(log)).toBe("two"); // cache hit path
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("missing file / no title -> null", () => {
|
|
67
|
+
expect(liveTitle("/nonexistent/x.log")).toBeNull();
|
|
68
|
+
const dir = mkdtempSync(join(tmpdir(), "codehost-ay-osc2-"));
|
|
69
|
+
const log = join(dir, "plain.log");
|
|
70
|
+
writeFileSync(log, "no escapes here");
|
|
71
|
+
expect(liveTitle(log)).toBeNull();
|
|
72
|
+
});
|
|
37
73
|
});
|
|
38
74
|
|
|
39
75
|
describe("plugin routing + meta", () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { toPosixPath } from "../../shared/repo";
|
|
@@ -22,10 +22,47 @@ interface AyRecord {
|
|
|
22
22
|
cli?: string;
|
|
23
23
|
prompt?: string | null;
|
|
24
24
|
cwd?: string;
|
|
25
|
+
log_file?: string | null;
|
|
25
26
|
status?: "active" | "idle" | "exited";
|
|
26
27
|
started_at?: number;
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
// Agents retitle their terminal by writing OSC 0/2 (\x1b]2;name\x07) into the
|
|
31
|
+
// PTY stream agent-yes logs; the most recent one is the live title (same trick
|
|
32
|
+
// `ay serve` uses for /api/ls). Read straight from the log tail so it works
|
|
33
|
+
// even when ay serve is down. Cached per (size, mtime): only logs that grew
|
|
34
|
+
// since the last refresh are re-read, so a frequent meta poll stays cheap.
|
|
35
|
+
const TITLE_TAIL_BYTES = 65536;
|
|
36
|
+
const titleCache = new Map<string, { size: number; mtimeMs: number; title: string | null }>();
|
|
37
|
+
|
|
38
|
+
export function liveTitle(logFile: string | null | undefined): string | null {
|
|
39
|
+
if (!logFile) return null;
|
|
40
|
+
try {
|
|
41
|
+
const fd = openSync(logFile, "r");
|
|
42
|
+
try {
|
|
43
|
+
const { size, mtimeMs } = fstatSync(fd);
|
|
44
|
+
const hit = titleCache.get(logFile);
|
|
45
|
+
if (hit && hit.size === size && hit.mtimeMs === mtimeMs) return hit.title;
|
|
46
|
+
const len = Math.min(size, TITLE_TAIL_BYTES);
|
|
47
|
+
const buf = Buffer.allocUnsafe(len);
|
|
48
|
+
const bytesRead = readSync(fd, buf, 0, len, size - len);
|
|
49
|
+
const text = buf.toString("utf-8", 0, bytesRead);
|
|
50
|
+
// eslint-disable-next-line no-control-regex
|
|
51
|
+
const oscTitleRe = /\x1b\][02];([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
|
|
52
|
+
let title: string | null = null;
|
|
53
|
+
for (let m: RegExpExecArray | null; (m = oscTitleRe.exec(text)); ) {
|
|
54
|
+
if (m[1].trim()) title = m[1].trim();
|
|
55
|
+
}
|
|
56
|
+
titleCache.set(logFile, { size, mtimeMs, title });
|
|
57
|
+
return title;
|
|
58
|
+
} finally {
|
|
59
|
+
closeSync(fd);
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
29
66
|
/** agent-yes's global registry: JSONL, last line per pid wins. */
|
|
30
67
|
export function readAgents(dir: string = AY_DIR): AgentInfo[] {
|
|
31
68
|
let raw: string;
|
|
@@ -48,10 +85,12 @@ export function readAgents(dir: string = AY_DIR): AgentInfo[] {
|
|
|
48
85
|
for (const rec of [...byPid.values()].sort((a, b) => (b.started_at ?? 0) - (a.started_at ?? 0))) {
|
|
49
86
|
if (out.length >= MAX_AGENTS) break;
|
|
50
87
|
if (rec.status === "exited" || !alive(rec.pid)) continue;
|
|
88
|
+
// Live self-set title from the PTY log beats the static launch prompt.
|
|
89
|
+
const title = liveTitle(rec.log_file) ?? rec.prompt ?? null;
|
|
51
90
|
out.push({
|
|
52
91
|
pid: rec.pid,
|
|
53
92
|
tool: rec.cli || "agent",
|
|
54
|
-
...(
|
|
93
|
+
...(title ? { title: title.slice(0, 120) } : {}),
|
|
55
94
|
cwd: toPosixPath(rec.cwd ?? ""),
|
|
56
95
|
state: rec.status === "active" ? "active" : "idle",
|
|
57
96
|
...(rec.started_at ? { startedAt: rec.started_at } : {}),
|
package/src/cli/run-server.ts
CHANGED
|
@@ -31,10 +31,14 @@ export interface RunServerOptions {
|
|
|
31
31
|
/** Enables `/__codehost/provision` on the tunnel (serve only — runs the home's
|
|
32
32
|
* setup.sh). Omitted by `expose`, which has no home/workspace. */
|
|
33
33
|
provision?: ProvisionDeps;
|
|
34
|
-
/** Recompute the advertised meta (e.g. re-enumerate workspaces). Polled on
|
|
35
|
-
*
|
|
34
|
+
/** Recompute the advertised meta (e.g. re-enumerate workspaces). Polled on
|
|
35
|
+
* an interval and right after each provision; pushed to the room only when
|
|
36
36
|
* it actually changed. */
|
|
37
37
|
refreshMeta?: () => PeerMeta;
|
|
38
|
+
/** Meta poll cadence (default META_REFRESH_MS). `serve` polls fast so live
|
|
39
|
+
* agent titles propagate — refreshMeta must then be cheap per call. The
|
|
40
|
+
* room only sees a message when the meta actually changed. */
|
|
41
|
+
metaRefreshMs?: number;
|
|
38
42
|
/** Daemon plugins: tunneled routes under /__codehost/<name>/ (their meta
|
|
39
43
|
* contributions are the caller's job, inside `meta`/`refreshMeta`). */
|
|
40
44
|
plugins?: DaemonPlugin[];
|
|
@@ -113,16 +117,24 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
|
|
|
113
117
|
|
|
114
118
|
client.connect();
|
|
115
119
|
if (opts.refreshMeta) {
|
|
116
|
-
setInterval(refreshMeta, META_REFRESH_MS);
|
|
117
|
-
//
|
|
118
|
-
//
|
|
120
|
+
setInterval(refreshMeta, opts.metaRefreshMs ?? META_REFRESH_MS);
|
|
121
|
+
// Near-instant re-advertise when a watched file changes. Throttled, not
|
|
122
|
+
// debounced: fs.watch fires in bursts, but a file that changes CONSTANTLY
|
|
123
|
+
// (a busy registry) would keep resetting a debounce forever — a trailing
|
|
124
|
+
// throttle guarantees a refresh at most/at least every 300ms.
|
|
119
125
|
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
126
|
+
const requestRefresh = () => {
|
|
127
|
+
if (pending) return;
|
|
128
|
+
pending = setTimeout(() => {
|
|
129
|
+
pending = null;
|
|
130
|
+
refreshMeta();
|
|
131
|
+
}, 300);
|
|
132
|
+
};
|
|
120
133
|
for (const file of opts.watchFiles ?? []) {
|
|
121
134
|
try {
|
|
122
135
|
watch(dirname(file), (_event, filename) => {
|
|
123
136
|
if (filename && filename !== basename(file)) return;
|
|
124
|
-
|
|
125
|
-
pending = setTimeout(refreshMeta, 300);
|
|
137
|
+
requestRefresh();
|
|
126
138
|
});
|
|
127
139
|
} catch {
|
|
128
140
|
// missing dir / unsupported platform — the interval still covers it
|
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
|
|
|
@@ -34,8 +34,10 @@ export interface CloseInfo {
|
|
|
34
34
|
* connection that completes the handshake then drops within seconds (a
|
|
35
35
|
* middlebox that accepts the WebSocket upgrade but kills the socket, seen on
|
|
36
36
|
* some field networks) must keep backing off — otherwise every reset-to-1s
|
|
37
|
-
* open/close cycle becomes a sub-second reconnect storm.
|
|
38
|
-
|
|
37
|
+
* open/close cycle becomes a sub-second reconnect storm. A server that drops
|
|
38
|
+
* sockets every few tens of seconds (room DO redeploys, sweep evictions) must
|
|
39
|
+
* also keep backing off, so this sits above any such churn period. */
|
|
40
|
+
const STABLE_MS = 60_000;
|
|
39
41
|
|
|
40
42
|
/** Abort a connect attempt that hasn't opened by this deadline. Observed in the
|
|
41
43
|
* field (Chrome, page-load burst): a socket can sit in CONNECTING for minutes
|
|
@@ -43,6 +45,20 @@ const STABLE_MS = 10_000;
|
|
|
43
45
|
* freshly-created socket to the same room opens instantly. */
|
|
44
46
|
const CONNECT_TIMEOUT_MS = 10_000;
|
|
45
47
|
|
|
48
|
+
/** Reconnect backoff bounds. Every signaling round-trip (WS upgrade, hello,
|
|
49
|
+
* ping) is a billable request on the room Durable Object, so idle/broken
|
|
50
|
+
* clients must converge to a slow cadence: cap at 2 min, with ±25% jitter so
|
|
51
|
+
* a fleet of daemons dropped by one server restart doesn't thundering-herd. */
|
|
52
|
+
const RECONNECT_MIN_MS = 1_000;
|
|
53
|
+
const RECONNECT_MAX_MS = 120_000;
|
|
54
|
+
|
|
55
|
+
/** Heartbeat cadence. Paired with the room's STALE_MS (65s): the sweep
|
|
56
|
+
* tolerates ~2 missed beats. Each ping is a billable DO request — at 25s a
|
|
57
|
+
* day-long connection costs ~3.5k requests, vs ~8.6k at the old 10s. Hidden
|
|
58
|
+
* tabs survive too: Chrome's intensive throttling clamps timers to 1/min,
|
|
59
|
+
* still inside the 65s window. */
|
|
60
|
+
const HEARTBEAT_MS = 25_000;
|
|
61
|
+
|
|
46
62
|
/**
|
|
47
63
|
* Thin WebSocket client for the signaling room. Runs unchanged in the browser
|
|
48
64
|
* and in Bun (both expose a global `WebSocket`). Auto-reconnects with backoff
|
|
@@ -52,8 +68,10 @@ export class SignalingClient {
|
|
|
52
68
|
readonly peerId: string;
|
|
53
69
|
private ws: WebSocket | null = null;
|
|
54
70
|
private closed = false;
|
|
55
|
-
private reconnectDelay =
|
|
71
|
+
private reconnectDelay = RECONNECT_MIN_MS;
|
|
56
72
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
73
|
+
/** True while a hidden tab sits out reconnection; onWake resumes it. */
|
|
74
|
+
private dormant = false;
|
|
57
75
|
private heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
58
76
|
/** Fires STABLE_MS after a socket opens; only then is the backoff reset. */
|
|
59
77
|
private stableTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -90,13 +108,24 @@ export class SignalingClient {
|
|
|
90
108
|
}
|
|
91
109
|
return;
|
|
92
110
|
}
|
|
93
|
-
//
|
|
94
|
-
|
|
111
|
+
// Dormant (hidden tab sat out reconnection) or waiting out a throttled
|
|
112
|
+
// backoff: reconnect now.
|
|
113
|
+
if (this.dormant || this.reconnectTimer != null) {
|
|
114
|
+
this.dormant = false;
|
|
95
115
|
this.clearReconnectTimer();
|
|
96
116
|
this.open();
|
|
97
117
|
}
|
|
98
118
|
};
|
|
99
119
|
|
|
120
|
+
/** A hidden tab doesn't reconnect at all — abandoned tabs used to churn
|
|
121
|
+
* evict/reconnect cycles against the room DO all night. Existing WebRTC
|
|
122
|
+
* tunnels keep working without signaling; on visibility/focus/online the
|
|
123
|
+
* wake handler reconnects within milliseconds. */
|
|
124
|
+
private hidden(): boolean {
|
|
125
|
+
const doc = (globalThis as { document?: { visibilityState?: string } }).document;
|
|
126
|
+
return doc?.visibilityState === "hidden";
|
|
127
|
+
}
|
|
128
|
+
|
|
100
129
|
private attachWakeListeners(): void {
|
|
101
130
|
const doc = (globalThis as { document?: EventTarget }).document;
|
|
102
131
|
doc?.addEventListener("visibilitychange", this.onWake);
|
|
@@ -143,7 +172,7 @@ export class SignalingClient {
|
|
|
143
172
|
// its backoff keeps growing instead of hammering at 1s.
|
|
144
173
|
this.clearStableTimer();
|
|
145
174
|
this.stableTimer = setTimeout(() => {
|
|
146
|
-
this.reconnectDelay =
|
|
175
|
+
this.reconnectDelay = RECONNECT_MIN_MS;
|
|
147
176
|
}, STABLE_MS);
|
|
148
177
|
const hello: ClientMessage = {
|
|
149
178
|
type: "hello",
|
|
@@ -186,9 +215,8 @@ export class SignalingClient {
|
|
|
186
215
|
};
|
|
187
216
|
}
|
|
188
217
|
|
|
189
|
-
// Heartbeat keeps the room's liveness sweep from evicting us
|
|
190
|
-
//
|
|
191
|
-
// enough that a crashed peer stops showing as a phantom server within ~1 sweep.
|
|
218
|
+
// Heartbeat keeps the room's liveness sweep from evicting us — see
|
|
219
|
+
// HEARTBEAT_MS for the cadence/cost trade-off.
|
|
192
220
|
private startHeartbeat(): void {
|
|
193
221
|
this.stopHeartbeat();
|
|
194
222
|
this.heartbeat = setInterval(() => {
|
|
@@ -197,7 +225,7 @@ export class SignalingClient {
|
|
|
197
225
|
} catch {
|
|
198
226
|
// socket gone; onclose will reconnect
|
|
199
227
|
}
|
|
200
|
-
},
|
|
228
|
+
}, HEARTBEAT_MS);
|
|
201
229
|
}
|
|
202
230
|
|
|
203
231
|
private stopHeartbeat(): void {
|
|
@@ -215,12 +243,23 @@ export class SignalingClient {
|
|
|
215
243
|
}
|
|
216
244
|
|
|
217
245
|
private scheduleReconnect(): void {
|
|
218
|
-
|
|
219
|
-
|
|
246
|
+
if (this.hidden()) {
|
|
247
|
+
this.dormant = true;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// ±25% jitter so a fleet dropped together doesn't reconnect together.
|
|
251
|
+
const delay = Math.round(this.reconnectDelay * (0.75 + Math.random() * 0.5));
|
|
252
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
220
253
|
this.clearReconnectTimer();
|
|
221
254
|
this.reconnectTimer = setTimeout(() => {
|
|
222
255
|
this.reconnectTimer = null;
|
|
223
|
-
if (
|
|
256
|
+
if (this.closed) return;
|
|
257
|
+
if (this.hidden()) {
|
|
258
|
+
// Went hidden while waiting — sit out until the wake handler fires.
|
|
259
|
+
this.dormant = true;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
this.open();
|
|
224
263
|
}, delay);
|
|
225
264
|
}
|
|
226
265
|
|
|
@@ -248,6 +287,7 @@ export class SignalingClient {
|
|
|
248
287
|
|
|
249
288
|
close(): void {
|
|
250
289
|
this.closed = true;
|
|
290
|
+
this.dormant = false;
|
|
251
291
|
this.detachWakeListeners();
|
|
252
292
|
this.clearReconnectTimer();
|
|
253
293
|
this.stopHeartbeat();
|
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}
|
|
@@ -1000,6 +1032,7 @@ export function Discovery() {
|
|
|
1000
1032
|
>
|
|
1001
1033
|
<span style={{ color: a.state === "active" ? "#4ec9b0" : "#777" }}>●</span> {a.tool}{" "}
|
|
1002
1034
|
{a.pid}
|
|
1035
|
+
{a.title && <span style={styles.agentTitle}>{a.title}</span>}
|
|
1003
1036
|
</a>
|
|
1004
1037
|
))}
|
|
1005
1038
|
</div>
|
|
@@ -1118,6 +1151,13 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
1118
1151
|
agentChip: {
|
|
1119
1152
|
fontFamily: "monospace", fontSize: 11.5, padding: "2px 8px", borderRadius: 999,
|
|
1120
1153
|
border: "1px solid #3d3d3d", color: "#9aa4af", textDecoration: "none", cursor: "pointer",
|
|
1154
|
+
display: "inline-flex", alignItems: "baseline", gap: 4, maxWidth: 360,
|
|
1155
|
+
},
|
|
1156
|
+
// Live self-set agent title (daemon re-reads it from the PTY log and pushes
|
|
1157
|
+
// a meta update, so this re-renders as the agent renames itself).
|
|
1158
|
+
agentTitle: {
|
|
1159
|
+
color: "#6e7681", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
|
1160
|
+
minWidth: 0, flex: "0 1 auto",
|
|
1121
1161
|
},
|
|
1122
1162
|
card: { display: "flex", alignItems: "center", gap: 12, background: "#252525", border: "1px solid #3d3d3d", borderRadius: 8, padding: "12px 14px" },
|
|
1123
1163
|
cardMain: { flex: 1, minWidth: 0 },
|
package/src/web/room-client.ts
CHANGED
|
@@ -25,12 +25,19 @@ export interface RoomOptions {
|
|
|
25
25
|
onStatus?: (open: boolean) => void;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** After a failed dial, refuse redials to that peer for this long. Pollers
|
|
29
|
+
* (the agent-yes console asks every host for /api/ls every ~3s) would
|
|
30
|
+
* otherwise spin up a fresh RTCPeerConnection per poll, and every ICE
|
|
31
|
+
* candidate of every attempt is a billable signaling-DO request. */
|
|
32
|
+
const DIAL_FAIL_COOLDOWN_MS = 10_000;
|
|
33
|
+
|
|
28
34
|
export class CodehostRoom {
|
|
29
35
|
/** Server peers currently in the room (viewers filtered out). */
|
|
30
36
|
peers: PeerInfo[] = [];
|
|
31
37
|
private signaling: SignalingClient;
|
|
32
38
|
private rtcs = new Map<string, RtcClient>();
|
|
33
39
|
private tunnels = new Map<string, Promise<TunnelClient>>();
|
|
40
|
+
private dialFailedAt = new Map<string, number>();
|
|
34
41
|
private closed = false;
|
|
35
42
|
|
|
36
43
|
constructor(opts: RoomOptions) {
|
|
@@ -65,6 +72,10 @@ export class CodehostRoom {
|
|
|
65
72
|
private dial(peerId: string): Promise<TunnelClient> {
|
|
66
73
|
const existing = this.tunnels.get(peerId);
|
|
67
74
|
if (existing) return existing;
|
|
75
|
+
const failedAt = this.dialFailedAt.get(peerId);
|
|
76
|
+
if (failedAt != null && Date.now() - failedAt < DIAL_FAIL_COOLDOWN_MS) {
|
|
77
|
+
return Promise.reject(new Error("dial failed recently; cooling down"));
|
|
78
|
+
}
|
|
68
79
|
const drop = () => {
|
|
69
80
|
this.tunnels.delete(peerId);
|
|
70
81
|
this.rtcs.get(peerId)?.close();
|
|
@@ -79,6 +90,7 @@ export class CodehostRoom {
|
|
|
79
90
|
sendSignal: (data: RtcSignal) => this.signaling.sendSignal(peerId, data),
|
|
80
91
|
onOpen: (channel) => {
|
|
81
92
|
clearTimeout(timer);
|
|
93
|
+
this.dialFailedAt.delete(peerId);
|
|
82
94
|
resolve(new TunnelClient(channel));
|
|
83
95
|
},
|
|
84
96
|
onClose: drop,
|
|
@@ -94,7 +106,10 @@ export class CodehostRoom {
|
|
|
94
106
|
});
|
|
95
107
|
});
|
|
96
108
|
this.tunnels.set(peerId, dialing);
|
|
97
|
-
dialing.catch(() =>
|
|
109
|
+
dialing.catch(() => {
|
|
110
|
+
this.dialFailedAt.set(peerId, Date.now());
|
|
111
|
+
this.tunnels.delete(peerId);
|
|
112
|
+
});
|
|
98
113
|
return dialing;
|
|
99
114
|
}
|
|
100
115
|
|
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
|
}
|
package/worker/room.ts
CHANGED
|
@@ -15,10 +15,14 @@ interface Attachment {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/** How often the room scans for dead sockets, and how long a socket may go
|
|
18
|
-
* silent before eviction. Clients heartbeat every ~
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
* silent before eviction. Clients heartbeat every ~25s (HEARTBEAT_MS in
|
|
19
|
+
* signaling-client.ts); allow ~2 misses, so a crashed peer drops out within
|
|
20
|
+
* ~65-85s. Every sweep alarm and every ping is a billable DO request, so both
|
|
21
|
+
* cadences are deliberately slow — a hidden Chrome tab's throttled timers
|
|
22
|
+
* (1/min) must still beat STALE_MS, or background tabs churn evict/reconnect
|
|
23
|
+
* cycles all day. */
|
|
24
|
+
const SWEEP_MS = 20_000;
|
|
25
|
+
const STALE_MS = 65_000;
|
|
22
26
|
|
|
23
27
|
/**
|
|
24
28
|
* One Durable Object instance per token-room. Holds the live WebSocket
|