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.
- package/dist/connect-plan.js +3 -2
- package/dist/connect.js +15 -0
- package/dist/index.js +10 -8
- package/dist/protocol-content.js +1 -1
- package/dist/relay/commands.js +15 -0
- package/dist/relay/daemon.js +15 -2
- package/dist/relay/transport.js +364 -0
- package/dist/relay/updates.js +10 -8
- package/dist/relay/ws.js +271 -0
- package/dist/runtime-install.js +93 -0
- package/dist/runtimes.js +210 -0
- package/package.json +1 -1
|
@@ -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;
|
package/dist/relay/updates.js
CHANGED
|
@@ -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
|
|
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
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
|
110
|
+
async function catchUpFromWatermarks(auth, watermarks, request) {
|
|
109
111
|
const events = [];
|
|
110
112
|
for (const [conversationId, since] of watermarks) {
|
|
111
113
|
let res;
|
package/dist/relay/ws.js
ADDED
|
@@ -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
|
+
}
|