@leavepulse/control-sdk 0.3.48 → 0.3.49

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/control-sdk",
3
- "version": "0.3.48",
3
+ "version": "0.3.49",
4
4
  "description": "Generated resource SDK (@leavepulse/control-sdk).",
5
5
  "type": "module",
6
6
  "module": "index.ts",
@@ -60,6 +60,48 @@ interface PendingRequest {
60
60
 
61
61
  const DEFAULT_MIN_MS = 500;
62
62
  const DEFAULT_MAX_MS = 30_000;
63
+ /**
64
+ * How long before a ws-token expires to fetch and send the next one.
65
+ *
66
+ * The gateway accepts an `auth` frame at any point in a session and rebinds the
67
+ * socket, but nothing was ever sending a second one: the token was presented
68
+ * once at connect and then left to rot. A ws-token lives two minutes, so every
69
+ * live subscription was torn down and rebuilt on that cycle — visible on the
70
+ * server as a matched pair of "connection closed" every 120s, and to an
71
+ * operator as live metrics that stall, because the agent's fast-sample tier is
72
+ * driven by subscriber count and dropped to idle in each gap.
73
+ *
74
+ * The margin has to cover fetching a fresh token (a round trip to auth-service)
75
+ * plus clock skew between the browser and the issuer.
76
+ */
77
+ const AUTH_REFRESH_MARGIN_MS = 20_000;
78
+ /** Never re-arm faster than this, however short-lived the token claims to be. */
79
+ const AUTH_REFRESH_MIN_MS = 5_000;
80
+
81
+ /**
82
+ * Milliseconds until a JWT's `exp`, or null if it carries no usable one.
83
+ *
84
+ * Deliberately does not verify the signature — the client cannot, and does not
85
+ * need to: this only decides WHEN to ask for the next token. A malformed or
86
+ * unsigned token yields null and refresh stays off, leaving the previous
87
+ * reconnect behaviour rather than a tight loop.
88
+ */
89
+ function millisUntilExpiry(token: string): number | null {
90
+ const parts = token.split(".");
91
+ if (parts.length !== 3) return null;
92
+ try {
93
+ const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
94
+ const padded = base64.padEnd(
95
+ base64.length + ((4 - (base64.length % 4)) % 4),
96
+ "=",
97
+ );
98
+ const claims = JSON.parse(atob(padded)) as { exp?: unknown };
99
+ if (typeof claims.exp !== "number") return null;
100
+ return claims.exp * 1000 - Date.now();
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
63
105
 
64
106
  /** Decode a JSON payload carried in an Event/Reply `data` byte field. */
65
107
  function decodeJson(bytes: Uint8Array): unknown {
@@ -94,6 +136,9 @@ export class RealtimeClient {
94
136
  // "Authentication required for private topic" and we needlessly reconnect).
95
137
  private welcomed: Promise<void> | null = null;
96
138
  private resolveWelcome: (() => void) | null = null;
139
+ // Pending re-auth for the CURRENT socket. Cleared on close so a timer from a
140
+ // dead socket cannot fire an auth frame into its replacement.
141
+ private authRefresh: ReturnType<typeof setTimeout> | null = null;
97
142
 
98
143
  constructor(private readonly opts: RealtimeClientOptions) {}
99
144
 
@@ -148,6 +193,9 @@ export class RealtimeClient {
148
193
 
149
194
  close(): void {
150
195
  this.closedByUser = true;
196
+ // Explicit close bypasses onClose (we drop the socket ourselves), so the
197
+ // refresh timer has to be cancelled here too or it outlives the client.
198
+ this.clearAuthRefresh();
151
199
  this.subscriptions.clear();
152
200
  for (const p of this.pending.values())
153
201
  p.reject(new Error("realtime client closed"));
@@ -220,6 +268,7 @@ export class RealtimeClient {
220
268
  body: { case: "auth", value: { token } },
221
269
  }),
222
270
  );
271
+ this.scheduleAuthRefresh(token);
223
272
  }
224
273
  // Wait for the gateway's `welcome` (auth applied) before (re)subscribing,
225
274
  // so private topics aren't requested on an un-bound socket. The gate is
@@ -231,7 +280,56 @@ export class RealtimeClient {
231
280
  this.reconnectAttempts = 0;
232
281
  }
233
282
 
283
+ /**
284
+ * Send a fresh token over the OPEN socket shortly before this one expires.
285
+ *
286
+ * Re-authenticating in place is what keeps a long-lived subscription alive:
287
+ * the alternative is letting the token lapse and being reconnected, which
288
+ * drops every subscription for the duration and, for subscriber-driven
289
+ * sources, stops the upstream feed entirely until it is re-established.
290
+ */
291
+ private scheduleAuthRefresh(token: string): void {
292
+ this.clearAuthRefresh();
293
+ if (!this.opts.getToken) return;
294
+ const remaining = millisUntilExpiry(token);
295
+ if (remaining === null) return;
296
+ const delay = Math.max(
297
+ AUTH_REFRESH_MIN_MS,
298
+ remaining - AUTH_REFRESH_MARGIN_MS,
299
+ );
300
+ this.authRefresh = setTimeout(() => {
301
+ void this.refreshAuth();
302
+ }, delay);
303
+ }
304
+
305
+ private async refreshAuth(): Promise<void> {
306
+ const socket = this.socket;
307
+ if (!socket || !this.opts.getToken) return;
308
+ let next: string | null = null;
309
+ try {
310
+ next = await this.opts.getToken();
311
+ } catch {
312
+ next = null;
313
+ }
314
+ // Nothing to send, or the socket died while we were fetching: leave it to
315
+ // the reconnect path rather than authenticating a socket that is gone.
316
+ if (!next || this.socket !== socket) return;
317
+ this.sendFrame(
318
+ create(ClientFrameSchema, {
319
+ body: { case: "auth", value: { token: next } },
320
+ }),
321
+ );
322
+ this.scheduleAuthRefresh(next);
323
+ }
324
+
325
+ private clearAuthRefresh(): void {
326
+ if (this.authRefresh === null) return;
327
+ clearTimeout(this.authRefresh);
328
+ this.authRefresh = null;
329
+ }
330
+
234
331
  private onClose(): void {
332
+ this.clearAuthRefresh();
235
333
  this.socket = null;
236
334
  this.connecting = null;
237
335
  this.authenticated = false;
package/types.ts CHANGED
@@ -1991,6 +1991,12 @@ export interface components {
1991
1991
  manufacturer_name: string;
1992
1992
  model: string;
1993
1993
  notes: string;
1994
+ /** @default */
1995
+ output_connector: string;
1996
+ /** @default 0 */
1997
+ output_ma: number;
1998
+ /** @default 0 */
1999
+ output_mv: number;
1994
2000
  part_number: string;
1995
2001
  power_w: number;
1996
2002
  source: string;
@@ -3297,6 +3303,12 @@ export interface components {
3297
3303
  /** @default */
3298
3304
  notes?: string;
3299
3305
  /** @default */
3306
+ output_connector?: string;
3307
+ /** @default 0 */
3308
+ output_ma?: number;
3309
+ /** @default 0 */
3310
+ output_mv?: number;
3311
+ /** @default */
3300
3312
  part_number?: string;
3301
3313
  /** @default 0 */
3302
3314
  power_w?: number;