@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 +1 -1
- package/runtime/realtime-client.ts +98 -0
- package/types.ts +12 -0
package/package.json
CHANGED
|
@@ -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;
|