yoto-nodejs-client 0.0.4 → 0.0.6

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.
Files changed (45) hide show
  1. package/README.md +24 -6
  2. package/bin/auth.js +4 -3
  3. package/bin/device-model.js +25 -5
  4. package/bin/device-tui.js +25 -3
  5. package/bin/devices.js +58 -42
  6. package/bin/lib/cli-helpers.d.ts.map +1 -1
  7. package/bin/lib/cli-helpers.js +3 -1
  8. package/bin/lib/token-helpers.d.ts +4 -2
  9. package/bin/lib/token-helpers.d.ts.map +1 -1
  10. package/bin/lib/token-helpers.js +9 -8
  11. package/bin/refresh-token.js +4 -2
  12. package/bin/token-info.js +2 -2
  13. package/lib/api-client.d.ts +11 -10
  14. package/lib/api-client.d.ts.map +1 -1
  15. package/lib/api-client.js +12 -14
  16. package/lib/api-endpoints/auth.test.js +4 -4
  17. package/lib/api-endpoints/content.test.js +32 -32
  18. package/lib/api-endpoints/devices.d.ts +4 -4
  19. package/lib/api-endpoints/devices.js +2 -2
  20. package/lib/api-endpoints/devices.test.js +45 -45
  21. package/lib/api-endpoints/endpoint-test-helpers.d.ts +3 -4
  22. package/lib/api-endpoints/endpoint-test-helpers.d.ts.map +1 -1
  23. package/lib/api-endpoints/endpoint-test-helpers.js +21 -5
  24. package/lib/api-endpoints/family-library-groups.d.ts +3 -3
  25. package/lib/api-endpoints/family-library-groups.d.ts.map +1 -1
  26. package/lib/api-endpoints/family-library-groups.js +3 -3
  27. package/lib/api-endpoints/family-library-groups.test.js +29 -29
  28. package/lib/api-endpoints/family.test.js +11 -11
  29. package/lib/api-endpoints/icons.test.js +14 -14
  30. package/lib/mqtt/client.d.ts +124 -49
  31. package/lib/mqtt/client.d.ts.map +1 -1
  32. package/lib/mqtt/client.js +132 -50
  33. package/lib/mqtt/factory.d.ts +12 -5
  34. package/lib/mqtt/factory.d.ts.map +1 -1
  35. package/lib/mqtt/factory.js +39 -11
  36. package/lib/mqtt/index.js +2 -1
  37. package/lib/mqtt/mqtt.test.js +25 -22
  38. package/lib/token.d.ts +44 -2
  39. package/lib/token.d.ts.map +1 -1
  40. package/lib/token.js +142 -2
  41. package/lib/yoto-device.d.ts +392 -32
  42. package/lib/yoto-device.d.ts.map +1 -1
  43. package/lib/yoto-device.js +643 -99
  44. package/lib/yoto-device.test.js +193 -0
  45. package/package.json +1 -1
@@ -7,6 +7,7 @@
7
7
 
8
8
  /**
9
9
  * @import { IClientOptions } from 'mqtt'
10
+ * @import { RefreshableToken } from '../token.js'
10
11
  */
11
12
 
12
13
  // ============================================================================
@@ -21,12 +22,13 @@
21
22
  /**
22
23
  * @typedef {Object} YotoMqttOptions
23
24
  * @property {string} deviceId - Device ID to connect to
24
- * @property {string} accessToken - JWT access token for authentication
25
+ * @property {RefreshableToken} token - RefreshableToken instance used to supply the current JWT. Enables auto-updating auth on reconnect via transformWsUrl.
26
+ * @property {string} [sessionId=randomUUID()] - Stable unique session ID suffix used to avoid collisions across multiple clients/processes. Used to build the MQTT clientId: DASH${deviceId}${sessionId} (non-alphanumeric characters are stripped). (MQTT calls this clientId, but we call it sessionId to not confuse it with the oauth clientId)
25
27
  * @property {string} [clientIdPrefix='DASH'] - Prefix for MQTT client ID (default: 'DASH')
26
28
  * @property {string} [brokerUrl='wss://aqrphjqbp3u2z-ats.iot.eu-west-2.amazonaws.com'] - MQTT broker URL
27
29
  * @property {number} [port=443] - MQTT broker port
28
30
  * @property {boolean} [autoSubscribe=true] - Auto-subscribe to device topics on connect
29
- * @property {MqttClientOptions} [mqttOptions] - Additional MQTT.js client options (defaults: reconnectPeriod=5000, keepalive=300; cannot override: clientId, username, password, protocol, ALPNProtocols)
31
+ * @property {MqttClientOptions} [mqttOptions] - Additional MQTT.js client options (defaults: reconnectPeriod=5000, reconnectOnConnackError=true, keepalive=300, clean=false; cannot override: clientId, username, password, protocol, ALPNProtocols; transformWsUrl is wrapped to keep auth current)
30
32
  */
31
33
 
32
34
  import mqtt from 'mqtt'
@@ -39,6 +41,7 @@ import {
39
41
  MQTT_KEEPALIVE,
40
42
  MQTT_ALPN_PROTOCOLS
41
43
  } from './topics.js'
44
+ import { randomUUID } from 'node:crypto'
42
45
 
43
46
  /**
44
47
  * Create a configured MQTT client for a Yoto device
@@ -52,7 +55,8 @@ import {
52
55
  *
53
56
  * const client = createYotoMqttClient({
54
57
  * deviceId: 'abc123',
55
- * accessToken: 'eyJhbGc...'
58
+ * token,
59
+ * clientId: 'my-unique-client-id'
56
60
  * })
57
61
  *
58
62
  * client.on('events', (message) => {
@@ -67,7 +71,8 @@ import {
67
71
  * // Disable automatic reconnection
68
72
  * const client = createYotoMqttClient({
69
73
  * deviceId: 'abc123',
70
- * accessToken: 'token',
74
+ * token,
75
+ * clientId: 'my-unique-client-id',
71
76
  * mqttOptions: {
72
77
  * reconnectPeriod: 0, // Disable auto-reconnect (default is 5000ms)
73
78
  * connectTimeout: 30000 // 30 second connection timeout
@@ -81,14 +86,15 @@ export function createYotoMqttClient (options) {
81
86
  throw new Error('deviceId is required')
82
87
  }
83
88
 
84
- if (!options.accessToken) {
85
- throw new Error('accessToken is required')
89
+ if (!options.token) {
90
+ throw new Error('token is required')
86
91
  }
87
92
 
88
93
  // Extract options with defaults
89
94
  const {
90
95
  deviceId,
91
- accessToken,
96
+ token,
97
+ sessionId = randomUUID(),
92
98
  clientIdPrefix = 'DASH',
93
99
  brokerUrl = MQTT_BROKER_URL,
94
100
  port = MQTT_PORT,
@@ -97,26 +103,48 @@ export function createYotoMqttClient (options) {
97
103
  } = options
98
104
 
99
105
  // Build MQTT client ID
100
- const clientId = `${clientIdPrefix}${deviceId}`
106
+ const mqttSessionId = (`${clientIdPrefix}${deviceId}${sessionId}`).replace(/[^a-zA-Z0-9]/g, '')
101
107
 
102
108
  // Build username with authorizer
103
109
  const username = `${deviceId}?x-amz-customauthorizer-name=${MQTT_AUTH_NAME}`
104
110
 
111
+ const userTransformWsUrl = additionalMqttOptions.transformWsUrl
112
+
113
+ /** @type {IClientOptions['transformWsUrl']} */
114
+ const transformWsUrl = (url, mqttOptions, client) => {
115
+ // Ensure the CONNECT auth uses the latest access token for every websocket (re)connect.
116
+ const latestAccessToken = token.accessToken
117
+ client.options.password = latestAccessToken
118
+
119
+ let nextUrl = url
120
+ if (typeof userTransformWsUrl === 'function') {
121
+ nextUrl = userTransformWsUrl(url, mqttOptions, client)
122
+ }
123
+
124
+ // Re-apply in case user hook mutated it.
125
+ client.options.password = latestAccessToken
126
+
127
+ return nextUrl
128
+ }
129
+
105
130
  // Create MQTT connection options
106
131
  // Merge additional options first, then override with required Yoto settings
107
132
  /** @type {IClientOptions} */
108
133
  const mqttOptions = {
109
134
  // Defaults (can be overridden by additionalMqttOptions)
110
135
  reconnectPeriod: 5000, // Default: auto-reconnect after 5 seconds
136
+ reconnectOnConnackError: true, // Default: keep reconnecting after CONNACK auth errors (token refresh may fix it)
111
137
  keepalive: MQTT_KEEPALIVE, // Default: 300 seconds (5 minutes)
138
+ clean: false, // Default: persistent session for stable subscriptions on the broker
112
139
  ...additionalMqttOptions, // Allow overriding defaults
113
140
  // Required Yoto-specific settings (cannot be overridden)
114
- clientId,
141
+ clientId: mqttSessionId,
115
142
  username,
116
- password: accessToken,
143
+ password: token.accessToken,
117
144
  port,
118
145
  protocol: MQTT_PROTOCOL,
119
- ALPNProtocols: MQTT_ALPN_PROTOCOLS
146
+ ALPNProtocols: MQTT_ALPN_PROTOCOLS,
147
+ transformWsUrl
120
148
  }
121
149
 
122
150
  // Create underlying MQTT client (not connected yet)
package/lib/mqtt/index.js CHANGED
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * const client = createYotoMqttClient({
12
12
  * deviceId: 'abc123',
13
- * accessToken: 'eyJhbGc...'
13
+ * token,
14
+ * clientId: 'my-unique-client-id'
14
15
  * })
15
16
  *
16
17
  * client.on('events', (message) => {
@@ -1,15 +1,17 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert'
3
+ import { randomUUID } from 'node:crypto'
3
4
  import { getDevices } from '../api-endpoints/devices.js'
4
5
  import { loadTestTokens, logResponse } from '../api-endpoints/endpoint-test-helpers.js'
5
6
  import { createYotoMqttClient } from './index.js'
6
7
 
7
- const { accessToken } = loadTestTokens()
8
+ const { token } = loadTestTokens()
8
9
 
9
10
  test('MQTT client', async (t) => {
10
11
  await t.test('should connect, request status/events, and receive response and events messages (status-legacy is optional)', async () => {
11
12
  // Get an online device
12
- const response = await getDevices({ accessToken })
13
+
14
+ const response = await getDevices({ accessToken: await token.getAccessToken() })
13
15
  assert.ok(response.devices.length > 0, 'Should have at least one device')
14
16
 
15
17
  const onlineDevice = response.devices.find(d => d.online)
@@ -20,7 +22,8 @@ test('MQTT client', async (t) => {
20
22
  // Create MQTT client
21
23
  const mqttClient = createYotoMqttClient({
22
24
  deviceId,
23
- accessToken
25
+ token,
26
+ sessionId: randomUUID()
24
27
  })
25
28
 
26
29
  // Track received messages
@@ -39,15 +42,15 @@ test('MQTT client', async (t) => {
39
42
  try {
40
43
  // Validate events message structure
41
44
  assert.ok(payload, 'Events message should exist')
42
- assert.ok(typeof topic === 'string', 'Topic should be a string')
45
+ assert.equal(typeof topic, 'string', 'Topic should be a string')
43
46
  assert.ok(topic.includes('/data/events'), 'Topic should contain /data/events')
44
47
 
45
48
  // Events messages are partial - not all fields are always present
46
49
  if (payload.playbackStatus !== undefined) {
47
- assert.ok(typeof payload.playbackStatus === 'string', 'playbackStatus should be string')
50
+ assert.equal(typeof payload.playbackStatus, 'string', 'playbackStatus should be string')
48
51
  }
49
52
  if (payload.cardId !== undefined) {
50
- assert.ok(typeof payload.cardId === 'string', 'cardId should be string')
53
+ assert.equal(typeof payload.cardId, 'string', 'cardId should be string')
51
54
  }
52
55
 
53
56
  eventsMessageReceived = true
@@ -62,14 +65,14 @@ test('MQTT client', async (t) => {
62
65
  try {
63
66
  // Validate status message structure
64
67
  assert.ok(payload, 'Status message should exist')
65
- assert.ok(typeof topic === 'string', 'Topic should be a string')
68
+ assert.equal(typeof topic, 'string', 'Topic should be a string')
66
69
  assert.ok(topic.includes('/data/status'), 'Topic should contain /data/status')
67
70
  assert.ok(payload.status, 'Status message should have status object')
68
- assert.ok(typeof payload.status.statusVersion === 'number', 'Should have statusVersion number')
69
- assert.ok(typeof payload.status.fwVersion === 'string', 'Should have fwVersion string')
70
- assert.ok(typeof payload.status.productType === 'string', 'Should have productType string')
71
- assert.ok(typeof payload.status.batteryLevel === 'number', 'Should have batteryLevel number')
72
- assert.ok(typeof payload.status.volume === 'number', 'Should have volume number')
71
+ assert.equal(typeof payload.status.statusVersion, 'number', 'Should have statusVersion number')
72
+ assert.equal(typeof payload.status.fwVersion, 'string', 'Should have fwVersion string')
73
+ assert.equal(typeof payload.status.productType, 'string', 'Should have productType string')
74
+ assert.equal(typeof payload.status.batteryLevel, 'number', 'Should have batteryLevel number')
75
+ assert.equal(typeof payload.status.volume, 'number', 'Should have volume number')
73
76
 
74
77
  statusMessageReceived = true
75
78
  } catch (err) {
@@ -83,33 +86,33 @@ test('MQTT client', async (t) => {
83
86
  try {
84
87
  // Validate legacy status message structure
85
88
  assert.ok(payload, 'Legacy status message should exist')
86
- assert.ok(typeof topic === 'string', 'Topic should be a string')
89
+ assert.equal(typeof topic, 'string', 'Topic should be a string')
87
90
  assert.ok(!topic.includes('/data/'), 'Legacy topic should NOT contain /data/')
88
91
  assert.ok(payload.status, 'Legacy status message should have status object')
89
92
 
90
93
  // Validate lifecycle fields unique to legacy format
91
- assert.ok(typeof payload.status.statusVersion === 'number', 'Should have statusVersion number')
92
- assert.ok(typeof payload.status.fwVersion === 'string', 'Should have fwVersion string')
94
+ assert.equal(typeof payload.status.statusVersion, 'number', 'Should have statusVersion number')
95
+ assert.equal(typeof payload.status.fwVersion, 'string', 'Should have fwVersion string')
93
96
 
94
97
  // shutDown field is the key field for lifecycle events
95
98
  if (payload.status.shutDown !== undefined) {
96
- assert.ok(typeof payload.status.shutDown === 'string', 'shutDown should be string')
99
+ assert.equal(typeof payload.status.shutDown, 'string', 'shutDown should be string')
97
100
  }
98
101
 
99
102
  // upTime and utcTime are key for startup detection
100
103
  if (payload.status.upTime !== undefined) {
101
- assert.ok(typeof payload.status.upTime === 'number', 'upTime should be number')
104
+ assert.equal(typeof payload.status.upTime, 'number', 'upTime should be number')
102
105
  }
103
106
  if (payload.status.utcTime !== undefined) {
104
- assert.ok(typeof payload.status.utcTime === 'number', 'utcTime should be number')
107
+ assert.equal(typeof payload.status.utcTime, 'number', 'utcTime should be number')
105
108
  }
106
109
 
107
110
  // Hardware diagnostic fields unique to legacy
108
111
  if (payload.status.battery !== undefined) {
109
- assert.ok(typeof payload.status.battery === 'number', 'battery should be number')
112
+ assert.equal(typeof payload.status.battery, 'number', 'battery should be number')
110
113
  }
111
114
  if (payload.status.wifiStrength !== undefined) {
112
- assert.ok(typeof payload.status.wifiStrength === 'number', 'wifiStrength should be number')
115
+ assert.equal(typeof payload.status.wifiStrength, 'number', 'wifiStrength should be number')
113
116
  }
114
117
 
115
118
  statusLegacyMessageReceived = true
@@ -124,10 +127,10 @@ test('MQTT client', async (t) => {
124
127
  try {
125
128
  // Validate response message structure
126
129
  assert.ok(payload, 'Response message should exist')
127
- assert.ok(typeof topic === 'string', 'Topic should be a string')
130
+ assert.equal(typeof topic, 'string', 'Topic should be a string')
128
131
  assert.ok(topic.includes('/response'), 'Topic should contain /response')
129
132
  assert.ok(payload.status, 'Response should have status object')
130
- assert.ok(typeof payload.status.req_body === 'string', 'Response should have req_body string')
133
+ assert.equal(typeof payload.status.req_body, 'string', 'Response should have req_body string')
131
134
 
132
135
  // Check if status request was acknowledged (field name is 'status/request')
133
136
  if (payload.status['status/request']) {
package/lib/token.d.ts CHANGED
@@ -3,8 +3,16 @@
3
3
  * @property {string} clientId - OAuth client ID
4
4
  * @property {string} refreshToken - OAuth refresh token
5
5
  * @property {string} accessToken - Initial OAuth access token (JWT)
6
+ * @property {OnTokenRefreshHandler} onTokenRefresh - A function that will receive the refreshed token info and perisist it for future use
6
7
  * @property {number} [bufferSeconds=30] - Seconds before expiration to consider token expired
7
8
  */
9
+ /**
10
+ * This is a REQUIRED function. It gets called after the token is refreshed with new tokens.
11
+ * It should save the new tokens somewhere. When starting back up, re-use the newly perissted tokens.
12
+ * If you loose track of these new tokens, you will effectively be logged out.
13
+ *
14
+ * @typedef {(refreshSuccessEvent: RefreshSuccessEvent) => void | Promise<void>} OnTokenRefreshHandler
15
+ */
8
16
  /**
9
17
  * @typedef {Object} RefreshSuccessEvent
10
18
  * @property {string} clientId - The OAuth client ID
@@ -30,7 +38,7 @@
30
38
  *
31
39
  * Events:
32
40
  * - 'refresh:start' - Emitted when token refresh begins
33
- * - 'refresh:success' - Emitted when token refresh succeeds, passes { clientId, accessToken, refreshToken, expiresAt }
41
+ * - 'refresh:success' - Emitted when token refresh succeeds, passes { clientId, updatedAccessToken, updatedRefreshToken, updatedExpiresAt, prevAccessToken, prevRefreshToken, prevExpiresAt }
34
42
  * - 'refresh:error' - Emitted when token refresh fails (transient errors), passes error
35
43
  * - 'invalid' - Emitted when refresh token is permanently invalid, passes error
36
44
  *
@@ -40,13 +48,37 @@ export class RefreshableToken extends EventEmitter<RefreshableTokenEventMap> {
40
48
  /**
41
49
  * @param {RefreshableTokenOpts} opts
42
50
  */
43
- constructor({ clientId, refreshToken, accessToken, bufferSeconds }: RefreshableTokenOpts);
51
+ constructor({ clientId, refreshToken, accessToken, bufferSeconds, onTokenRefresh }: RefreshableTokenOpts);
44
52
  /**
45
53
  * Get a valid access token, refreshing if necessary.
46
54
  * @returns {Promise<string>} Valid access token
47
55
  * @throws {Error} If token is invalid or refresh fails
48
56
  */
49
57
  getAccessToken(): Promise<string>;
58
+ /**
59
+ * Stop background token refresh scheduling.
60
+ */
61
+ stopAutoRefresh(): void;
62
+ /**
63
+ * Start background token refresh scheduling (enabled by default).
64
+ */
65
+ startAutoRefresh(): void;
66
+ /**
67
+ * Check if background token refresh scheduling is enabled.
68
+ * @returns {boolean}
69
+ */
70
+ isAutoRefreshEnabled(): boolean;
71
+ /**
72
+ * Get the current OAuth client ID (synchronous).
73
+ * @returns {string}
74
+ */
75
+ get clientId(): string;
76
+ /**
77
+ * Get the latest access token value (synchronous).
78
+ * Prefer getAccessToken() if you need a guaranteed-valid token in async contexts.
79
+ * @returns {string}
80
+ */
81
+ get accessToken(): string;
50
82
  /**
51
83
  * Check if the token is currently valid (not expired and not marked invalid).
52
84
  * @returns {boolean} True if token is valid
@@ -84,11 +116,21 @@ export type RefreshableTokenOpts = {
84
116
  * - Initial OAuth access token (JWT)
85
117
  */
86
118
  accessToken: string;
119
+ /**
120
+ * - A function that will receive the refreshed token info and perisist it for future use
121
+ */
122
+ onTokenRefresh: OnTokenRefreshHandler;
87
123
  /**
88
124
  * - Seconds before expiration to consider token expired
89
125
  */
90
126
  bufferSeconds?: number;
91
127
  };
128
+ /**
129
+ * This is a REQUIRED function. It gets called after the token is refreshed with new tokens.
130
+ * It should save the new tokens somewhere. When starting back up, re-use the newly perissted tokens.
131
+ * If you loose track of these new tokens, you will effectively be logged out.
132
+ */
133
+ export type OnTokenRefreshHandler = (refreshSuccessEvent: RefreshSuccessEvent) => void | Promise<void>;
92
134
  export type RefreshSuccessEvent = {
93
135
  /**
94
136
  * - The OAuth client ID
@@ -1 +1 @@
1
- {"version":3,"file":"token.d.ts","sourceRoot":"","sources":["token.js"],"names":[],"mappings":"AAIA;;;;;;GAMG;AAEH;;;;;;;;;GASG;AAEH;;;;;;;;GAQG;AAEH;;;;;;;;;;;GAWG;AACH;IAgBE;;OAEG;IACH,oEAFW,oBAAoB,EAoB9B;IAED;;;;OAIG;IACH,kBAHa,OAAO,CAAC,MAAM,CAAC,CAkB3B;IAkGD;;;OAGG;IACH,WAFa,OAAO,CASnB;IAED;;;OAGG;IACH,gBAFa,MAAM,CAIlB;IAED;;;OAGG;IACH,oBAFa,MAAM,CAKlB;IAED;;;;;OAKG;IACH,WAHa,OAAO,CAAC,mBAAmB,CAAC,CAUxC;;CACF;;;;;cAhPa,MAAM;;;;kBACN,MAAM;;;;iBACN,MAAM;;;;oBACN,MAAM;;;;;;cAKN,MAAM;;;;wBACN,MAAM;;;;yBACN,MAAM;;;;sBACN,MAAM;;;;qBACN,MAAM;;;;sBACN,MAAM;;;;mBACN,MAAM;;;;;uCAKP;IACZ,eAAmB,EAAE,EAAE,CAAC;IACxB,iBAAqB,EAAE,CAAC,mBAAmB,CAAC,CAAC;IAC7C,eAAmB,EAAE,CAAC,KAAK,CAAC,CAAC;IAC7B,SAAa,EAAE,CAAC,KAAK,CAAC,CAAA;CACnB;6BA9ByB,aAAa"}
1
+ {"version":3,"file":"token.d.ts","sourceRoot":"","sources":["token.js"],"names":[],"mappings":"AAOA;;;;;;;GAOG;AAEH;;;;;;GAMG;AAEH;;;;;;;;;GASG;AAEH;;;;;;;;GAQG;AAEH;;;;;;;;;;;GAWG;AAEH;IAwBE;;OAEG;IACH,oFAFW,oBAAoB,EAuB9B;IAED;;;;OAIG;IACH,kBAHa,OAAO,CAAC,MAAM,CAAC,CAkB3B;IAyGD;;OAEG;IACH,wBAGC;IAED;;OAEG;IACH,yBAQC;IAED;;;OAGG;IACH,wBAFa,OAAO,CAInB;IAED;;;OAGG;IACH,gBAFa,MAAM,CAIlB;IAED;;;;OAIG;IACH,mBAFa,MAAM,CAIlB;IAiED;;;OAGG;IACH,WAFa,OAAO,CASnB;IAED;;;OAGG;IACH,gBAFa,MAAM,CAIlB;IAED;;;OAGG;IACH,oBAFa,MAAM,CAKlB;IAED;;;;;OAKG;IACH,WAHa,OAAO,CAAC,mBAAmB,CAAC,CAUxC;;CACF;;;;;cAzXa,MAAM;;;;kBACN,MAAM;;;;iBACN,MAAM;;;;oBACN,qBAAqB;;;;oBACrB,MAAM;;;;;;;oCAQP,CAAC,mBAAmB,EAAE,mBAAmB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;;;;;cAKjE,MAAM;;;;wBACN,MAAM;;;;yBACN,MAAM;;;;sBACN,MAAM;;;;qBACN,MAAM;;;;sBACN,MAAM;;;;mBACN,MAAM;;;;;uCAKP;IACZ,eAAmB,EAAE,EAAE,CAAC;IACxB,iBAAqB,EAAE,CAAC,mBAAmB,CAAC,CAAC;IAC7C,eAAmB,EAAE,CAAC,KAAK,CAAC,CAAC;IAC7B,SAAa,EAAE,CAAC,KAAK,CAAC,CAAA;CACnB;6BA1CyB,aAAa"}
package/lib/token.js CHANGED
@@ -2,14 +2,26 @@ import { EventEmitter } from 'node:events'
2
2
  import { jwtDecode } from 'jwt-decode'
3
3
  import { exchangeToken } from './api-endpoints/auth.js'
4
4
 
5
+ const AUTO_REFRESH_RETRY_BASE_SECONDS = 5
6
+ const AUTO_REFRESH_RETRY_MAX_SECONDS = 300
7
+
5
8
  /**
6
9
  * @typedef {Object} RefreshableTokenOpts
7
10
  * @property {string} clientId - OAuth client ID
8
11
  * @property {string} refreshToken - OAuth refresh token
9
12
  * @property {string} accessToken - Initial OAuth access token (JWT)
13
+ * @property {OnTokenRefreshHandler} onTokenRefresh - A function that will receive the refreshed token info and perisist it for future use
10
14
  * @property {number} [bufferSeconds=30] - Seconds before expiration to consider token expired
11
15
  */
12
16
 
17
+ /**
18
+ * This is a REQUIRED function. It gets called after the token is refreshed with new tokens.
19
+ * It should save the new tokens somewhere. When starting back up, re-use the newly perissted tokens.
20
+ * If you loose track of these new tokens, you will effectively be logged out.
21
+ *
22
+ * @typedef {(refreshSuccessEvent: RefreshSuccessEvent) => void | Promise<void>} OnTokenRefreshHandler
23
+ */
24
+
13
25
  /**
14
26
  * @typedef {Object} RefreshSuccessEvent
15
27
  * @property {string} clientId - The OAuth client ID
@@ -37,12 +49,13 @@ import { exchangeToken } from './api-endpoints/auth.js'
37
49
  *
38
50
  * Events:
39
51
  * - 'refresh:start' - Emitted when token refresh begins
40
- * - 'refresh:success' - Emitted when token refresh succeeds, passes { clientId, accessToken, refreshToken, expiresAt }
52
+ * - 'refresh:success' - Emitted when token refresh succeeds, passes { clientId, updatedAccessToken, updatedRefreshToken, updatedExpiresAt, prevAccessToken, prevRefreshToken, prevExpiresAt }
41
53
  * - 'refresh:error' - Emitted when token refresh fails (transient errors), passes error
42
54
  * - 'invalid' - Emitted when refresh token is permanently invalid, passes error
43
55
  *
44
56
  * @extends {EventEmitter<RefreshableTokenEventMap>}
45
57
  */
58
+
46
59
  export class RefreshableToken extends EventEmitter {
47
60
  /** @type {string} */
48
61
  #clientId
@@ -58,16 +71,25 @@ export class RefreshableToken extends EventEmitter {
58
71
  #invalid = false
59
72
  /** @type {Promise<RefreshSuccessEvent> | null} */
60
73
  #inFlightRefresh = null
74
+ /** @type {boolean} */
75
+ #autoRefreshEnabled = true
76
+ /** @type {ReturnType<typeof setTimeout> | null} */
77
+ #autoRefreshTimeout = null
78
+ /** @type {number} */
79
+ #autoRefreshRetryAttempt = 0
80
+ /** @type {OnTokenRefreshHandler} */
81
+ #onTokenRefresh
61
82
 
62
83
  /**
63
84
  * @param {RefreshableTokenOpts} opts
64
85
  */
65
- constructor ({ clientId, refreshToken, accessToken, bufferSeconds = 30 }) {
86
+ constructor ({ clientId, refreshToken, accessToken, bufferSeconds = 30, onTokenRefresh }) {
66
87
  super()
67
88
  this.#clientId = clientId
68
89
  this.#refreshToken = refreshToken
69
90
  this.#accessToken = accessToken
70
91
  this.#bufferSeconds = bufferSeconds
92
+ this.#onTokenRefresh = onTokenRefresh
71
93
 
72
94
  // Decode the JWT to get expiration
73
95
  try {
@@ -80,6 +102,8 @@ export class RefreshableToken extends EventEmitter {
80
102
  const error = /** @type {Error} */ (err)
81
103
  throw new Error(`Failed to decode access token: ${error.message}`)
82
104
  }
105
+
106
+ this.#scheduleAutoRefresh()
83
107
  }
84
108
 
85
109
  /**
@@ -170,8 +194,13 @@ export class RefreshableToken extends EventEmitter {
170
194
  prevExpiresAt,
171
195
  }
172
196
 
197
+ await this.#onTokenRefresh(eventPayload)
198
+
173
199
  this.emit('refresh:success', eventPayload)
174
200
 
201
+ this.#autoRefreshRetryAttempt = 0
202
+ this.#scheduleAutoRefresh()
203
+
175
204
  return eventPayload
176
205
  } catch (err) {
177
206
  const error = /** @type {any} */ (err)
@@ -188,6 +217,7 @@ export class RefreshableToken extends EventEmitter {
188
217
  if (error.body?.error && invalidRefreshErrors.includes(error.body.error)) {
189
218
  // Mark this token as permanently invalid
190
219
  this.#invalid = true
220
+ this.#clearAutoRefreshTimeout()
191
221
  const statusCode = error.statusCode ? ` (${error.statusCode})` : ''
192
222
  const invalidError = new Error(`Refresh token is invalid or expired${statusCode}: ${error.body.error}${error.body.error_description ? ` - ${error.body.error_description}` : ''}`)
193
223
  this.emit('invalid', invalidError)
@@ -196,10 +226,120 @@ export class RefreshableToken extends EventEmitter {
196
226
 
197
227
  // For other errors, rethrow without marking as invalid (might be transient)
198
228
  this.emit('refresh:error', error)
229
+ this.#scheduleAutoRefreshRetry()
199
230
  throw error
200
231
  }
201
232
  }
202
233
 
234
+ /**
235
+ * Stop background token refresh scheduling.
236
+ */
237
+ stopAutoRefresh () {
238
+ this.#autoRefreshEnabled = false
239
+ this.#clearAutoRefreshTimeout()
240
+ }
241
+
242
+ /**
243
+ * Start background token refresh scheduling (enabled by default).
244
+ */
245
+ startAutoRefresh () {
246
+ if (this.#invalid) {
247
+ return
248
+ }
249
+
250
+ this.#autoRefreshEnabled = true
251
+ this.#autoRefreshRetryAttempt = 0
252
+ this.#scheduleAutoRefresh()
253
+ }
254
+
255
+ /**
256
+ * Check if background token refresh scheduling is enabled.
257
+ * @returns {boolean}
258
+ */
259
+ isAutoRefreshEnabled () {
260
+ return this.#autoRefreshEnabled
261
+ }
262
+
263
+ /**
264
+ * Get the current OAuth client ID (synchronous).
265
+ * @returns {string}
266
+ */
267
+ get clientId () {
268
+ return this.#clientId
269
+ }
270
+
271
+ /**
272
+ * Get the latest access token value (synchronous).
273
+ * Prefer getAccessToken() if you need a guaranteed-valid token in async contexts.
274
+ * @returns {string}
275
+ */
276
+ get accessToken () {
277
+ return this.#accessToken
278
+ }
279
+
280
+ /**
281
+ * Schedule the next refresh for when the token becomes stale (expiresAt - bufferSeconds).
282
+ * Uses an unref'd timer so it doesn't keep the process alive.
283
+ */
284
+ #scheduleAutoRefresh () {
285
+ if (!this.#autoRefreshEnabled || this.#invalid) {
286
+ return
287
+ }
288
+
289
+ const now = Math.floor(Date.now() / 1000)
290
+ const refreshAt = this.#expiresAt - this.#bufferSeconds
291
+ const delayMs = Math.max(0, (refreshAt - now) * 1000)
292
+
293
+ this.#clearAutoRefreshTimeout()
294
+ this.#autoRefreshTimeout = setTimeout(() => {
295
+ this.#autoRefreshTimeout = null
296
+ if (this.#invalid) {
297
+ return
298
+ }
299
+
300
+ this.#refreshAccessToken().catch(() => {
301
+ // Errors are emitted by #performRefresh; retries are scheduled there as well.
302
+ })
303
+ }, delayMs)
304
+
305
+ this.#autoRefreshTimeout.unref?.()
306
+ }
307
+
308
+ #clearAutoRefreshTimeout () {
309
+ if (!this.#autoRefreshTimeout) {
310
+ return
311
+ }
312
+
313
+ clearTimeout(this.#autoRefreshTimeout)
314
+ this.#autoRefreshTimeout = null
315
+ }
316
+
317
+ #scheduleAutoRefreshRetry () {
318
+ if (!this.#autoRefreshEnabled || this.#invalid) {
319
+ return
320
+ }
321
+
322
+ this.#autoRefreshRetryAttempt += 1
323
+ const retrySeconds = Math.min(
324
+ AUTO_REFRESH_RETRY_MAX_SECONDS,
325
+ AUTO_REFRESH_RETRY_BASE_SECONDS * (2 ** (this.#autoRefreshRetryAttempt - 1))
326
+ )
327
+
328
+ this.#clearAutoRefreshTimeout()
329
+ this.#autoRefreshTimeout = setTimeout(() => {
330
+ this.#autoRefreshTimeout = null
331
+ if (this.#invalid) {
332
+ return
333
+ }
334
+
335
+ this.#refreshAccessToken().catch(() => {
336
+ // Errors are emitted by #performRefresh; retries are scheduled there as well.
337
+ })
338
+ }, retrySeconds * 1000)
339
+
340
+ this.#autoRefreshTimeout.unref?.()
341
+ }
342
+
203
343
  /**
204
344
  * Check if the token is currently valid (not expired and not marked invalid).
205
345
  * @returns {boolean} True if token is valid