@leavepulse/control-sdk 0.3.48 → 0.3.50

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.50",
4
4
  "description": "Generated resource SDK (@leavepulse/control-sdk).",
5
5
  "type": "module",
6
6
  "module": "index.ts",
@@ -35,6 +35,18 @@ export class ControlDcimComponentType extends Resource<Data> {
35
35
  );
36
36
  }
37
37
 
38
+ /** control.dcim.catalog.component_types.get */
39
+ async controlDcimCatalogComponentTypesGet(): Promise<models.ComponentTypeDTO> {
40
+ return fetchCachedOrThrow<models.ComponentTypeDTO>(
41
+ this.ctx.transport,
42
+ this.ctx.etagStore,
43
+ {
44
+ method: "GET",
45
+ path: `/v1/control/dcim/catalog/component-types/${this.id}`,
46
+ },
47
+ );
48
+ }
49
+
38
50
  /** control.dcim.catalog.component_types.upsert */
39
51
  async dcimCatalogComponentTypesUpsert(
40
52
  body: models.UpsertComponentTypeRequest,
@@ -60,6 +60,51 @@ 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
+ // Destructured rather than indexed: consumers compile this file under their
91
+ // own tsconfig, and one with noUncheckedIndexedAccess types parts[1] as
92
+ // possibly undefined however the length was checked.
93
+ const [header, payload, signature] = token.split(".");
94
+ if (!header || !payload || !signature) return null;
95
+ try {
96
+ const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
97
+ const padded = base64.padEnd(
98
+ base64.length + ((4 - (base64.length % 4)) % 4),
99
+ "=",
100
+ );
101
+ const claims = JSON.parse(atob(padded)) as { exp?: unknown };
102
+ if (typeof claims.exp !== "number") return null;
103
+ return claims.exp * 1000 - Date.now();
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
63
108
 
64
109
  /** Decode a JSON payload carried in an Event/Reply `data` byte field. */
65
110
  function decodeJson(bytes: Uint8Array): unknown {
@@ -94,6 +139,9 @@ export class RealtimeClient {
94
139
  // "Authentication required for private topic" and we needlessly reconnect).
95
140
  private welcomed: Promise<void> | null = null;
96
141
  private resolveWelcome: (() => void) | null = null;
142
+ // Pending re-auth for the CURRENT socket. Cleared on close so a timer from a
143
+ // dead socket cannot fire an auth frame into its replacement.
144
+ private authRefresh: ReturnType<typeof setTimeout> | null = null;
97
145
 
98
146
  constructor(private readonly opts: RealtimeClientOptions) {}
99
147
 
@@ -148,6 +196,9 @@ export class RealtimeClient {
148
196
 
149
197
  close(): void {
150
198
  this.closedByUser = true;
199
+ // Explicit close bypasses onClose (we drop the socket ourselves), so the
200
+ // refresh timer has to be cancelled here too or it outlives the client.
201
+ this.clearAuthRefresh();
151
202
  this.subscriptions.clear();
152
203
  for (const p of this.pending.values())
153
204
  p.reject(new Error("realtime client closed"));
@@ -220,6 +271,7 @@ export class RealtimeClient {
220
271
  body: { case: "auth", value: { token } },
221
272
  }),
222
273
  );
274
+ this.scheduleAuthRefresh(token);
223
275
  }
224
276
  // Wait for the gateway's `welcome` (auth applied) before (re)subscribing,
225
277
  // so private topics aren't requested on an un-bound socket. The gate is
@@ -231,7 +283,56 @@ export class RealtimeClient {
231
283
  this.reconnectAttempts = 0;
232
284
  }
233
285
 
286
+ /**
287
+ * Send a fresh token over the OPEN socket shortly before this one expires.
288
+ *
289
+ * Re-authenticating in place is what keeps a long-lived subscription alive:
290
+ * the alternative is letting the token lapse and being reconnected, which
291
+ * drops every subscription for the duration and, for subscriber-driven
292
+ * sources, stops the upstream feed entirely until it is re-established.
293
+ */
294
+ private scheduleAuthRefresh(token: string): void {
295
+ this.clearAuthRefresh();
296
+ if (!this.opts.getToken) return;
297
+ const remaining = millisUntilExpiry(token);
298
+ if (remaining === null) return;
299
+ const delay = Math.max(
300
+ AUTH_REFRESH_MIN_MS,
301
+ remaining - AUTH_REFRESH_MARGIN_MS,
302
+ );
303
+ this.authRefresh = setTimeout(() => {
304
+ void this.refreshAuth();
305
+ }, delay);
306
+ }
307
+
308
+ private async refreshAuth(): Promise<void> {
309
+ const socket = this.socket;
310
+ if (!socket || !this.opts.getToken) return;
311
+ let next: string | null = null;
312
+ try {
313
+ next = await this.opts.getToken();
314
+ } catch {
315
+ next = null;
316
+ }
317
+ // Nothing to send, or the socket died while we were fetching: leave it to
318
+ // the reconnect path rather than authenticating a socket that is gone.
319
+ if (!next || this.socket !== socket) return;
320
+ this.sendFrame(
321
+ create(ClientFrameSchema, {
322
+ body: { case: "auth", value: { token: next } },
323
+ }),
324
+ );
325
+ this.scheduleAuthRefresh(next);
326
+ }
327
+
328
+ private clearAuthRefresh(): void {
329
+ if (this.authRefresh === null) return;
330
+ clearTimeout(this.authRefresh);
331
+ this.authRefresh = null;
332
+ }
333
+
234
334
  private onClose(): void {
335
+ this.clearAuthRefresh();
235
336
  this.socket = null;
236
337
  this.connecting = null;
237
338
  this.authenticated = false;
package/types.ts CHANGED
@@ -498,7 +498,8 @@ export interface paths {
498
498
  path?: never;
499
499
  cookie?: never;
500
500
  };
501
- get?: never;
501
+ /** GetComponentType */
502
+ get: operations["control.dcim.catalog.component_types.get"];
502
503
  put?: never;
503
504
  post?: never;
504
505
  /** DeleteComponentType */
@@ -1991,6 +1992,12 @@ export interface components {
1991
1992
  manufacturer_name: string;
1992
1993
  model: string;
1993
1994
  notes: string;
1995
+ /** @default */
1996
+ output_connector: string;
1997
+ /** @default 0 */
1998
+ output_ma: number;
1999
+ /** @default 0 */
2000
+ output_mv: number;
1994
2001
  part_number: string;
1995
2002
  power_w: number;
1996
2003
  source: string;
@@ -3297,6 +3304,12 @@ export interface components {
3297
3304
  /** @default */
3298
3305
  notes?: string;
3299
3306
  /** @default */
3307
+ output_connector?: string;
3308
+ /** @default 0 */
3309
+ output_ma?: number;
3310
+ /** @default 0 */
3311
+ output_mv?: number;
3312
+ /** @default */
3300
3313
  part_number?: string;
3301
3314
  /** @default 0 */
3302
3315
  power_w?: number;
@@ -5155,6 +5168,46 @@ export interface operations {
5155
5168
  };
5156
5169
  };
5157
5170
  };
5171
+ "control.dcim.catalog.component_types.get": {
5172
+ parameters: {
5173
+ query?: never;
5174
+ header?: never;
5175
+ path: {
5176
+ component_type_id: string;
5177
+ };
5178
+ cookie?: never;
5179
+ };
5180
+ requestBody?: never;
5181
+ responses: {
5182
+ /** @description Request fulfilled, document follows */
5183
+ 200: {
5184
+ headers: {
5185
+ [name: string]: unknown;
5186
+ };
5187
+ content: {
5188
+ "application/json": components["schemas"]["ComponentTypeDTO"];
5189
+ };
5190
+ };
5191
+ /** @description Bad request syntax or unsupported method */
5192
+ 400: {
5193
+ headers: {
5194
+ [name: string]: unknown;
5195
+ };
5196
+ content: {
5197
+ "application/json": {
5198
+ detail: string;
5199
+ extra?:
5200
+ | null
5201
+ | {
5202
+ [key: string]: unknown;
5203
+ }
5204
+ | unknown[];
5205
+ status_code: number;
5206
+ };
5207
+ };
5208
+ };
5209
+ };
5210
+ };
5158
5211
  "control.dcim.catalog.component_types.delete": {
5159
5212
  parameters: {
5160
5213
  query?: never;