@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.
@@ -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
- private connecting?;
95
- private readySignal?;
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
- /** Connects, resolving once every tool and hook has been registered. */
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
- /** Resolves when the link is usable, connecting first if it has not been asked to yet. */
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
- /** Connects, resolving once every tool and hook has been registered. */
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.connecting = this.openSocket().then(() => this, error => {
118
- this.connecting = undefined;
119
- throw error;
120
- });
121
- return this.connecting;
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
- /** Resolves when the link is usable, connecting first if it has not been asked to yet. */
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 Error("This link has been closed.");
129
- await this.connect();
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.failPending(new protocol_1.LinkError("closed", "The link was closed."));
137
- this.socket?.close(1000, reason);
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
- this.connecting = undefined;
140
- this.readySignal = undefined;
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
- const signal = deferred();
145
- this.readySignal = signal;
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
- this.socket = this.options.socketFactory(url, protocols, {
150
- onOpen: () => this.onOpen(),
151
- onMessage: (data) => this.onMessage(data),
152
- onClose: (code, reason) => this.onClose(code, reason),
153
- onError: (error) => this.emitter.emit("error", asError(error)),
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
- signal.reject(asError(error));
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
- this.identity = { connectionId: welcome.connectionId, scope: welcome.scope };
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.readySignal?.resolve();
179
- this.emitter.emit("connect", this.identity);
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
- // A rejected claim or an unsupported protocol will not fix itself by
183
- // trying again, so this stops rather than looping.
184
- this.closedByUs = true;
185
- this.readySignal?.reject(error);
186
- this.socket?.close(1000, "handshake failed");
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
- onClose(code, reason) {
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.connecting = undefined;
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(new protocol_1.LinkError("disconnected", `The link disconnected (${code}${reason ? `: ${reason}` : ""}).`));
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
- const timer = setTimeout(() => {
218
- if (this.closedByUs)
357
+ this.reconnectTimer = setTimeout(() => {
358
+ this.reconnectTimer = undefined;
359
+ if (this.closedByUs || this.currentState === "open" || this.attempting)
219
360
  return;
220
- this.openSocket().catch(error => this.emitter.emit("error", asError(error)));
361
+ this.openSocket();
221
362
  }, delay);
222
- unref(timer);
363
+ unref(this.reconnectTimer);
223
364
  }
224
- startHeartbeat() {
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", {}, { isDone: (frame) => frame.type === "log" })
230
- .catch(() => undefined);
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
- await this.ready();
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butlerbot/sdk",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "description": "The official ButlerBot SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",