@signalhousellc/sdk 1.0.64 → 1.0.66

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.
@@ -11,9 +11,14 @@ import { Call } from "./Call.js";
11
11
 
12
12
  /**
13
13
  * @typedef {Object} VoiceTokenResponse
14
- * @property {string} token - JWT (informational; SDK does not currently re-send it).
15
- * @property {string} identity - The wrtc_<hex> SIP username.
16
- * @property {string} expires_at - ISO timestamp.
14
+ * @property {string} token - JWT (informational). Tokens are ephemeral: refresh
15
+ * before `expires_at` (see updateToken()) so the softphone keeps a valid
16
+ * registration.
17
+ * @property {string} identity - The wrtc_<hex> SIP username. The refresh
18
+ * endpoint (POST /voice/tokens/refresh) keeps this identity stable; a fresh
19
+ * mint (POST /voice/tokens) returns a new one.
20
+ * @property {string} expires_at - ISO timestamp. The Device schedules a
21
+ * `tokenWillExpire` event at this minus `tokenRefreshMs`.
17
22
  * @property {SipCredentials} sip_credentials
18
23
  */
19
24
 
@@ -40,12 +45,33 @@ import { Call } from "./Call.js";
40
45
  * const call = await device.connect({ to: "+15551234", from: "+15559999" });
41
46
  * device.on("incoming", call => call.accept());
42
47
  *
43
- * Token shape mirrors what `POST /voice/tokens` returns from the SignalHouse
44
- * voice-backend. You can also hand in the inner `sip_credentials` object
45
- * directly if your app already unwrapped the response.
48
+ * Token refresh (REQUIRED for long-lived sessions): voice tokens are short-
49
+ * lived — a token's SIP credential authenticates only until `expires_at`. If
50
+ * you let it lapse, the softphone can no longer re-register and inbound calls
51
+ * stop arriving (they go to voicemail). The Device raises a `tokenWillExpire`
52
+ * event shortly before expiry so you can keep it alive. Mirrors Twilio's
53
+ * Device.updateToken() model:
54
+ *
55
+ * // Manual — fetch a fresh token from your backend and hand it back:
56
+ * device.on("tokenWillExpire", async () => {
57
+ * const fresh = await fetch("/my/backend/voice-token").then((r) => r.json());
58
+ * await device.updateToken(fresh);
59
+ * });
60
+ *
61
+ * // Automatic — let the Device refresh itself:
62
+ * const device = new Device(tokenResponse, {
63
+ * tokenProvider: () => fetch("/my/backend/voice-token").then((r) => r.json()),
64
+ * });
65
+ *
66
+ * Preferred: have your backend call POST /voice/tokens/refresh, which extends
67
+ * the SAME identity in place — the Device then just reschedules and keeps its
68
+ * existing registration (no re-register, seamless). If instead you mint a fresh
69
+ * token (new identity), the Device re-registers under it (break-before-make,
70
+ * deferred while a call is active).
46
71
  *
47
72
  * Events: "registered", "unregistered", "registrationFailed", "incoming",
48
- * "connected" (WSS established), "disconnected", "error".
73
+ * "connected" (WSS established), "disconnected", "tokenWillExpire",
74
+ * "tokenRefreshFailed", "error".
49
75
  */
50
76
  export class Device {
51
77
  /**
@@ -54,25 +80,36 @@ export class Device {
54
80
  * @param {number} [opts.registerExpires=600] - REGISTER expires interval, in seconds.
55
81
  * @param {string} [opts.userAgent="SignalHouse-Voice-SDK/0.1"]
56
82
  * @param {boolean} [opts.autoRegister=false] - Call register() on construct.
83
+ * @param {number} [opts.tokenRefreshMs=30000] - How long before `expires_at`
84
+ * to raise `tokenWillExpire` (and auto-refresh, when `tokenProvider` is set).
85
+ * @param {() => (VoiceTokenResponse|Promise<VoiceTokenResponse>)} [opts.tokenProvider]
86
+ * Optional. A function YOU supply that returns a fresh token — ideally by
87
+ * calling POST /voice/tokens/refresh through your backend (identity-
88
+ * preserving), or a fresh mint. When set, the Device calls it on
89
+ * `tokenWillExpire` and applies the result via updateToken() automatically.
57
90
  */
58
91
  constructor(tokenOrCreds, opts = {}) {
59
- const creds = tokenOrCreds?.sip_credentials || tokenOrCreds;
60
- if (!creds?.username || !creds?.password || !creds?.domain || !creds?.wss_url) {
61
- throw new Error("Device: token/credentials missing required fields (username, password, domain, wss_url)");
62
- }
63
-
64
- this.identity = tokenOrCreds?.identity || creds.username;
92
+ this._credentials = this._parseCreds(tokenOrCreds, "Device");
93
+ this.identity = tokenOrCreds?.identity || this._credentials.username;
65
94
  this.expiresAt = tokenOrCreds?.expires_at || null;
66
95
 
67
- this._credentials = creds;
68
96
  this._opts = {
69
97
  registerExpires: opts.registerExpires ?? 600,
70
98
  userAgent: opts.userAgent ?? "SignalHouse-Voice-SDK/0.1",
99
+ tokenRefreshMs: opts.tokenRefreshMs ?? 30000,
100
+ tokenProvider: typeof opts.tokenProvider === "function" ? opts.tokenProvider : null,
71
101
  };
72
102
 
73
103
  this._ua = null;
74
104
  this._listeners = new Map();
75
105
  this._registered = false;
106
+ this._stopped = false; // set by unregister(); guards in-flight refreshes
107
+
108
+ // Token-refresh state.
109
+ this._refreshTimer = null; // fires `tokenWillExpire` before expiry
110
+ this._activeSessions = new Set(); // in-flight calls; defer an identity swap while busy
111
+ this._pendingToken = null; // identity-change token handed to updateToken() while busy
112
+ this._refreshPendingAuto = false; // tokenProvider refresh deferred while busy
76
113
 
77
114
  if (opts.autoRegister) this.register().catch(() => { /* surfaced via "registrationFailed" event */ });
78
115
  }
@@ -81,10 +118,12 @@ export class Device {
81
118
 
82
119
  /**
83
120
  * Open the WSS connection and REGISTER. Resolves on first "registered"
84
- * event, rejects on first "registrationFailed".
121
+ * event, rejects on first "registrationFailed". On success, schedules the
122
+ * `tokenWillExpire` refresh timer from the current token's `expires_at`.
85
123
  * @returns {Promise<void>}
86
124
  */
87
125
  register() {
126
+ this._stopped = false; // an intentional (re)start re-arms the device
88
127
  if (this._registered) return Promise.resolve();
89
128
  if (!this._ua) this._initUA();
90
129
 
@@ -92,6 +131,7 @@ export class Device {
92
131
  const onOk = () => {
93
132
  this._ua.removeListener("registered", onOk);
94
133
  this._ua.removeListener("registrationFailed", onFail);
134
+ this._scheduleTokenRefresh();
95
135
  resolve();
96
136
  };
97
137
  const onFail = (e) => {
@@ -106,18 +146,55 @@ export class Device {
106
146
  }
107
147
 
108
148
  /**
109
- * Unregister and close the WSS connection. Idempotent.
149
+ * Unregister and close the WSS connection. Idempotent. Marks the device
150
+ * stopped so any in-flight token refresh cannot bring it back online, and
151
+ * cancels the refresh timer + pending state.
110
152
  * @returns {Promise<void>}
111
153
  */
112
154
  unregister() {
113
- if (!this._ua) return Promise.resolve();
114
- return new Promise((resolve) => {
115
- const done = () => { try { this._ua.stop(); } catch {} resolve(); };
116
- if (!this._registered) return done();
117
- this._ua.once("unregistered", done);
118
- try { this._ua.unregister(); }
119
- catch { done(); }
120
- });
155
+ this._stopped = true;
156
+ this._clearTokenRefresh();
157
+ this._pendingToken = null;
158
+ this._refreshPendingAuto = false;
159
+ return this._teardownUA();
160
+ }
161
+
162
+ /**
163
+ * Apply a fresh token so the registration never lapses — call this in
164
+ * response to `tokenWillExpire` (or let `tokenProvider` do it).
165
+ *
166
+ * Identity-preserving fast path: if the token carries the SAME identity +
167
+ * password as the current one (i.e. your backend called POST
168
+ * /voice/tokens/refresh to extend in place), the existing registration is
169
+ * still valid, so the Device just updates `expires_at` and reschedules — no
170
+ * re-register, fully seamless, safe even mid-call.
171
+ *
172
+ * Identity change (a fresh mint): the Device re-registers under the new
173
+ * identity (break-before-make), deferred while a call is active and applied
174
+ * once the last call ends, so live audio is never disrupted.
175
+ *
176
+ * @param {VoiceTokenResponse | SipCredentials} tokenOrCreds
177
+ * @returns {Promise<void>} resolves once applied (or immediately when a swap
178
+ * is deferred behind an active call).
179
+ */
180
+ async updateToken(tokenOrCreds) {
181
+ const creds = this._parseCreds(tokenOrCreds, "Device.updateToken");
182
+ const newIdentity = tokenOrCreds?.identity || creds.username;
183
+
184
+ // Identity-preserving fast path — same credential, just extended expiry.
185
+ if (this._ua && this._registered && !this._stopped &&
186
+ newIdentity === this.identity && creds.password === this._credentials.password) {
187
+ this.expiresAt = tokenOrCreds?.expires_at || this.expiresAt;
188
+ this._scheduleTokenRefresh();
189
+ return;
190
+ }
191
+
192
+ // Identity changed → full re-register; defer while a call is active.
193
+ if (this._activeSessions.size > 0) {
194
+ this._pendingToken = tokenOrCreds;
195
+ return;
196
+ }
197
+ await this._applyToken(tokenOrCreds);
121
198
  }
122
199
 
123
200
  /**
@@ -162,6 +239,7 @@ export class Device {
162
239
  extraHeaders: headers,
163
240
  });
164
241
 
242
+ this._trackSession(session);
165
243
  return new Call({ session, direction: "outbound" });
166
244
  }
167
245
 
@@ -192,6 +270,173 @@ export class Device {
192
270
  }
193
271
  }
194
272
 
273
+ /**
274
+ * Unwrap `sip_credentials` from a token response (or accept a bare creds
275
+ * object) and validate the four required SIP fields.
276
+ * @param {VoiceTokenResponse | SipCredentials} tokenOrCreds
277
+ * @param {string} ctx - Caller label used in the error message.
278
+ * @returns {SipCredentials}
279
+ * @throws {Error} when a required field is missing.
280
+ */
281
+ _parseCreds(tokenOrCreds, ctx) {
282
+ const creds = tokenOrCreds?.sip_credentials || tokenOrCreds;
283
+ if (!creds?.username || !creds?.password || !creds?.domain || !creds?.wss_url) {
284
+ throw new Error(`${ctx}: token/credentials missing required fields (username, password, domain, wss_url)`);
285
+ }
286
+ return creds;
287
+ }
288
+
289
+ /**
290
+ * Stop the current UA and close its WSS, without touching lifecycle flags —
291
+ * used both by unregister() and by _applyToken()'s break-before-make swap.
292
+ * @returns {Promise<void>}
293
+ */
294
+ _teardownUA() {
295
+ if (!this._ua) return Promise.resolve();
296
+ return new Promise((resolve) => {
297
+ const ua = this._ua;
298
+ const done = () => { try { ua.stop(); } catch {} resolve(); };
299
+ if (!this._registered) return done();
300
+ ua.once("unregistered", done);
301
+ try { ua.unregister(); }
302
+ catch { done(); }
303
+ });
304
+ }
305
+
306
+ /**
307
+ * Re-register under fresh credentials when the identity changed
308
+ * (break-before-make). Only invoked when no call is active (updateToken
309
+ * defers while busy). Bails if the app tore the device down mid-swap; if the
310
+ * new registration fails, surfaces `tokenRefreshFailed` and re-arms a short
311
+ * retry so the device recovers rather than sitting unregistered.
312
+ * @param {VoiceTokenResponse | SipCredentials} tokenOrCreds
313
+ */
314
+ async _applyToken(tokenOrCreds) {
315
+ const creds = this._parseCreds(tokenOrCreds, "Device.updateToken");
316
+ this._clearTokenRefresh();
317
+ try { await this._teardownUA(); } catch { /* ignore — tearing down anyway */ }
318
+ if (this._stopped) return; // unregister() ran while we were tearing down
319
+
320
+ this._ua = null;
321
+ this._registered = false;
322
+ this._credentials = creds;
323
+ this.identity = tokenOrCreds?.identity || creds.username;
324
+ this.expiresAt = tokenOrCreds?.expires_at || null;
325
+
326
+ try {
327
+ await this.register(); // rebuilds the UA + reschedules the refresh timer
328
+ } catch (err) {
329
+ this._emit("tokenRefreshFailed", { error: err });
330
+ if (!this._stopped) {
331
+ this._clearTokenRefresh();
332
+ this._refreshTimer = setTimeout(() => this._onTokenWillExpire(), 5000);
333
+ }
334
+ throw err; // propagate to a manual updateToken() caller
335
+ }
336
+ }
337
+
338
+ /** `expires_at` as epoch ms, or null when unparseable/absent. */
339
+ _expiresAtMs() {
340
+ const t = Date.parse(this.expiresAt || "");
341
+ return Number.isFinite(t) ? t : null;
342
+ }
343
+
344
+ /**
345
+ * True if the given token has too little life left to safely apply (would
346
+ * expire within tokenRefreshMs). Tokens with no parseable expiry are treated
347
+ * as usable.
348
+ * @param {VoiceTokenResponse} tokenOrCreds
349
+ * @returns {boolean}
350
+ */
351
+ _isTokenStale(tokenOrCreds) {
352
+ const exp = Date.parse(tokenOrCreds?.expires_at || "");
353
+ if (!Number.isFinite(exp)) return false;
354
+ return exp - Date.now() <= this._opts.tokenRefreshMs;
355
+ }
356
+
357
+ /** Arm the one-shot `tokenWillExpire` timer for tokenRefreshMs before expiry. */
358
+ _scheduleTokenRefresh() {
359
+ this._clearTokenRefresh();
360
+ const expMs = this._expiresAtMs();
361
+ if (expMs === null) return; // no expiry info → caller manages refresh manually
362
+ const delay = Math.max(0, expMs - Date.now() - this._opts.tokenRefreshMs);
363
+ this._refreshTimer = setTimeout(() => this._onTokenWillExpire(), delay);
364
+ }
365
+
366
+ /** Cancel the pending `tokenWillExpire` timer, if any. */
367
+ _clearTokenRefresh() {
368
+ if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; }
369
+ }
370
+
371
+ /**
372
+ * Fired shortly before the current token expires. Emits `tokenWillExpire`
373
+ * for manual handlers; if a `tokenProvider` is configured, also fetches a
374
+ * fresh token and applies it. Deferred while a call is active (the fetch
375
+ * happens only once idle, so a fetched token can't go stale in hand). Bails
376
+ * if the device was torn down while the fetch was in flight.
377
+ */
378
+ async _onTokenWillExpire() {
379
+ if (this._stopped) return;
380
+ this._emit("tokenWillExpire", { identity: this.identity, expiresAt: this.expiresAt });
381
+ if (!this._opts.tokenProvider) return; // app handles it via updateToken()
382
+ if (this._activeSessions.size > 0) {
383
+ this._refreshPendingAuto = true; // fetch + apply once the call ends
384
+ return;
385
+ }
386
+ try {
387
+ const fresh = await this._opts.tokenProvider();
388
+ if (this._stopped) return; // torn down while the provider fetch was pending
389
+ if (!fresh) throw new Error("tokenProvider returned no token");
390
+ await this.updateToken(fresh);
391
+ } catch (err) {
392
+ if (this._stopped) return;
393
+ this._emit("tokenRefreshFailed", { error: err });
394
+ // Retry shortly so a transient failure doesn't strand us, provided
395
+ // there's still time before the current token expires.
396
+ const expMs = this._expiresAtMs();
397
+ if (expMs !== null && expMs - Date.now() > 5000) {
398
+ this._clearTokenRefresh();
399
+ this._refreshTimer = setTimeout(() => this._onTokenWillExpire(), 5000);
400
+ }
401
+ }
402
+ }
403
+
404
+ /** Track a call session so an identity swap can be deferred until it ends. */
405
+ _trackSession(session) {
406
+ if (!session) return;
407
+ this._activeSessions.add(session);
408
+ const done = () => this._untrackSession(session);
409
+ try { session.on("ended", done); session.on("failed", done); }
410
+ catch { /* not a JsSIP session — best-effort tracking only */ }
411
+ }
412
+
413
+ /**
414
+ * Stop tracking a finished session. When the line goes fully idle, apply any
415
+ * token refresh that was deferred behind the call: a `tokenProvider` fetch
416
+ * (fresh) or a stored identity-change token — re-fetching instead if that
417
+ * stored token has gone stale while the call ran.
418
+ * @param {object} session - The JsSIP session that ended/failed.
419
+ */
420
+ _untrackSession(session) {
421
+ this._activeSessions.delete(session);
422
+ if (this._activeSessions.size > 0 || this._stopped) return;
423
+
424
+ if (this._refreshPendingAuto) {
425
+ this._refreshPendingAuto = false;
426
+ this._onTokenWillExpire();
427
+ } else if (this._pendingToken) {
428
+ const pending = this._pendingToken;
429
+ this._pendingToken = null;
430
+ // A long call can outlive the token we stashed — don't register with
431
+ // stale creds; get a fresh one the same way the timer would.
432
+ if (this._isTokenStale(pending)) {
433
+ this._onTokenWillExpire();
434
+ } else {
435
+ this._applyToken(pending).catch((err) => this._emit("tokenRefreshFailed", { error: err }));
436
+ }
437
+ }
438
+ }
439
+
195
440
  _initUA() {
196
441
  const socket = new JsSIP.WebSocketInterface(this._credentials.wss_url);
197
442
  this._ua = new JsSIP.UA({
@@ -219,6 +464,7 @@ export class Device {
219
464
 
220
465
  this._ua.on("newRTCSession", (e) => {
221
466
  if (e.originator !== "remote") return; // outbound: we already wrap in connect()
467
+ this._trackSession(e.session);
222
468
  const call = new Call({ session: e.session, direction: "inbound", request: e.request });
223
469
  this._emit("incoming", call);
224
470
  });