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.
- package/README.md +24 -6
- package/bin/auth.js +4 -3
- package/bin/device-model.js +25 -5
- package/bin/device-tui.js +25 -3
- package/bin/devices.js +58 -42
- package/bin/lib/cli-helpers.d.ts.map +1 -1
- package/bin/lib/cli-helpers.js +3 -1
- package/bin/lib/token-helpers.d.ts +4 -2
- package/bin/lib/token-helpers.d.ts.map +1 -1
- package/bin/lib/token-helpers.js +9 -8
- package/bin/refresh-token.js +4 -2
- package/bin/token-info.js +2 -2
- package/lib/api-client.d.ts +11 -10
- package/lib/api-client.d.ts.map +1 -1
- package/lib/api-client.js +12 -14
- package/lib/api-endpoints/auth.test.js +4 -4
- package/lib/api-endpoints/content.test.js +32 -32
- package/lib/api-endpoints/devices.d.ts +4 -4
- package/lib/api-endpoints/devices.js +2 -2
- package/lib/api-endpoints/devices.test.js +45 -45
- package/lib/api-endpoints/endpoint-test-helpers.d.ts +3 -4
- package/lib/api-endpoints/endpoint-test-helpers.d.ts.map +1 -1
- package/lib/api-endpoints/endpoint-test-helpers.js +21 -5
- package/lib/api-endpoints/family-library-groups.d.ts +3 -3
- package/lib/api-endpoints/family-library-groups.d.ts.map +1 -1
- package/lib/api-endpoints/family-library-groups.js +3 -3
- package/lib/api-endpoints/family-library-groups.test.js +29 -29
- package/lib/api-endpoints/family.test.js +11 -11
- package/lib/api-endpoints/icons.test.js +14 -14
- package/lib/mqtt/client.d.ts +124 -49
- package/lib/mqtt/client.d.ts.map +1 -1
- package/lib/mqtt/client.js +132 -50
- package/lib/mqtt/factory.d.ts +12 -5
- package/lib/mqtt/factory.d.ts.map +1 -1
- package/lib/mqtt/factory.js +39 -11
- package/lib/mqtt/index.js +2 -1
- package/lib/mqtt/mqtt.test.js +25 -22
- package/lib/token.d.ts +44 -2
- package/lib/token.d.ts.map +1 -1
- package/lib/token.js +142 -2
- package/lib/yoto-device.d.ts +392 -32
- package/lib/yoto-device.d.ts.map +1 -1
- package/lib/yoto-device.js +643 -99
- package/lib/yoto-device.test.js +193 -0
- package/package.json +1 -1
package/lib/mqtt/factory.js
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
85
|
-
throw new Error('
|
|
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
|
-
|
|
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
|
|
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
package/lib/mqtt/mqtt.test.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
50
|
+
assert.equal(typeof payload.playbackStatus, 'string', 'playbackStatus should be string')
|
|
48
51
|
}
|
|
49
52
|
if (payload.cardId !== undefined) {
|
|
50
|
-
assert.
|
|
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.
|
|
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.
|
|
69
|
-
assert.
|
|
70
|
-
assert.
|
|
71
|
-
assert.
|
|
72
|
-
assert.
|
|
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.
|
|
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.
|
|
92
|
-
assert.
|
|
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.
|
|
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.
|
|
104
|
+
assert.equal(typeof payload.status.upTime, 'number', 'upTime should be number')
|
|
102
105
|
}
|
|
103
106
|
if (payload.status.utcTime !== undefined) {
|
|
104
|
-
assert.
|
|
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.
|
|
112
|
+
assert.equal(typeof payload.status.battery, 'number', 'battery should be number')
|
|
110
113
|
}
|
|
111
114
|
if (payload.status.wifiStrength !== undefined) {
|
|
112
|
-
assert.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
package/lib/token.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"token.d.ts","sourceRoot":"","sources":["token.js"],"names":[],"mappings":"
|
|
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,
|
|
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
|