baychat 0.9.1 → 0.11.0

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.
@@ -0,0 +1,364 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SeenDeliveries = void 0;
4
+ exports.runRelayFeed = runRelayFeed;
5
+ /**
6
+ * The relay's event SOURCE, and the only place that chooses between the two.
7
+ *
8
+ * Everything downstream — routing on `sessionName`, the per-session mutex, the
9
+ * adapters, the pending-vs-delivered distinction — is untouched by this file.
10
+ * It hands `onEvents` exactly what `runUpdatesLoop` used to hand it, from
11
+ * whichever transport is currently live.
12
+ *
13
+ * The policy, in one place because it is one decision:
14
+ *
15
+ * • Prefer the socket. It is the same events with none of the poll latency.
16
+ * • The long-poll is the FLOOR, not a mode. Any socket that will not connect,
17
+ * is refused, or drops sends us straight back to it with the cursor we were
18
+ * holding — no config, no flag, nothing for a user to know about. An older
19
+ * server with no `/ws` route simply never gets past `connect-failed`, and
20
+ * the relay behaves exactly as it did before this file existed.
21
+ * • The two never run at once. The bus allows one waiter per agent and tells a
22
+ * displaced incumbent to retry, so a socket and a poll racing for the same
23
+ * session would take turns and add latency to both.
24
+ *
25
+ * The socket is retried on a ladder, and the long-poll covers every gap in it,
26
+ * so "degraded" here means "slower", never "deaf".
27
+ */
28
+ const api_1 = require("../api");
29
+ const updates_1 = require("./updates");
30
+ const ws_1 = require("./ws");
31
+ /**
32
+ * How long a socket must survive before we call the attempt healthy and reset
33
+ * the ladder. Shorter than this and a server that accepts a handshake then
34
+ * drops it would be retried forever at the fastest rung.
35
+ */
36
+ const HEALTHY_AFTER_MS = 60_000;
37
+ /**
38
+ * Delay before the next socket attempt, by consecutive failure count. The last
39
+ * rung repeats. A server with no `/ws` route settles on it and costs one
40
+ * refused handshake every ten minutes — cheap enough to keep trying, because
41
+ * the alternative is a relay that stays on the long-poll until it is restarted.
42
+ */
43
+ const WS_RETRY_LADDER_MS = [3_000, 15_000, 60_000, 300_000, 600_000];
44
+ /** How often the live-session list is re-read while the socket is up. See `refreshSessions`. */
45
+ const SESSION_REFRESH_MS = 60_000;
46
+ /** Deliveries (session + message id) remembered for de-duplication across a transport switch. */
47
+ const SEEN_DELIVERIES_LIMIT = 2_000;
48
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
49
+ /**
50
+ * Feed relay events from the best available transport until `signal` aborts.
51
+ *
52
+ * Throws only for the reasons `runUpdatesLoop` already threw: a terminal 4xx on
53
+ * the long-poll (an expired device credential needs a human). A socket failure
54
+ * is never terminal on its own — see `wsEnded`.
55
+ */
56
+ async function runRelayFeed(opts) {
57
+ const { auth, watermarks, onEvents, onSessions, onPoll, onError, onTransport, signal, request = api_1.apiRequest, connect = ws_1.nodeWebSocketConnect, retryLadderMs = WS_RETRY_LADDER_MS, healthyAfterMs = HEALTHY_AFTER_MS, sessionRefreshMs = SESSION_REFRESH_MS, } = opts;
58
+ const seen = new SeenDeliveries(SEEN_DELIVERIES_LIMIT);
59
+ /** The shared position. Handed to whichever transport starts next. */
60
+ let cursor;
61
+ /** Consecutive socket attempts that did not stay healthy. Indexes the ladder. */
62
+ let failures = 0;
63
+ /** Epoch ms before which the socket is not worth trying. */
64
+ let retryAfter = 0;
65
+ /** Set once a runtime has told us it has no WebSocket; it will not grow one. */
66
+ let socketsPossible = connect !== null;
67
+ let announced = null;
68
+ const announce = (kind, detail) => {
69
+ const key = `${kind}:${detail}`;
70
+ if (announced === key)
71
+ return;
72
+ announced = key;
73
+ onTransport?.(kind, detail);
74
+ };
75
+ /**
76
+ * The single funnel every event passes through, from either transport.
77
+ *
78
+ * De-duplication lives HERE rather than in the queue because a transport
79
+ * switch is exactly when the same event arrives twice: the socket hands over
80
+ * a batch, dies before the `cursor` frame that would have closed it, and the
81
+ * long-poll resumes from the last cursor — which is the position BEFORE that
82
+ * batch, so the whole batch comes back. That replay is the design (it is why
83
+ * event frames carry no cursor), and this set is what makes it free.
84
+ *
85
+ * The queue's own buffer de-dupe is unchanged and still needed: it covers
86
+ * duplicates inside one undelivered batch. This covers duplicates that span a
87
+ * delivery, which the buffer cannot see because it has already been drained.
88
+ *
89
+ * A duplicate is the same message TO THE SAME SESSION. One message into a room
90
+ * two of this machine's sessions are in arrives as two events sharing a
91
+ * `message.id`, one per session, and both of those are deliveries this relay
92
+ * owes a terminal — see `SeenDeliveries`.
93
+ */
94
+ const emit = (events) => {
95
+ const fresh = events.filter((e) => seen.add(e.sessionName, e.message.id));
96
+ if (fresh.length > 0)
97
+ onEvents(fresh);
98
+ };
99
+ const recordCursor = (next) => {
100
+ cursor = next;
101
+ onPoll?.(next);
102
+ };
103
+ while (!signal.aborted) {
104
+ if (socketsPossible && connect && Date.now() >= retryAfter) {
105
+ const startedAt = Date.now();
106
+ const end = await runSocket({
107
+ auth,
108
+ watermarks,
109
+ request,
110
+ connect,
111
+ cursor,
112
+ emit,
113
+ onSessions,
114
+ onError,
115
+ // Only claim the socket once the server has answered `ready`. Announcing
116
+ // at connect time would report "transport: websocket" to a human staring
117
+ // at a relay that is in fact failing every handshake.
118
+ onReady: () => announce("websocket", "connected"),
119
+ recordCursor,
120
+ clearCursor: () => {
121
+ cursor = undefined;
122
+ },
123
+ sessionRefreshMs,
124
+ signal,
125
+ });
126
+ if (signal.aborted)
127
+ return;
128
+ if (end.reason === "unavailable") {
129
+ // Node 20 has no WebSocket and never will at runtime. Stop asking.
130
+ socketsPossible = false;
131
+ onError?.(new Error(`websocket transport unavailable: ${end.detail}`), true);
132
+ }
133
+ else {
134
+ const healthy = end.reason === "closed" && Date.now() - startedAt >= healthyAfterMs;
135
+ failures = healthy ? 0 : failures + 1;
136
+ retryAfter = Date.now() + ladderDelay(retryLadderMs, failures);
137
+ wsEnded(end, onError);
138
+ }
139
+ }
140
+ if (signal.aborted)
141
+ return;
142
+ // The socket is due right now — a healthy connection that just dropped. Go
143
+ // straight back rather than paying for a long-poll round trip we would
144
+ // abandon at its first boundary anyway.
145
+ if (socketsPossible && connect && Date.now() >= retryAfter)
146
+ continue;
147
+ // The long-poll stint. It runs until the socket is worth retrying, and its
148
+ // own signal is the ONLY thing that ends it — a terminal 401 still throws
149
+ // straight out of here, exactly as it did before.
150
+ const detail = socketsPossible
151
+ ? `fallback — retrying the socket in ${Math.max(0, Math.round((retryAfter - Date.now()) / 1000))}s`
152
+ : "fallback — no WebSocket in this runtime";
153
+ announce("long-poll", detail);
154
+ const stint = new AbortController();
155
+ const stopStint = () => stint.abort();
156
+ signal.addEventListener("abort", stopStint, { once: true });
157
+ try {
158
+ await (0, updates_1.runUpdatesLoop)({
159
+ auth,
160
+ watermarks,
161
+ initialCursor: cursor,
162
+ onEvents: emit,
163
+ onSessions,
164
+ onPoll: (next) => {
165
+ recordCursor(next);
166
+ // End the stint at a poll BOUNDARY, never mid-request: the position
167
+ // has just advanced and nothing is in flight, so the handover to the
168
+ // socket cannot straddle a batch. It costs at most one poll's wait
169
+ // before the socket is retried, and loses nothing.
170
+ if (socketsPossible && connect && Date.now() >= retryAfter)
171
+ stint.abort();
172
+ },
173
+ onError,
174
+ signal: stint.signal,
175
+ request,
176
+ });
177
+ }
178
+ finally {
179
+ signal.removeEventListener("abort", stopStint);
180
+ }
181
+ // A stint that ended without the parent aborting and without a socket to go
182
+ // back to would spin. It cannot happen (the only aborts are the parent's
183
+ // and the retry boundary), but a hot loop here would be invisible and
184
+ // expensive, so make it impossible rather than unlikely.
185
+ if (!signal.aborted && !(socketsPossible && connect))
186
+ await sleep(1_000);
187
+ }
188
+ }
189
+ /** Fold one socket's end into an `onError` line. Never terminal — see the comment. */
190
+ function wsEnded(end, onError) {
191
+ if (end.reason === "aborted")
192
+ return;
193
+ if (end.reason === "credential") {
194
+ // A 4001 is NOT treated as terminal here. The gateway raises it for a
195
+ // revoked credential AND for any error thrown while re-verifying one (a
196
+ // database blip re-verifying a device token lands in the same branch), and
197
+ // a WebSocket close carries no HTTP status to tell those apart. The
198
+ // long-poll we drop back to answers the question properly: a real 401 is
199
+ // terminal there and always has been, and a blip just keeps polling.
200
+ onError?.(new Error(`websocket credential check failed (${end.detail}) — verifying over the long-poll`), true);
201
+ return;
202
+ }
203
+ onError?.(new Error(`websocket ${end.reason}: ${end.detail}`), true);
204
+ }
205
+ function ladderDelay(ladder, failures) {
206
+ // A socket that was healthy and then dropped (a redeploy, a NAT reset) goes
207
+ // straight back — the ladder is for things that are actually broken.
208
+ if (failures === 0)
209
+ return 0;
210
+ // A floor for a caller that passed an empty ladder: zero here plus a socket
211
+ // that fails instantly is a hot reconnect loop, which is the one failure mode
212
+ // this whole file exists to avoid.
213
+ if (ladder.length === 0)
214
+ return 1_000;
215
+ return ladder[Math.min(failures, ladder.length) - 1] ?? ladder[ladder.length - 1];
216
+ }
217
+ /** One socket, plus the live-session refresh that rides alongside it. */
218
+ async function runSocket(args) {
219
+ const { auth, watermarks, request, connect, cursor, emit, onSessions, onError } = args;
220
+ const done = new AbortController();
221
+ const stop = () => done.abort();
222
+ args.signal.addEventListener("abort", stop, { once: true });
223
+ /** Serialises `reset` recoveries: two overlapping catch-ups would double-read every room. */
224
+ let recovering = Promise.resolve();
225
+ const refresher = refreshSessions({
226
+ auth,
227
+ request,
228
+ onSessions,
229
+ onError,
230
+ everyMs: args.sessionRefreshMs,
231
+ signal: done.signal,
232
+ });
233
+ try {
234
+ return await (0, ws_1.runWebSocketFeed)({
235
+ auth,
236
+ resume: cursor,
237
+ onEvents: emit,
238
+ onSessions,
239
+ onCursor: args.recordCursor,
240
+ onReady: () => args.onReady(),
241
+ onReset: () => {
242
+ // `{"t":"reset"}` is `409 cursor_expired` in frame form, and the
243
+ // recovery is the same one: re-read every watched conversation from our
244
+ // own watermark. The server does not bridge the gap and keeps streaming
245
+ // through it, so events arriving during the catch-up are delivered too
246
+ // and de-duplicated by id.
247
+ args.clearCursor();
248
+ recovering = recovering
249
+ .then(() => (0, updates_1.catchUpFromWatermarks)(auth, watermarks, request))
250
+ .then((recovered) => {
251
+ if (recovered.length > 0)
252
+ emit(recovered);
253
+ })
254
+ .catch((err) => {
255
+ // The cursor is already cleared — the socket re-baselined at "now"
256
+ // server-side whatever we do — so this is a reported gap, not a
257
+ // silent one.
258
+ onError?.(err, true);
259
+ });
260
+ },
261
+ signal: done.signal,
262
+ connect,
263
+ });
264
+ }
265
+ finally {
266
+ args.signal.removeEventListener("abort", stop);
267
+ done.abort();
268
+ await refresher;
269
+ await recovering;
270
+ }
271
+ }
272
+ /**
273
+ * Re-read the live session list on a timer while the socket is up.
274
+ *
275
+ * The socket reports sessions on `cursor` frames — but the server sends no
276
+ * cursor frame at all when a device owns NO live session (there is no bus to
277
+ * park on, so the loop just sleeps). Without this, a relay whose last session
278
+ * was ended from the app would keep that target in its registry until the
279
+ * socket next dropped. The long-poll has no such gap: every response carries
280
+ * `sessions`, including an empty one.
281
+ *
282
+ * Best-effort by construction: an older server has no `/sessions` route, and
283
+ * one failure stops the refresher for this connection rather than retrying into
284
+ * a 404 forever. Pruning then falls back to the long-poll, which is where it
285
+ * lived before.
286
+ */
287
+ async function refreshSessions(args) {
288
+ const { auth, request, onSessions, onError, everyMs, signal } = args;
289
+ if (!onSessions || everyMs <= 0)
290
+ return;
291
+ while (!signal.aborted) {
292
+ await interruptibleSleep(everyMs, signal);
293
+ if (signal.aborted)
294
+ return;
295
+ try {
296
+ const res = await request(auth, "GET", "/api/device-api/sessions");
297
+ if (signal.aborted)
298
+ return;
299
+ onSessions((res.sessions ?? []).filter((s) => s.live).map((s) => s.name));
300
+ }
301
+ catch (err) {
302
+ if (signal.aborted)
303
+ return;
304
+ onError?.(new Error(`live-session refresh unavailable (${err instanceof Error ? err.message : String(err)}) — pruning waits for the long-poll`), true);
305
+ return;
306
+ }
307
+ }
308
+ }
309
+ function interruptibleSleep(ms, signal) {
310
+ if (signal.aborted)
311
+ return Promise.resolve();
312
+ return new Promise((resolve) => {
313
+ const timer = setTimeout(finish, ms);
314
+ timer.unref?.();
315
+ function finish() {
316
+ clearTimeout(timer);
317
+ signal.removeEventListener("abort", finish);
318
+ resolve();
319
+ }
320
+ signal.addEventListener("abort", finish, { once: true });
321
+ });
322
+ }
323
+ /**
324
+ * Deliveries already handed downstream, newest-last, bounded.
325
+ *
326
+ * Keyed by SESSION and message id, because that pair is the delivery — not the
327
+ * message. Two sessions on this machine in the same room each get their own copy
328
+ * of one message, with one `message.id` between them; a set keyed on the id alone
329
+ * wakes whichever arrived first and drops the other session's copy on the floor,
330
+ * after the server's cursor has already moved past it. That is a lost message,
331
+ * not a de-duplicated one.
332
+ *
333
+ * Bounded rather than complete on purpose: the relay is a long-lived daemon and
334
+ * an unbounded set is a slow leak. The window only has to outlive a transport
335
+ * switch — one batch, plus whatever a catch-up re-reads — and 2000 entries is
336
+ * several orders of magnitude more than that.
337
+ */
338
+ class SeenDeliveries {
339
+ limit;
340
+ keys = new Set();
341
+ order = [];
342
+ constructor(limit) {
343
+ this.limit = limit;
344
+ }
345
+ /** True when this delivery had not been seen — i.e. the caller should deliver it. */
346
+ add(sessionName, messageId) {
347
+ // NUL separates: a session name is user-chosen and may contain anything else.
348
+ const key = `${sessionName ?? ""}\u0000${messageId}`;
349
+ if (this.keys.has(key))
350
+ return false;
351
+ this.keys.add(key);
352
+ this.order.push(key);
353
+ while (this.order.length > this.limit) {
354
+ const evicted = this.order.shift();
355
+ if (evicted !== undefined)
356
+ this.keys.delete(evicted);
357
+ }
358
+ return true;
359
+ }
360
+ get size() {
361
+ return this.keys.size;
362
+ }
363
+ }
364
+ exports.SeenDeliveries = SeenDeliveries;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runUpdatesLoop = runUpdatesLoop;
4
+ exports.catchUpFromWatermarks = catchUpFromWatermarks;
4
5
  const api_1 = require("../api");
5
6
  /** Server clamps `wait` into [1s, 30s]; 25s stays under the usual 60s proxy idle timeout. */
6
7
  const WAIT_SECONDS = 25;
@@ -42,8 +43,8 @@ function isTransient(err) {
42
43
  * watermark, then poll again with no cursor.
43
44
  */
44
45
  async function runUpdatesLoop(opts) {
45
- const { auth, watermarks, onEvents, onSessions, onPoll, onError, signal, request = api_1.apiRequest } = opts;
46
- let cursor;
46
+ const { auth, watermarks, onEvents, onSessions, onPoll, onError, initialCursor, signal, request = api_1.apiRequest } = opts;
47
+ let cursor = initialCursor;
47
48
  let backoff = BACKOFF_MIN_MS;
48
49
  while (!signal.aborted) {
49
50
  try {
@@ -69,7 +70,7 @@ async function runUpdatesLoop(opts) {
69
70
  if (err instanceof api_1.ApiError && err.status === 409) {
70
71
  onError?.(err, true);
71
72
  try {
72
- const recovered = await catchUp(auth, watermarks, request);
73
+ const recovered = await catchUpFromWatermarks(auth, watermarks, request);
73
74
  if (recovered.length > 0)
74
75
  onEvents(recovered);
75
76
  }
@@ -96,16 +97,17 @@ async function runUpdatesLoop(opts) {
96
97
  }
97
98
  }
98
99
  /**
99
- * Re-read each watched conversation from its watermark. Used only on 409
100
- * recovery. Messages we already delivered come back here; the caller's
101
- * per-session queue de-dupes them by id, so a replay is cheap rather than
102
- * duplicated into the room.
100
+ * Re-read each watched conversation from its watermark. Used on 409 recovery
101
+ * and on the WebSocket's `{"t":"reset"}`, which is the same condition in frame
102
+ * form — one recovery, so the two transports cannot drift apart. Messages we
103
+ * already delivered come back here; the caller de-dupes them by id, so a replay
104
+ * is cheap rather than duplicated into the room.
103
105
  *
104
106
  * A 404 for one conversation is skipped rather than fatal: a session may have
105
107
  * ended or left the room between the poll and the recovery, and one dead room
106
108
  * must not strand the catch-up for every other.
107
109
  */
108
- async function catchUp(auth, watermarks, request) {
110
+ async function catchUpFromWatermarks(auth, watermarks, request) {
109
111
  const events = [];
110
112
  for (const [conversationId, since] of watermarks) {
111
113
  let res;
@@ -0,0 +1,271 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nodeWebSocketConnect = exports.WebSocketUnavailableError = exports.AGENT_WS_PATH = void 0;
4
+ exports.runWebSocketFeed = runWebSocketFeed;
5
+ exports.wsUrlFor = wsUrlFor;
6
+ exports.parseFrame = parseFrame;
7
+ /** The published endpoint. Changing it breaks every deployed server — a wire constant. */
8
+ exports.AGENT_WS_PATH = "/api/agent-api/ws";
9
+ /** How long we wait for the server's `ready` after sending `hello`. */
10
+ const READY_TIMEOUT_MS = 10_000;
11
+ /** Close code the gateway uses when the credential stopped verifying. */
12
+ const WS_CLOSE_CREDENTIAL = 4001;
13
+ /** Thrown by `nodeWebSocketConnect` on a runtime with no global `WebSocket`. */
14
+ class WebSocketUnavailableError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "WebSocketUnavailableError";
18
+ }
19
+ }
20
+ exports.WebSocketUnavailableError = WebSocketUnavailableError;
21
+ /**
22
+ * Run one socket until it ends. Never throws: every failure mode is a `WsFeedEnd`,
23
+ * because a transport that can be fallen back from must not surface as an
24
+ * exception the daemon would treat as fatal.
25
+ */
26
+ function runWebSocketFeed(opts) {
27
+ const { auth, resume, onEvents, onSessions, onCursor, onReset, onReady, signal, connect = exports.nodeWebSocketConnect, readyTimeoutMs = READY_TIMEOUT_MS, } = opts;
28
+ return new Promise((resolve) => {
29
+ if (signal.aborted)
30
+ return resolve({ reason: "aborted" });
31
+ let settled = false;
32
+ let ready = false;
33
+ /** An `{"t":"error"}` frame arrives just before the close that explains it. */
34
+ let serverError;
35
+ let socket;
36
+ let readyTimer;
37
+ const finish = (end) => {
38
+ if (settled)
39
+ return;
40
+ settled = true;
41
+ if (readyTimer)
42
+ clearTimeout(readyTimer);
43
+ signal.removeEventListener("abort", onAbort);
44
+ resolve(end);
45
+ };
46
+ function onAbort() {
47
+ try {
48
+ socket?.close(1000, "relay stopping");
49
+ }
50
+ catch {
51
+ // Already gone. The promise still has to settle, which the next line does.
52
+ }
53
+ finish({ reason: "aborted" });
54
+ }
55
+ signal.addEventListener("abort", onAbort, { once: true });
56
+ const handlers = {
57
+ onOpen() {
58
+ // `hello` must be the first frame; the server reaps a socket that stays
59
+ // silent for 10s. `resume` is omitted rather than sent as null when we
60
+ // hold no position — absent means "start at now", which is what a
61
+ // cursorless client actually wants.
62
+ try {
63
+ socket?.send(JSON.stringify(resume ? { t: "hello", resume } : { t: "hello" }));
64
+ }
65
+ catch (err) {
66
+ finish({ reason: "connect-failed", detail: errText(err) });
67
+ }
68
+ },
69
+ onMessage(data) {
70
+ const frame = parseFrame(data);
71
+ if (!frame)
72
+ return; // unknown or unparseable frames are ignored by contract
73
+ if (frame.t === "ready") {
74
+ ready = true;
75
+ if (readyTimer)
76
+ clearTimeout(readyTimer);
77
+ readyTimer = undefined;
78
+ if (frame.sessions)
79
+ onSessions?.(frame.sessions);
80
+ if (frame.cursor)
81
+ onCursor?.(frame.cursor);
82
+ onReady?.(frame.cursor ?? null);
83
+ return;
84
+ }
85
+ if (frame.t === "cursor") {
86
+ // The batch above this frame is now ours. Sessions ride along, which
87
+ // is how a session that joined or ended mid-connection is noticed.
88
+ if (frame.sessions)
89
+ onSessions?.(frame.sessions);
90
+ if (frame.cursor)
91
+ onCursor?.(frame.cursor);
92
+ return;
93
+ }
94
+ if (frame.t === "event") {
95
+ // Handed over immediately rather than buffered until the closing
96
+ // cursor frame: the position does not advance here, so a socket that
97
+ // dies now replays this event and the caller de-dupes it by id.
98
+ if (frame.event)
99
+ onEvents([frame.event]);
100
+ return;
101
+ }
102
+ if (frame.t === "reset") {
103
+ onReset?.();
104
+ return;
105
+ }
106
+ serverError = { code: frame.code, message: frame.message };
107
+ },
108
+ onError(detail) {
109
+ // A WebSocket error event carries no HTTP status — a 401, a 404 from a
110
+ // server with no `/ws` route and a dead network are indistinguishable
111
+ // here. That is why classification stops at "could not connect" and the
112
+ // long-poll is left to be the authority on a bad credential.
113
+ if (!ready)
114
+ finish({ reason: "connect-failed", detail: detail || "socket error" });
115
+ },
116
+ onClose(code, reason) {
117
+ const detail = serverError ? `${serverError.code}: ${serverError.message}` : reason || `code ${code}`;
118
+ if (code === WS_CLOSE_CREDENTIAL || serverError?.code === "WS_CREDENTIAL_REVOKED" || serverError?.code === "WS_TOKEN_EXPIRED") {
119
+ finish({ reason: "credential", detail });
120
+ return;
121
+ }
122
+ if (!ready) {
123
+ finish({ reason: "connect-failed", detail });
124
+ return;
125
+ }
126
+ finish({ reason: "closed", code, detail });
127
+ },
128
+ };
129
+ try {
130
+ socket = connect(wsUrlFor(auth), handlers);
131
+ }
132
+ catch (err) {
133
+ const unavailable = err instanceof WebSocketUnavailableError;
134
+ finish({ reason: unavailable ? "unavailable" : "connect-failed", detail: errText(err) });
135
+ return;
136
+ }
137
+ readyTimer = setTimeout(() => {
138
+ try {
139
+ socket?.close(1000, "no ready frame");
140
+ }
141
+ catch {
142
+ // Nothing to do; `finish` below is what actually unblocks the caller.
143
+ }
144
+ finish({ reason: "connect-failed", detail: `no ready frame within ${readyTimeoutMs}ms` });
145
+ }, readyTimeoutMs);
146
+ readyTimer.unref?.();
147
+ });
148
+ }
149
+ /**
150
+ * The socket URL for a stored REST base URL: `http` → `ws`, `https` → `wss`,
151
+ * host, port and any path prefix preserved.
152
+ *
153
+ * The credential rides in the query string because the WHATWG WebSocket API
154
+ * cannot set an `Authorization` header — the server accepts `?token=` for
155
+ * exactly that reason (`handshakeToken` in `agent-ws/auth.ts`). It is a real
156
+ * trade-off: a URL is likelier to be logged by a proxy than a header, and it is
157
+ * why the CLI only ever speaks `wss` in production.
158
+ */
159
+ function wsUrlFor(auth) {
160
+ const url = new URL(exports.AGENT_WS_PATH.replace(/^\//, ""), ensureTrailingSlash(auth.baseUrl));
161
+ url.protocol = url.protocol === "https:" ? "wss:" : url.protocol === "http:" ? "ws:" : url.protocol;
162
+ url.searchParams.set("token", auth.token);
163
+ return url.toString();
164
+ }
165
+ function ensureTrailingSlash(base) {
166
+ return base.endsWith("/") ? base : `${base}/`;
167
+ }
168
+ /**
169
+ * The default connect: Node's built-in WebSocket, adapted to `WsHandlers`.
170
+ *
171
+ * Reached through a cast rather than `@types/node`'s own declaration on
172
+ * purpose — the global has moved between type releases (Node 20 has none, 21
173
+ * has it behind a flag, 22 ships it), and a package published for `node >= 20`
174
+ * must compile the same on all three and decide at RUNTIME whether it exists.
175
+ */
176
+ const nodeWebSocketConnect = (url, handlers) => {
177
+ const ctor = globalThis.WebSocket;
178
+ if (typeof ctor !== "function") {
179
+ throw new WebSocketUnavailableError("this Node build has no WebSocket (added in Node 22) — staying on the long-poll");
180
+ }
181
+ const ws = new ctor(url);
182
+ ws.onopen = () => handlers.onOpen();
183
+ ws.onmessage = (ev) => handlers.onMessage(typeof ev?.data === "string" ? ev.data : String(ev?.data ?? ""));
184
+ ws.onerror = (ev) => handlers.onError(eventText(ev));
185
+ ws.onclose = (ev) => handlers.onClose(typeof ev?.code === "number" ? ev.code : 1006, ev?.reason ?? "");
186
+ return {
187
+ send: (data) => ws.send(data),
188
+ close: (code, reason) => ws.close(code, reason),
189
+ };
190
+ };
191
+ exports.nodeWebSocketConnect = nodeWebSocketConnect;
192
+ /**
193
+ * Read one server frame. Anything we do not recognise returns null and is
194
+ * dropped: the contract says unknown `t` values must be ignored, so a server
195
+ * that grows a frame type does not break an older relay.
196
+ */
197
+ function parseFrame(raw) {
198
+ let value;
199
+ try {
200
+ value = JSON.parse(raw);
201
+ }
202
+ catch {
203
+ return null;
204
+ }
205
+ if (!value || typeof value !== "object")
206
+ return null;
207
+ const frame = value;
208
+ switch (frame.t) {
209
+ case "ready":
210
+ return { t: "ready", cursor: str(frame.cursor), sessions: names(frame.sessions) };
211
+ case "cursor":
212
+ return { t: "cursor", cursor: str(frame.cursor), sessions: names(frame.sessions) };
213
+ case "event":
214
+ return { t: "event", event: toUpdateEvent(frame.event) };
215
+ case "reset":
216
+ return { t: "reset" };
217
+ case "error":
218
+ return { t: "error", code: str(frame.code) ?? "WS_ERROR", message: str(frame.message) ?? "socket error" };
219
+ default:
220
+ return null;
221
+ }
222
+ }
223
+ /**
224
+ * The device/user event payload: `{ sessionName, agentId, conversationId, message }`
225
+ * — byte-identical to an entry in the long-poll's `events` array, which is what
226
+ * makes the two transports interchangeable for everything downstream.
227
+ *
228
+ * A frame missing `conversationId` or `message.id` is dropped rather than
229
+ * forwarded half-formed: routing and de-duplication both key on those, and an
230
+ * event that cannot be routed or de-duplicated is worse than one not delivered.
231
+ */
232
+ function toUpdateEvent(value) {
233
+ if (!value || typeof value !== "object")
234
+ return null;
235
+ const raw = value;
236
+ const message = raw.message;
237
+ if (!message || typeof message !== "object")
238
+ return null;
239
+ const msg = message;
240
+ const conversationId = str(raw.conversationId) ?? str(msg.conversationId);
241
+ const id = str(msg.id);
242
+ if (!conversationId || !id)
243
+ return null;
244
+ return {
245
+ conversationId,
246
+ sessionName: str(raw.sessionName),
247
+ message: { ...msg, id, conversationId },
248
+ };
249
+ }
250
+ function str(value) {
251
+ return typeof value === "string" && value.length > 0 ? value : null;
252
+ }
253
+ function names(value) {
254
+ if (!Array.isArray(value))
255
+ return undefined;
256
+ return value.filter((v) => typeof v === "string");
257
+ }
258
+ function eventText(ev) {
259
+ if (!ev)
260
+ return "socket error";
261
+ if (typeof ev.message === "string" && ev.message)
262
+ return ev.message;
263
+ return errText(ev.error);
264
+ }
265
+ function errText(err) {
266
+ if (err instanceof Error)
267
+ return err.message;
268
+ if (typeof err === "string" && err)
269
+ return err;
270
+ return "socket error";
271
+ }