@butlerbot/sdk 0.0.23 → 0.0.25
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/link/link.d.ts +78 -4
- package/dist/link/link.js +256 -56
- package/package.json +1 -1
package/dist/link/link.d.ts
CHANGED
|
@@ -88,11 +88,27 @@ export declare class Link {
|
|
|
88
88
|
private readonly pending;
|
|
89
89
|
private readonly calls;
|
|
90
90
|
private socket;
|
|
91
|
+
/** Every socket this link has opened and not yet closed, by generation. */
|
|
92
|
+
private readonly sockets;
|
|
91
93
|
private frameCounter;
|
|
92
94
|
private currentState;
|
|
93
95
|
private identity?;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Which socket the callbacks below belong to.
|
|
98
|
+
*
|
|
99
|
+
* Every handler carries the generation it was made for and does nothing once that is
|
|
100
|
+
* no longer current. Without it a late `close` from a socket we have already replaced
|
|
101
|
+
* tears down its successor — one blip turning into a link that flaps for the life of
|
|
102
|
+
* the process.
|
|
103
|
+
*/
|
|
104
|
+
private generation;
|
|
105
|
+
/** True from the moment a socket is created until it is open or gone. */
|
|
106
|
+
private attempting;
|
|
107
|
+
private reconnectTimer?;
|
|
108
|
+
/** Settled per attempt: this is what `connect()` awaits. */
|
|
109
|
+
private readonly attemptWaiters;
|
|
110
|
+
/** Settled when the link opens, however many attempts that takes. `ready()` awaits these. */
|
|
111
|
+
private readonly openWaiters;
|
|
96
112
|
private reconnectAttempt;
|
|
97
113
|
private reconnectAfterMs;
|
|
98
114
|
private heartbeat?;
|
|
@@ -112,15 +128,54 @@ export declare class Link {
|
|
|
112
128
|
get scope(): LinkScopeKind | undefined;
|
|
113
129
|
on<K extends keyof LinkEvents>(event: K, listener: (...args: LinkEvents[K]) => unknown): string;
|
|
114
130
|
off<K extends keyof LinkEvents>(event: K, id: string): void;
|
|
115
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Connects, resolving once every tool and hook has been registered.
|
|
133
|
+
*
|
|
134
|
+
* Rejects if *this* attempt fails. When `reconnect` is on the link keeps trying in
|
|
135
|
+
* the background regardless, so a caller can either await this again or just listen
|
|
136
|
+
* for the `connect` event.
|
|
137
|
+
*/
|
|
116
138
|
connect(): Promise<this>;
|
|
117
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* Resolves when the link is usable, connecting first if it has not been asked to yet.
|
|
141
|
+
*
|
|
142
|
+
* Bounded by `requestTimeoutMs`, and it never opens a socket of its own while one is
|
|
143
|
+
* in flight: the callers are hook emits and tool replies, which are worth sending now
|
|
144
|
+
* or not at all. Waiting out a long outage here used to mean a new connection per
|
|
145
|
+
* event.
|
|
146
|
+
*/
|
|
118
147
|
ready(): Promise<void>;
|
|
119
148
|
/** Closes for good. Registrations are released server-side as the socket drops. */
|
|
120
149
|
close(reason?: string): void;
|
|
150
|
+
/**
|
|
151
|
+
* Starts an attempt, but only when there is nothing to join.
|
|
152
|
+
*
|
|
153
|
+
* The single door to opening a socket: an attempt in flight, or a reconnect already
|
|
154
|
+
* waiting out its backoff, is the attempt.
|
|
155
|
+
*/
|
|
156
|
+
private ensureAttempting;
|
|
121
157
|
private openSocket;
|
|
122
158
|
private onOpen;
|
|
159
|
+
/**
|
|
160
|
+
* Abandons a socket and treats it as closed right now.
|
|
161
|
+
*
|
|
162
|
+
* A connection that died without a close frame can take minutes to report it, or
|
|
163
|
+
* never, so the close is synthesised rather than waited for. Bumping the generation
|
|
164
|
+
* means the real event, whenever it turns up, is ignored.
|
|
165
|
+
*/
|
|
166
|
+
private dropSocket;
|
|
167
|
+
/**
|
|
168
|
+
* Closes a socket this end has stopped using, whatever generation it belongs to.
|
|
169
|
+
*
|
|
170
|
+
* Every path that walks away from a socket goes through here. Forgetting one is not harmless:
|
|
171
|
+
* the server has no way to tell an abandoned connection from a live one — it has said hello,
|
|
172
|
+
* registered its tools and claimed its link id — so it keeps it, keeps serving from it, and
|
|
173
|
+
* hands the link id back and forth between it and its replacements.
|
|
174
|
+
*/
|
|
175
|
+
private abandon;
|
|
123
176
|
private onClose;
|
|
177
|
+
/** Retries when it is allowed to, and tells everyone waiting when it is not. */
|
|
178
|
+
private retryOrGiveUp;
|
|
124
179
|
/**
|
|
125
180
|
* Reconnects with full jitter on top of any delay the server asked for.
|
|
126
181
|
*
|
|
@@ -128,8 +183,27 @@ export declare class Link {
|
|
|
128
183
|
* a fixed delay produces.
|
|
129
184
|
*/
|
|
130
185
|
private scheduleReconnect;
|
|
186
|
+
private clearReconnect;
|
|
187
|
+
/**
|
|
188
|
+
* Pings on an interval and, the important half, notices when a ping goes unanswered.
|
|
189
|
+
*
|
|
190
|
+
* A websocket can die without a close frame — a dropped route, a proxy that forgets
|
|
191
|
+
* the connection, a suspended machine — leaving both ends convinced they are
|
|
192
|
+
* connected while every frame sent into it vanishes. An unanswered ping is the only
|
|
193
|
+
* evidence this end will ever get, so it is treated as a dead connection and
|
|
194
|
+
* reconnected rather than swallowed.
|
|
195
|
+
*/
|
|
131
196
|
private startHeartbeat;
|
|
197
|
+
/**
|
|
198
|
+
* Never longer than the interval itself: a second ping in flight tells us nothing new.
|
|
199
|
+
*
|
|
200
|
+
* Floored so that a very short interval cannot declare a merely busy connection dead.
|
|
201
|
+
*/
|
|
202
|
+
private heartbeatTimeoutMs;
|
|
132
203
|
private stopHeartbeat;
|
|
204
|
+
/** Parks a caller until someone settles the list it was parked in. 0 waits forever. */
|
|
205
|
+
private wait;
|
|
206
|
+
private settleWaiters;
|
|
133
207
|
private registerAll;
|
|
134
208
|
private registerTools;
|
|
135
209
|
private registerHook;
|
package/dist/link/link.js
CHANGED
|
@@ -28,8 +28,25 @@ class Link {
|
|
|
28
28
|
this.pending = new Map();
|
|
29
29
|
this.calls = new Map();
|
|
30
30
|
this.socket = null;
|
|
31
|
+
/** Every socket this link has opened and not yet closed, by generation. */
|
|
32
|
+
this.sockets = new Map();
|
|
31
33
|
this.frameCounter = 0;
|
|
32
34
|
this.currentState = "idle";
|
|
35
|
+
/**
|
|
36
|
+
* Which socket the callbacks below belong to.
|
|
37
|
+
*
|
|
38
|
+
* Every handler carries the generation it was made for and does nothing once that is
|
|
39
|
+
* no longer current. Without it a late `close` from a socket we have already replaced
|
|
40
|
+
* tears down its successor — one blip turning into a link that flaps for the life of
|
|
41
|
+
* the process.
|
|
42
|
+
*/
|
|
43
|
+
this.generation = 0;
|
|
44
|
+
/** True from the moment a socket is created until it is open or gone. */
|
|
45
|
+
this.attempting = false;
|
|
46
|
+
/** Settled per attempt: this is what `connect()` awaits. */
|
|
47
|
+
this.attemptWaiters = [];
|
|
48
|
+
/** Settled when the link opens, however many attempts that takes. `ready()` awaits these. */
|
|
49
|
+
this.openWaiters = [];
|
|
33
50
|
this.reconnectAttempt = 0;
|
|
34
51
|
this.reconnectAfterMs = 0;
|
|
35
52
|
this.closedByUs = false;
|
|
@@ -107,58 +124,113 @@ class Link {
|
|
|
107
124
|
// =============================================
|
|
108
125
|
// LIFECYCLE
|
|
109
126
|
// =============================================
|
|
110
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* Connects, resolving once every tool and hook has been registered.
|
|
129
|
+
*
|
|
130
|
+
* Rejects if *this* attempt fails. When `reconnect` is on the link keeps trying in
|
|
131
|
+
* the background regardless, so a caller can either await this again or just listen
|
|
132
|
+
* for the `connect` event.
|
|
133
|
+
*/
|
|
111
134
|
connect() {
|
|
112
135
|
if (this.currentState === "open")
|
|
113
136
|
return Promise.resolve(this);
|
|
114
|
-
if (this.connecting)
|
|
115
|
-
return this.connecting;
|
|
116
137
|
this.closedByUs = false;
|
|
117
|
-
this.
|
|
118
|
-
this.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
138
|
+
if (this.currentState === "closed")
|
|
139
|
+
this.currentState = "idle";
|
|
140
|
+
// Joins whatever attempt is already in flight — or already scheduled — rather than
|
|
141
|
+
// racing a second socket against it.
|
|
142
|
+
const waited = this.wait(this.attemptWaiters, 0);
|
|
143
|
+
this.ensureAttempting();
|
|
144
|
+
return waited.then(() => this);
|
|
122
145
|
}
|
|
123
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* Resolves when the link is usable, connecting first if it has not been asked to yet.
|
|
148
|
+
*
|
|
149
|
+
* Bounded by `requestTimeoutMs`, and it never opens a socket of its own while one is
|
|
150
|
+
* in flight: the callers are hook emits and tool replies, which are worth sending now
|
|
151
|
+
* or not at all. Waiting out a long outage here used to mean a new connection per
|
|
152
|
+
* event.
|
|
153
|
+
*/
|
|
124
154
|
async ready() {
|
|
125
155
|
if (this.currentState === "open")
|
|
126
156
|
return;
|
|
127
157
|
if (this.currentState === "closed")
|
|
128
|
-
throw new
|
|
129
|
-
|
|
158
|
+
throw new protocol_1.LinkError("closed", "This link has been closed.");
|
|
159
|
+
const waited = this.wait(this.openWaiters, this.options.requestTimeoutMs);
|
|
160
|
+
this.ensureAttempting();
|
|
161
|
+
await waited;
|
|
130
162
|
}
|
|
131
163
|
/** Closes for good. Registrations are released server-side as the socket drops. */
|
|
132
164
|
close(reason = "client closed") {
|
|
165
|
+
const closed = new protocol_1.LinkError("closed", "The link was closed.");
|
|
133
166
|
this.closedByUs = true;
|
|
134
167
|
this.currentState = "closed";
|
|
168
|
+
// Orphans the live socket's callbacks, so its close cannot reopen anything.
|
|
169
|
+
this.generation += 1;
|
|
170
|
+
this.attempting = false;
|
|
135
171
|
this.stopHeartbeat();
|
|
136
|
-
this.
|
|
137
|
-
this.
|
|
172
|
+
this.clearReconnect();
|
|
173
|
+
this.failPending(closed);
|
|
174
|
+
this.settleWaiters(this.attemptWaiters, closed);
|
|
175
|
+
this.settleWaiters(this.openWaiters, closed);
|
|
176
|
+
// Every socket, not just the current one: a link that is being closed for good must not
|
|
177
|
+
// leave anything of itself attached to the server.
|
|
178
|
+
for (const generation of [...this.sockets.keys()])
|
|
179
|
+
this.abandon(generation, reason);
|
|
138
180
|
this.socket = null;
|
|
139
|
-
|
|
140
|
-
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Starts an attempt, but only when there is nothing to join.
|
|
184
|
+
*
|
|
185
|
+
* The single door to opening a socket: an attempt in flight, or a reconnect already
|
|
186
|
+
* waiting out its backoff, is the attempt.
|
|
187
|
+
*/
|
|
188
|
+
ensureAttempting() {
|
|
189
|
+
if (this.closedByUs || this.currentState === "open")
|
|
190
|
+
return;
|
|
191
|
+
if (this.attempting || this.reconnectTimer)
|
|
192
|
+
return;
|
|
193
|
+
this.openSocket();
|
|
141
194
|
}
|
|
142
195
|
openSocket() {
|
|
143
196
|
this.currentState = "connecting";
|
|
144
|
-
|
|
145
|
-
|
|
197
|
+
this.attempting = true;
|
|
198
|
+
const generation = ++this.generation;
|
|
146
199
|
const { url, protocols } = (0, socket_1.buildHandshake)(this.options.serverUrl, "link", this.options.apiKey);
|
|
147
200
|
this.debug(`connecting to ${url.replace(/api_key=[^&]+/, "api_key=***")}`);
|
|
148
201
|
try {
|
|
149
|
-
|
|
150
|
-
onOpen: () => this.onOpen(),
|
|
151
|
-
onMessage: (data) => this.
|
|
152
|
-
|
|
153
|
-
|
|
202
|
+
const socket = this.options.socketFactory(url, protocols, {
|
|
203
|
+
onOpen: () => this.onOpen(generation),
|
|
204
|
+
onMessage: (data) => { if (generation === this.generation)
|
|
205
|
+
this.onMessage(data); },
|
|
206
|
+
onClose: (code, reason) => {
|
|
207
|
+
this.sockets.delete(generation);
|
|
208
|
+
this.onClose(generation, code, reason);
|
|
209
|
+
},
|
|
210
|
+
onError: (error) => { if (generation === this.generation)
|
|
211
|
+
this.emitter.emit("error", asError(error)); },
|
|
154
212
|
});
|
|
213
|
+
// Held by generation, not only as `this.socket`, so a socket that stops being the
|
|
214
|
+
// current one can still be closed. One that is merely forgotten stays open at the far
|
|
215
|
+
// end: it has already said hello and registered its tools, so the server goes on serving
|
|
216
|
+
// it, and it answers websocket pings forever because the network stack does that for it.
|
|
217
|
+
this.sockets.set(generation, socket);
|
|
218
|
+
this.socket = socket;
|
|
155
219
|
}
|
|
156
220
|
catch (error) {
|
|
157
|
-
|
|
221
|
+
// A factory that throws never produces a close event, so this failure is
|
|
222
|
+
// reported and retried from here instead.
|
|
223
|
+
const failure = asError(error);
|
|
224
|
+
this.socket = null;
|
|
225
|
+
this.attempting = false;
|
|
226
|
+
this.emitter.emit("error", failure);
|
|
227
|
+
this.settleWaiters(this.attemptWaiters, failure);
|
|
228
|
+
this.retryOrGiveUp(failure);
|
|
158
229
|
}
|
|
159
|
-
return signal.promise;
|
|
160
230
|
}
|
|
161
|
-
onOpen() {
|
|
231
|
+
onOpen(generation) {
|
|
232
|
+
if (generation !== this.generation)
|
|
233
|
+
return;
|
|
162
234
|
// The handshake declares who we are; nothing else may be sent before it.
|
|
163
235
|
void this.exchange("hello", {
|
|
164
236
|
linkId: this.options.linkId,
|
|
@@ -169,39 +241,106 @@ class Link {
|
|
|
169
241
|
isDone: (frame) => frame.type === "welcome",
|
|
170
242
|
}).then(async (frame) => {
|
|
171
243
|
const welcome = frame.payload;
|
|
172
|
-
|
|
244
|
+
const identity = { connectionId: welcome.connectionId, scope: welcome.scope };
|
|
245
|
+
this.identity = identity;
|
|
173
246
|
await this.registerAll();
|
|
247
|
+
// Registration is several round trips; the socket may have gone during them. It is fully
|
|
248
|
+
// registered at the server by now, so abandoning it quietly would leave it serving tools
|
|
249
|
+
// nothing here will ever answer.
|
|
250
|
+
if (generation !== this.generation)
|
|
251
|
+
return this.abandon(generation, "superseded during handshake");
|
|
252
|
+
this.attempting = false;
|
|
174
253
|
this.currentState = "open";
|
|
175
254
|
this.reconnectAttempt = 0;
|
|
176
255
|
this.reconnectAfterMs = 0;
|
|
177
|
-
this.startHeartbeat();
|
|
178
|
-
this.
|
|
179
|
-
this.
|
|
256
|
+
this.startHeartbeat(generation);
|
|
257
|
+
this.settleWaiters(this.attemptWaiters);
|
|
258
|
+
this.settleWaiters(this.openWaiters);
|
|
259
|
+
this.emitter.emit("connect", identity);
|
|
180
260
|
}).catch((error) => {
|
|
261
|
+
if (generation !== this.generation)
|
|
262
|
+
return this.abandon(generation, "superseded during handshake");
|
|
181
263
|
this.emitter.emit("error", error);
|
|
182
|
-
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
264
|
+
this.settleWaiters(this.attemptWaiters, error);
|
|
265
|
+
// Only the server saying "do not come back" stops us: a rejected claim or an
|
|
266
|
+
// unsupported protocol will not fix itself. Everything else that can land here —
|
|
267
|
+
// a timeout, a socket dropped mid-registration, a transient server error — is
|
|
268
|
+
// precisely what reconnecting is for. Treating all of it as fatal left the link
|
|
269
|
+
// dead for the life of the process.
|
|
270
|
+
if (error instanceof protocol_1.LinkError && error.fatal)
|
|
271
|
+
this.closedByUs = true;
|
|
272
|
+
this.dropSocket(generation, 1000, "handshake failed");
|
|
187
273
|
});
|
|
188
274
|
}
|
|
189
|
-
|
|
275
|
+
/**
|
|
276
|
+
* Abandons a socket and treats it as closed right now.
|
|
277
|
+
*
|
|
278
|
+
* A connection that died without a close frame can take minutes to report it, or
|
|
279
|
+
* never, so the close is synthesised rather than waited for. Bumping the generation
|
|
280
|
+
* means the real event, whenever it turns up, is ignored.
|
|
281
|
+
*/
|
|
282
|
+
dropSocket(generation, code, reason) {
|
|
283
|
+
if (generation !== this.generation)
|
|
284
|
+
return;
|
|
285
|
+
this.onClose(generation, code, reason);
|
|
286
|
+
// 1006 is reserved and rejected by browsers; anything else we raise is a valid
|
|
287
|
+
// application code.
|
|
288
|
+
this.abandon(generation, reason, code === 1006 ? 1000 : code);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Closes a socket this end has stopped using, whatever generation it belongs to.
|
|
292
|
+
*
|
|
293
|
+
* Every path that walks away from a socket goes through here. Forgetting one is not harmless:
|
|
294
|
+
* the server has no way to tell an abandoned connection from a live one — it has said hello,
|
|
295
|
+
* registered its tools and claimed its link id — so it keeps it, keeps serving from it, and
|
|
296
|
+
* hands the link id back and forth between it and its replacements.
|
|
297
|
+
*/
|
|
298
|
+
abandon(generation, reason, code = 1000) {
|
|
299
|
+
const socket = this.sockets.get(generation);
|
|
300
|
+
if (!socket)
|
|
301
|
+
return;
|
|
302
|
+
this.sockets.delete(generation);
|
|
303
|
+
if (this.socket === socket)
|
|
304
|
+
this.socket = null;
|
|
305
|
+
try {
|
|
306
|
+
socket.close(code, reason);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
// Already gone, which is the outcome we wanted anyway.
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
onClose(generation, code, reason) {
|
|
313
|
+
if (generation !== this.generation)
|
|
314
|
+
return;
|
|
315
|
+
// Whatever else this socket has to say is now somebody else's news.
|
|
316
|
+
this.generation += 1;
|
|
190
317
|
const willReconnect = this.options.reconnect && !this.closedByUs;
|
|
318
|
+
const error = new protocol_1.LinkError("disconnected", `The link disconnected (${code}${reason ? `: ${reason}` : ""}).`);
|
|
191
319
|
this.socket = null;
|
|
192
320
|
this.identity = undefined;
|
|
193
|
-
this.
|
|
321
|
+
this.attempting = false;
|
|
194
322
|
this.currentState = willReconnect ? "connecting" : "closed";
|
|
195
323
|
// Nothing about subscriptions survives a socket: the server re-sends the set on every
|
|
196
324
|
// connect, so holding the old one would only risk reporting against ids that are gone.
|
|
197
325
|
this.subscriptionStore.reset();
|
|
198
326
|
this.stopHeartbeat();
|
|
199
|
-
this.failPending(
|
|
327
|
+
this.failPending(error);
|
|
328
|
+
this.settleWaiters(this.attemptWaiters, error);
|
|
200
329
|
this.emitter.emit("disconnect", { code, reason, willReconnect });
|
|
201
|
-
this.readySignal?.reject(new protocol_1.LinkError("disconnected", `The link disconnected (${code}).`));
|
|
202
|
-
this.readySignal = undefined;
|
|
203
330
|
if (willReconnect)
|
|
204
331
|
this.scheduleReconnect();
|
|
332
|
+
else
|
|
333
|
+
this.settleWaiters(this.openWaiters, error);
|
|
334
|
+
}
|
|
335
|
+
/** Retries when it is allowed to, and tells everyone waiting when it is not. */
|
|
336
|
+
retryOrGiveUp(error) {
|
|
337
|
+
if (this.options.reconnect && !this.closedByUs) {
|
|
338
|
+
this.currentState = "connecting";
|
|
339
|
+
this.scheduleReconnect();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
this.currentState = "closed";
|
|
343
|
+
this.settleWaiters(this.openWaiters, error);
|
|
205
344
|
}
|
|
206
345
|
/**
|
|
207
346
|
* Reconnects with full jitter on top of any delay the server asked for.
|
|
@@ -210,33 +349,97 @@ class Link {
|
|
|
210
349
|
* a fixed delay produces.
|
|
211
350
|
*/
|
|
212
351
|
scheduleReconnect() {
|
|
352
|
+
this.clearReconnect();
|
|
213
353
|
const ceiling = Math.min(this.options.maxReconnectDelayMs, this.options.minReconnectDelayMs * 2 ** this.reconnectAttempt);
|
|
214
354
|
const delay = this.reconnectAfterMs + Math.random() * ceiling;
|
|
215
355
|
this.reconnectAttempt += 1;
|
|
216
356
|
this.debug(`reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempt})`);
|
|
217
|
-
|
|
218
|
-
|
|
357
|
+
this.reconnectTimer = setTimeout(() => {
|
|
358
|
+
this.reconnectTimer = undefined;
|
|
359
|
+
if (this.closedByUs || this.currentState === "open" || this.attempting)
|
|
219
360
|
return;
|
|
220
|
-
this.openSocket()
|
|
361
|
+
this.openSocket();
|
|
221
362
|
}, delay);
|
|
222
|
-
unref(
|
|
363
|
+
unref(this.reconnectTimer);
|
|
223
364
|
}
|
|
224
|
-
|
|
365
|
+
clearReconnect() {
|
|
366
|
+
if (this.reconnectTimer)
|
|
367
|
+
clearTimeout(this.reconnectTimer);
|
|
368
|
+
this.reconnectTimer = undefined;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Pings on an interval and, the important half, notices when a ping goes unanswered.
|
|
372
|
+
*
|
|
373
|
+
* A websocket can die without a close frame — a dropped route, a proxy that forgets
|
|
374
|
+
* the connection, a suspended machine — leaving both ends convinced they are
|
|
375
|
+
* connected while every frame sent into it vanishes. An unanswered ping is the only
|
|
376
|
+
* evidence this end will ever get, so it is treated as a dead connection and
|
|
377
|
+
* reconnected rather than swallowed.
|
|
378
|
+
*/
|
|
379
|
+
startHeartbeat(generation) {
|
|
225
380
|
if (!this.options.heartbeatMs)
|
|
226
381
|
return;
|
|
382
|
+
this.stopHeartbeat();
|
|
227
383
|
this.heartbeat = setInterval(() => {
|
|
384
|
+
if (generation !== this.generation)
|
|
385
|
+
return;
|
|
228
386
|
// The reply is consumed by the exchange, so it never reaches log listeners.
|
|
229
|
-
this.exchange("ping", {}, {
|
|
230
|
-
|
|
387
|
+
this.exchange("ping", {}, {
|
|
388
|
+
awaitReady: false,
|
|
389
|
+
isDone: (frame) => frame.type === "log",
|
|
390
|
+
timeoutMs: this.heartbeatTimeoutMs(),
|
|
391
|
+
}).catch(() => {
|
|
392
|
+
if (generation !== this.generation)
|
|
393
|
+
return;
|
|
394
|
+
this.debug("heartbeat went unanswered, treating the connection as dead");
|
|
395
|
+
this.dropSocket(generation, 4000, "heartbeat timeout");
|
|
396
|
+
});
|
|
231
397
|
}, this.options.heartbeatMs);
|
|
232
398
|
unref(this.heartbeat);
|
|
233
399
|
}
|
|
400
|
+
/**
|
|
401
|
+
* Never longer than the interval itself: a second ping in flight tells us nothing new.
|
|
402
|
+
*
|
|
403
|
+
* Floored so that a very short interval cannot declare a merely busy connection dead.
|
|
404
|
+
*/
|
|
405
|
+
heartbeatTimeoutMs() {
|
|
406
|
+
return Math.max(250, Math.min(this.options.requestTimeoutMs, this.options.heartbeatMs));
|
|
407
|
+
}
|
|
234
408
|
stopHeartbeat() {
|
|
235
409
|
if (this.heartbeat)
|
|
236
410
|
clearInterval(this.heartbeat);
|
|
237
411
|
this.heartbeat = undefined;
|
|
238
412
|
}
|
|
239
413
|
// =============================================
|
|
414
|
+
// WAITING
|
|
415
|
+
// =============================================
|
|
416
|
+
/** Parks a caller until someone settles the list it was parked in. 0 waits forever. */
|
|
417
|
+
wait(waiters, timeoutMs) {
|
|
418
|
+
return new Promise((resolve, reject) => {
|
|
419
|
+
const waiter = { resolve, reject };
|
|
420
|
+
if (timeoutMs > 0) {
|
|
421
|
+
waiter.timer = setTimeout(() => {
|
|
422
|
+
const index = waiters.indexOf(waiter);
|
|
423
|
+
if (index >= 0)
|
|
424
|
+
waiters.splice(index, 1);
|
|
425
|
+
reject(new protocol_1.LinkError("timeout", `The link was not open within ${timeoutMs}ms.`));
|
|
426
|
+
}, timeoutMs);
|
|
427
|
+
unref(waiter.timer);
|
|
428
|
+
}
|
|
429
|
+
waiters.push(waiter);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
settleWaiters(waiters, error) {
|
|
433
|
+
for (const waiter of waiters.splice(0, waiters.length)) {
|
|
434
|
+
if (waiter.timer)
|
|
435
|
+
clearTimeout(waiter.timer);
|
|
436
|
+
if (error)
|
|
437
|
+
waiter.reject(error);
|
|
438
|
+
else
|
|
439
|
+
waiter.resolve();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// =============================================
|
|
240
443
|
// REGISTRATION
|
|
241
444
|
// =============================================
|
|
242
445
|
async registerAll() {
|
|
@@ -269,7 +472,13 @@ class Link {
|
|
|
269
472
|
* that was valid a moment ago is a race, not a bug worth complaining about.
|
|
270
473
|
*/
|
|
271
474
|
async reportHookEvent(hookId, event, payload, chosenIds) {
|
|
272
|
-
|
|
475
|
+
// Not `ready()`: a report is only worth anything now, and while the link is down the
|
|
476
|
+
// subscription set is empty anyway — so waiting would mean holding a busy guild's
|
|
477
|
+
// events open to match them against nothing.
|
|
478
|
+
if (this.currentState !== "open") {
|
|
479
|
+
this.debug(`dropped ${event}: the link is not connected`);
|
|
480
|
+
return [];
|
|
481
|
+
}
|
|
273
482
|
const sourceId = this.hooks.get(hookId)?.sourceId ?? hookId;
|
|
274
483
|
// Explicit ids are still checked against what this link actually holds. Not out of distrust of
|
|
275
484
|
// the caller — the server checks again anyway — but because an id it was never given can only
|
|
@@ -490,15 +699,6 @@ exports.Link = Link;
|
|
|
490
699
|
// =============================================
|
|
491
700
|
// HELPERS
|
|
492
701
|
// =============================================
|
|
493
|
-
function deferred() {
|
|
494
|
-
let resolve;
|
|
495
|
-
let reject;
|
|
496
|
-
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
497
|
-
// Rejections are surfaced through connect() and the error event; an unobserved
|
|
498
|
-
// one here must not take the process down.
|
|
499
|
-
promise.catch(() => undefined);
|
|
500
|
-
return { promise, resolve, reject };
|
|
501
|
-
}
|
|
502
702
|
function asError(value) {
|
|
503
703
|
if (value instanceof Error)
|
|
504
704
|
return value;
|