yoto-nodejs-client 0.0.1

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 (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +736 -0
  3. package/bin/auth.d.ts +3 -0
  4. package/bin/auth.d.ts.map +1 -0
  5. package/bin/auth.js +130 -0
  6. package/bin/content.d.ts +3 -0
  7. package/bin/content.d.ts.map +1 -0
  8. package/bin/content.js +117 -0
  9. package/bin/devices.d.ts +3 -0
  10. package/bin/devices.d.ts.map +1 -0
  11. package/bin/devices.js +239 -0
  12. package/bin/groups.d.ts +3 -0
  13. package/bin/groups.d.ts.map +1 -0
  14. package/bin/groups.js +80 -0
  15. package/bin/icons.d.ts +3 -0
  16. package/bin/icons.d.ts.map +1 -0
  17. package/bin/icons.js +100 -0
  18. package/bin/lib/cli-helpers.d.ts +21 -0
  19. package/bin/lib/cli-helpers.d.ts.map +1 -0
  20. package/bin/lib/cli-helpers.js +140 -0
  21. package/bin/lib/token-helpers.d.ts +14 -0
  22. package/bin/lib/token-helpers.d.ts.map +1 -0
  23. package/bin/lib/token-helpers.js +151 -0
  24. package/bin/refresh-token.d.ts +3 -0
  25. package/bin/refresh-token.d.ts.map +1 -0
  26. package/bin/refresh-token.js +168 -0
  27. package/bin/token-info.d.ts +3 -0
  28. package/bin/token-info.d.ts.map +1 -0
  29. package/bin/token-info.js +351 -0
  30. package/index.d.ts +218 -0
  31. package/index.d.ts.map +1 -0
  32. package/index.js +689 -0
  33. package/lib/api-endpoints/auth.d.ts +56 -0
  34. package/lib/api-endpoints/auth.d.ts.map +1 -0
  35. package/lib/api-endpoints/auth.js +209 -0
  36. package/lib/api-endpoints/auth.test.js +27 -0
  37. package/lib/api-endpoints/constants.d.ts +6 -0
  38. package/lib/api-endpoints/constants.d.ts.map +1 -0
  39. package/lib/api-endpoints/constants.js +31 -0
  40. package/lib/api-endpoints/content.d.ts +275 -0
  41. package/lib/api-endpoints/content.d.ts.map +1 -0
  42. package/lib/api-endpoints/content.js +518 -0
  43. package/lib/api-endpoints/content.test.js +250 -0
  44. package/lib/api-endpoints/devices.d.ts +202 -0
  45. package/lib/api-endpoints/devices.d.ts.map +1 -0
  46. package/lib/api-endpoints/devices.js +404 -0
  47. package/lib/api-endpoints/devices.test.js +483 -0
  48. package/lib/api-endpoints/family-library-groups.d.ts +75 -0
  49. package/lib/api-endpoints/family-library-groups.d.ts.map +1 -0
  50. package/lib/api-endpoints/family-library-groups.js +247 -0
  51. package/lib/api-endpoints/family-library-groups.test.js +272 -0
  52. package/lib/api-endpoints/family.d.ts +39 -0
  53. package/lib/api-endpoints/family.d.ts.map +1 -0
  54. package/lib/api-endpoints/family.js +166 -0
  55. package/lib/api-endpoints/family.test.js +184 -0
  56. package/lib/api-endpoints/helpers.d.ts +29 -0
  57. package/lib/api-endpoints/helpers.d.ts.map +1 -0
  58. package/lib/api-endpoints/helpers.js +104 -0
  59. package/lib/api-endpoints/icons.d.ts +62 -0
  60. package/lib/api-endpoints/icons.d.ts.map +1 -0
  61. package/lib/api-endpoints/icons.js +201 -0
  62. package/lib/api-endpoints/icons.test.js +118 -0
  63. package/lib/api-endpoints/media.d.ts +37 -0
  64. package/lib/api-endpoints/media.d.ts.map +1 -0
  65. package/lib/api-endpoints/media.js +155 -0
  66. package/lib/api-endpoints/test-helpers.d.ts +7 -0
  67. package/lib/api-endpoints/test-helpers.d.ts.map +1 -0
  68. package/lib/api-endpoints/test-helpers.js +64 -0
  69. package/lib/mqtt/client.d.ts +124 -0
  70. package/lib/mqtt/client.d.ts.map +1 -0
  71. package/lib/mqtt/client.js +558 -0
  72. package/lib/mqtt/commands.d.ts +69 -0
  73. package/lib/mqtt/commands.d.ts.map +1 -0
  74. package/lib/mqtt/commands.js +238 -0
  75. package/lib/mqtt/factory.d.ts +12 -0
  76. package/lib/mqtt/factory.d.ts.map +1 -0
  77. package/lib/mqtt/factory.js +107 -0
  78. package/lib/mqtt/index.d.ts +5 -0
  79. package/lib/mqtt/index.d.ts.map +1 -0
  80. package/lib/mqtt/index.js +81 -0
  81. package/lib/mqtt/mqtt.test.js +168 -0
  82. package/lib/mqtt/topics.d.ts +34 -0
  83. package/lib/mqtt/topics.d.ts.map +1 -0
  84. package/lib/mqtt/topics.js +295 -0
  85. package/lib/pkg.cjs +3 -0
  86. package/lib/pkg.d.cts +70 -0
  87. package/lib/pkg.d.cts.map +1 -0
  88. package/lib/token.d.ts +29 -0
  89. package/lib/token.d.ts.map +1 -0
  90. package/lib/token.js +240 -0
  91. package/package.json +91 -0
  92. package/yoto.png +0 -0
@@ -0,0 +1,34 @@
1
+ export function getEventsTopic(deviceId: string): string[];
2
+ export function getStatusTopic(deviceId: string): string[];
3
+ export function getResponseTopic(deviceId: string): string;
4
+ export function getSubscriptionTopics(deviceId: string): string[];
5
+ export function getCommandTopic(deviceId: string, resource: string, action?: string): string;
6
+ export function parseTopic(topic: string): {
7
+ deviceId: string;
8
+ messageType: YotoMqttTopicType | "unknown";
9
+ };
10
+ export function getEventsRequestTopic(deviceId: string): string;
11
+ export function getStatusRequestTopic(deviceId: string): string;
12
+ export function getVolumeSetTopic(deviceId: string): string;
13
+ export function getAmbientsSetTopic(deviceId: string): string;
14
+ export function getSleepTimerSetTopic(deviceId: string): string;
15
+ export function getRebootTopic(deviceId: string): string;
16
+ export function getCardStartTopic(deviceId: string): string;
17
+ export function getCardStopTopic(deviceId: string): string;
18
+ export function getCardPauseTopic(deviceId: string): string;
19
+ export function getCardResumeTopic(deviceId: string): string;
20
+ export function getBluetoothOnTopic(deviceId: string): string;
21
+ export function getBluetoothOffTopic(deviceId: string): string;
22
+ export function getBluetoothDeleteBondsTopic(deviceId: string): string;
23
+ export function getBluetoothConnectTopic(deviceId: string): string;
24
+ export function getBluetoothDisconnectTopic(deviceId: string): string;
25
+ export function getBluetoothStateTopic(deviceId: string): string;
26
+ export function getDisplayPreviewTopic(deviceId: string): string;
27
+ export const MQTT_BROKER_URL: "wss://aqrphjqbp3u2z-ats.iot.eu-west-2.amazonaws.com";
28
+ export const MQTT_AUTH_NAME: "PublicJWTAuthorizer";
29
+ export const MQTT_PORT: 443;
30
+ export const MQTT_PROTOCOL: "wss";
31
+ export const MQTT_KEEPALIVE: 300;
32
+ export const MQTT_ALPN_PROTOCOLS: string[];
33
+ export type YotoMqttTopicType = "events" | "status" | "response";
34
+ //# sourceMappingURL=topics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"topics.d.ts","sourceRoot":"","sources":["topics.js"],"names":[],"mappings":"AAoDA,yCAHW,MAAM,GACJ,MAAM,EAAE,CAOpB;AAQD,yCAHW,MAAM,GACJ,MAAM,EAAE,CAOpB;AAOD,2CAHW,MAAM,GACJ,MAAM,CAIlB;AAcD,gDAHW,MAAM,GACJ,MAAM,EAAE,CAQpB;AASD,0CALW,MAAM,YACN,MAAM,WACN,MAAM,GACJ,MAAM,CAKlB;AAOD,kCAHW,MAAM,GACJ;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,iBAAiB,GAAG,SAAS,CAAA;CAAE,CAuB5E;AASD,gDAHW,MAAM,GACJ,MAAM,CAIlB;AAOD,gDAHW,MAAM,GACJ,MAAM,CAIlB;AAOD,4CAHW,MAAM,GACJ,MAAM,CAIlB;AAOD,8CAHW,MAAM,GACJ,MAAM,CAIlB;AAOD,gDAHW,MAAM,GACJ,MAAM,CAIlB;AAOD,yCAHW,MAAM,GACJ,MAAM,CAIlB;AAOD,4CAHW,MAAM,GACJ,MAAM,CAIlB;AAOD,2CAHW,MAAM,GACJ,MAAM,CAIlB;AAOD,4CAHW,MAAM,GACJ,MAAM,CAIlB;AAOD,6CAHW,MAAM,GACJ,MAAM,CAIlB;AAOD,8CAHW,MAAM,GACJ,MAAM,CAIlB;AAOD,+CAHW,MAAM,GACJ,MAAM,CAIlB;AAOD,uDAHW,MAAM,GACJ,MAAM,CAIlB;AAOD,mDAHW,MAAM,GACJ,MAAM,CAIlB;AAOD,sDAHW,MAAM,GACJ,MAAM,CAIlB;AAOD,iDAHW,MAAM,GACJ,MAAM,CAIlB;AAOD,iDAHW,MAAM,GACJ,MAAM,CAIlB;AAnRD,8BAA+B,qDAAqD,CAAA;AAKpF,6BAA8B,qBAAqB,CAAA;AAKnD,wBAAyB,GAAG,CAAA;AAK5B,4BAA6B,KAAK,CAAA;AAKlC,6BAA8B,GAAG,CAAA;AAKjC,2CAAqD;gCA/BxC,QAAQ,GAAG,QAAQ,GAAG,UAAU"}
@@ -0,0 +1,295 @@
1
+ /**
2
+ * MQTT Topics for Yoto Players
3
+ *
4
+ * Topic builders and constants for Yoto MQTT communication
5
+ * @see https://yoto.dev/players-mqtt/
6
+ */
7
+
8
+ // ============================================================================
9
+ // MQTT Topics: Topic builders and constants
10
+ // ============================================================================
11
+
12
+ /**
13
+ * MQTT topic type for subscriptions
14
+ * @typedef {'events' | 'status' | 'response'} YotoMqttTopicType
15
+ */
16
+
17
+ /**
18
+ * MQTT broker URL for Yoto devices
19
+ */
20
+ export const MQTT_BROKER_URL = 'wss://aqrphjqbp3u2z-ats.iot.eu-west-2.amazonaws.com'
21
+
22
+ /**
23
+ * MQTT authorizer name for Yoto authentication
24
+ */
25
+ export const MQTT_AUTH_NAME = 'PublicJWTAuthorizer'
26
+
27
+ /**
28
+ * MQTT connection port
29
+ */
30
+ export const MQTT_PORT = 443
31
+
32
+ /**
33
+ * MQTT protocol
34
+ */
35
+ export const MQTT_PROTOCOL = 'wss'
36
+
37
+ /**
38
+ * MQTT keepalive interval in seconds
39
+ */
40
+ export const MQTT_KEEPALIVE = 300
41
+
42
+ /**
43
+ * ALPN protocols for AWS IoT
44
+ */
45
+ export const MQTT_ALPN_PROTOCOLS = ['x-amzn-mqtt-ca']
46
+
47
+ /**
48
+ * Get the events topics for a device (both old and new formats)
49
+ * Devices publish to both paths, so both are returned
50
+ * @param {string} deviceId - Device ID
51
+ * @returns {string[]} Events topics [old format, new format]
52
+ */
53
+ export function getEventsTopic (deviceId) {
54
+ return [
55
+ `device/${deviceId}/events`, // Old path (still published)
56
+ `device/${deviceId}/data/events` // New path (also published)
57
+ ]
58
+ }
59
+
60
+ /**
61
+ * Get the status topics for a device (both old and new formats)
62
+ * Devices publish to both paths, so both are returned
63
+ * @param {string} deviceId - Device ID
64
+ * @returns {string[]} Status topics [old format, new format]
65
+ */
66
+ export function getStatusTopic (deviceId) {
67
+ return [
68
+ `device/${deviceId}/status`, // Old path (still published)
69
+ `device/${deviceId}/data/status` // New path (also published)
70
+ ]
71
+ }
72
+
73
+ /**
74
+ * Get the response topic for a device (subscribe)
75
+ * @param {string} deviceId - Device ID
76
+ * @returns {string} Response topic
77
+ */
78
+ export function getResponseTopic (deviceId) {
79
+ return `device/${deviceId}/response`
80
+ }
81
+
82
+ /**
83
+ * Get all subscription topics for a device
84
+ *
85
+ * Subscribes to both old and new topic formats because Yoto devices publish to both:
86
+ * - Old format: device/{id}/events, device/{id}/status
87
+ * - New format: device/{id}/data/events, device/{id}/data/status
88
+ *
89
+ * This ensures all messages are received regardless of which path the device uses.
90
+ *
91
+ * @param {string} deviceId - Device ID
92
+ * @returns {string[]} Array of topics to subscribe to (includes both old and new formats)
93
+ */
94
+ export function getSubscriptionTopics (deviceId) {
95
+ return [
96
+ ...getEventsTopic(deviceId), // Both events topics
97
+ ...getStatusTopic(deviceId), // Both status topics
98
+ getResponseTopic(deviceId) // Response topic (no old/new variants)
99
+ ]
100
+ }
101
+
102
+ /**
103
+ * Get a command topic for a device (publish)
104
+ * @param {string} deviceId - Device ID
105
+ * @param {string} resource - Command resource (e.g., 'volume', 'ambients', 'card')
106
+ * @param {string} [action] - Command action (e.g., 'set', 'start', 'stop')
107
+ * @returns {string} Command topic
108
+ */
109
+ export function getCommandTopic (deviceId, resource, action) {
110
+ const base = `device/${deviceId}/command/${resource}`
111
+ return action ? `${base}/${action}` : base
112
+ }
113
+
114
+ /**
115
+ * Parse a topic string to extract device ID and message type
116
+ * @param {string} topic - Full MQTT topic string
117
+ * @returns {{ deviceId: string, messageType: YotoMqttTopicType | 'unknown' }} Parsed topic info
118
+ */
119
+ export function parseTopic (topic) {
120
+ const parts = topic.split('/')
121
+
122
+ if (parts.length < 3 || parts[0] !== 'device') {
123
+ return { deviceId: '', messageType: 'unknown' }
124
+ }
125
+
126
+ const deviceId = parts[1] || ''
127
+
128
+ // Handle both old format (device/{id}/events) and new format (device/{id}/data/events)
129
+ let messageType = parts[2]
130
+ if (messageType === 'data' && parts.length >= 4) {
131
+ messageType = parts[3] // Get the actual message type after /data/
132
+ }
133
+
134
+ // Validate message type
135
+ if (messageType !== 'events' && messageType !== 'status' && messageType !== 'response') {
136
+ return { deviceId, messageType: 'unknown' }
137
+ }
138
+
139
+ return { deviceId, messageType: /** @type {YotoMqttTopicType} */ (messageType) }
140
+ }
141
+
142
+ // Command topic builders for specific commands
143
+
144
+ /**
145
+ * Get the events request command topic
146
+ * @param {string} deviceId - Device ID
147
+ * @returns {string} Events request topic
148
+ */
149
+ export function getEventsRequestTopic (deviceId) {
150
+ return getCommandTopic(deviceId, 'events', 'request')
151
+ }
152
+
153
+ /**
154
+ * Get the status request command topic
155
+ * @param {string} deviceId - Device ID
156
+ * @returns {string} Status request topic
157
+ */
158
+ export function getStatusRequestTopic (deviceId) {
159
+ return getCommandTopic(deviceId, 'status', 'request')
160
+ }
161
+
162
+ /**
163
+ * Get the volume set command topic
164
+ * @param {string} deviceId - Device ID
165
+ * @returns {string} Volume set topic
166
+ */
167
+ export function getVolumeSetTopic (deviceId) {
168
+ return getCommandTopic(deviceId, 'volume', 'set')
169
+ }
170
+
171
+ /**
172
+ * Get the ambients set command topic
173
+ * @param {string} deviceId - Device ID
174
+ * @returns {string} Ambients set topic
175
+ */
176
+ export function getAmbientsSetTopic (deviceId) {
177
+ return getCommandTopic(deviceId, 'ambients', 'set')
178
+ }
179
+
180
+ /**
181
+ * Get the sleep timer set command topic
182
+ * @param {string} deviceId - Device ID
183
+ * @returns {string} Sleep timer set topic
184
+ */
185
+ export function getSleepTimerSetTopic (deviceId) {
186
+ return getCommandTopic(deviceId, 'sleep-timer', 'set')
187
+ }
188
+
189
+ /**
190
+ * Get the reboot command topic
191
+ * @param {string} deviceId - Device ID
192
+ * @returns {string} Reboot topic
193
+ */
194
+ export function getRebootTopic (deviceId) {
195
+ return getCommandTopic(deviceId, 'reboot')
196
+ }
197
+
198
+ /**
199
+ * Get the card start command topic
200
+ * @param {string} deviceId - Device ID
201
+ * @returns {string} Card start topic
202
+ */
203
+ export function getCardStartTopic (deviceId) {
204
+ return getCommandTopic(deviceId, 'card', 'start')
205
+ }
206
+
207
+ /**
208
+ * Get the card stop command topic
209
+ * @param {string} deviceId - Device ID
210
+ * @returns {string} Card stop topic
211
+ */
212
+ export function getCardStopTopic (deviceId) {
213
+ return getCommandTopic(deviceId, 'card', 'stop')
214
+ }
215
+
216
+ /**
217
+ * Get the card pause command topic
218
+ * @param {string} deviceId - Device ID
219
+ * @returns {string} Card pause topic
220
+ */
221
+ export function getCardPauseTopic (deviceId) {
222
+ return getCommandTopic(deviceId, 'card', 'pause')
223
+ }
224
+
225
+ /**
226
+ * Get the card resume command topic
227
+ * @param {string} deviceId - Device ID
228
+ * @returns {string} Card resume topic
229
+ */
230
+ export function getCardResumeTopic (deviceId) {
231
+ return getCommandTopic(deviceId, 'card', 'resume')
232
+ }
233
+
234
+ /**
235
+ * Get the bluetooth on command topic
236
+ * @param {string} deviceId - Device ID
237
+ * @returns {string} Bluetooth on topic
238
+ */
239
+ export function getBluetoothOnTopic (deviceId) {
240
+ return getCommandTopic(deviceId, 'bluetooth', 'on')
241
+ }
242
+
243
+ /**
244
+ * Get the bluetooth off command topic
245
+ * @param {string} deviceId - Device ID
246
+ * @returns {string} Bluetooth off topic
247
+ */
248
+ export function getBluetoothOffTopic (deviceId) {
249
+ return getCommandTopic(deviceId, 'bluetooth', 'off')
250
+ }
251
+
252
+ /**
253
+ * Get the bluetooth delete bonds command topic
254
+ * @param {string} deviceId - Device ID
255
+ * @returns {string} Bluetooth delete bonds topic
256
+ */
257
+ export function getBluetoothDeleteBondsTopic (deviceId) {
258
+ return getCommandTopic(deviceId, 'bluetooth', 'delete-bonds')
259
+ }
260
+
261
+ /**
262
+ * Get the bluetooth connect command topic
263
+ * @param {string} deviceId - Device ID
264
+ * @returns {string} Bluetooth connect topic
265
+ */
266
+ export function getBluetoothConnectTopic (deviceId) {
267
+ return getCommandTopic(deviceId, 'bluetooth', 'connect')
268
+ }
269
+
270
+ /**
271
+ * Get the bluetooth disconnect command topic
272
+ * @param {string} deviceId - Device ID
273
+ * @returns {string} Bluetooth disconnect topic
274
+ */
275
+ export function getBluetoothDisconnectTopic (deviceId) {
276
+ return getCommandTopic(deviceId, 'bluetooth', 'disconnect')
277
+ }
278
+
279
+ /**
280
+ * Get the bluetooth state command topic
281
+ * @param {string} deviceId - Device ID
282
+ * @returns {string} Bluetooth state topic
283
+ */
284
+ export function getBluetoothStateTopic (deviceId) {
285
+ return getCommandTopic(deviceId, 'bluetooth', 'state')
286
+ }
287
+
288
+ /**
289
+ * Get the display preview command topic
290
+ * @param {string} deviceId - Device ID
291
+ * @returns {string} Display preview topic
292
+ */
293
+ export function getDisplayPreviewTopic (deviceId) {
294
+ return getCommandTopic(deviceId, 'display', 'preview')
295
+ }
package/lib/pkg.cjs ADDED
@@ -0,0 +1,3 @@
1
+ const pkg = require('../package.json')
2
+
3
+ module.exports.pkg = pkg
package/lib/pkg.d.cts ADDED
@@ -0,0 +1,70 @@
1
+ export const pkg: {
2
+ name: string;
3
+ description: string;
4
+ version: string;
5
+ author: string;
6
+ bugs: {
7
+ url: string;
8
+ };
9
+ dependencies: {
10
+ "jwt-decode": string;
11
+ mqtt: string;
12
+ undici: string;
13
+ };
14
+ devDependencies: {
15
+ "@types/node": string;
16
+ "@voxpelli/tsconfig": string;
17
+ argsclopts: string;
18
+ "auto-changelog": string;
19
+ c8: string;
20
+ "gh-release": string;
21
+ neostandard: string;
22
+ "npm-run-all2": string;
23
+ typescript: string;
24
+ };
25
+ engines: {
26
+ node: string;
27
+ npm: string;
28
+ };
29
+ homepage: string;
30
+ keywords: string[];
31
+ license: string;
32
+ type: string;
33
+ module: string;
34
+ main: string;
35
+ types: string;
36
+ files: string[];
37
+ bin: {
38
+ "yoto-auth": string;
39
+ "yoto-token-info": string;
40
+ };
41
+ repository: {
42
+ type: string;
43
+ url: string;
44
+ };
45
+ scripts: {
46
+ prepublishOnly: string;
47
+ postpublish: string;
48
+ test: string;
49
+ "test:lint": string;
50
+ "test:tsc": string;
51
+ "test:node-test": string;
52
+ version: string;
53
+ "version:changelog": string;
54
+ "version:git": string;
55
+ build: string;
56
+ "build:declaration": string;
57
+ clean: string;
58
+ "clean:declarations-top": string;
59
+ "clean:declarations-lib": string;
60
+ "clean:declarations-bin": string;
61
+ };
62
+ funding: {
63
+ type: string;
64
+ url: string;
65
+ };
66
+ c8: {
67
+ reporter: string[];
68
+ };
69
+ };
70
+ //# sourceMappingURL=pkg.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pkg.d.cts","sourceRoot":"","sources":["pkg.cjs"],"names":[],"mappings":""}
package/lib/token.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ export class RefreshableToken extends EventEmitter<RefreshableTokenEventMap> {
2
+ constructor({ clientId, refreshToken, accessToken, bufferSeconds }: RefreshableTokenOpts);
3
+ getAccessToken(): Promise<string>;
4
+ isValid(): boolean;
5
+ getExpiresAt(): number;
6
+ getTimeRemaining(): number;
7
+ refresh(): Promise<RefreshSuccessEvent>;
8
+ #private;
9
+ }
10
+ export type RefreshableTokenOpts = {
11
+ clientId: string;
12
+ refreshToken: string;
13
+ accessToken: string;
14
+ bufferSeconds?: number;
15
+ };
16
+ export type RefreshSuccessEvent = {
17
+ clientId: string;
18
+ accessToken: string;
19
+ refreshToken: string;
20
+ expiresAt: number;
21
+ };
22
+ export type RefreshableTokenEventMap = {
23
+ "refresh:start": [];
24
+ "refresh:success": [RefreshSuccessEvent];
25
+ "refresh:error": [Error];
26
+ "invalid": [Error];
27
+ };
28
+ import { EventEmitter } from 'node:events';
29
+ //# sourceMappingURL=token.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token.d.ts","sourceRoot":"","sources":["token.js"],"names":[],"mappings":"AA0CA;IAmBE,oEAFW,oBAAoB,EAoB9B;IAOD,kBAHa,OAAO,CAAC,MAAM,CAAC,CAiB3B;IA4FD,WAFa,OAAO,CASnB;IAMD,gBAFa,MAAM,CAIlB;IAMD,oBAFa,MAAM,CAKlB;IAQD,WAHa,OAAO,CAAC,mBAAmB,CAAC,CAiBxC;;CACF;;cAzOa,MAAM;kBACN,MAAM;iBACN,MAAM;oBACN,MAAM;;;cAKN,MAAM;iBACN,MAAM;kBACN,MAAM;eACN,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;6BA3ByB,aAAa"}
package/lib/token.js ADDED
@@ -0,0 +1,240 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { jwtDecode } from 'jwt-decode'
3
+ import { exchangeToken } from './api-endpoints/auth.js'
4
+
5
+ /**
6
+ * @typedef {Object} RefreshableTokenOpts
7
+ * @property {string} clientId - OAuth client ID
8
+ * @property {string} refreshToken - OAuth refresh token
9
+ * @property {string} accessToken - Initial OAuth access token (JWT)
10
+ * @property {number} [bufferSeconds=30] - Seconds before expiration to consider token expired
11
+ */
12
+
13
+ /**
14
+ * @typedef {Object} RefreshSuccessEvent
15
+ * @property {string} clientId - The OAuth client ID
16
+ * @property {string} accessToken - The new access token
17
+ * @property {string} refreshToken - The refresh token (may be updated)
18
+ * @property {number} expiresAt - Unix timestamp in seconds when token expires
19
+ */
20
+
21
+ /**
22
+ * Event map for RefreshableToken
23
+ * @typedef {{
24
+ * 'refresh:start': [],
25
+ * 'refresh:success': [RefreshSuccessEvent],
26
+ * 'refresh:error': [Error],
27
+ * 'invalid': [Error]
28
+ * }} RefreshableTokenEventMap
29
+ */
30
+
31
+ /**
32
+ * A refreshable OAuth token that automatically refreshes when expired.
33
+ * Handles in-flight refresh deduplication to prevent multiple concurrent refresh requests.
34
+ *
35
+ * Events:
36
+ * - 'refresh:start' - Emitted when token refresh begins
37
+ * - 'refresh:success' - Emitted when token refresh succeeds, passes { clientId, accessToken, refreshToken, expiresAt }
38
+ * - 'refresh:error' - Emitted when token refresh fails (transient errors), passes error
39
+ * - 'invalid' - Emitted when refresh token is permanently invalid, passes error
40
+ *
41
+ * @extends {EventEmitter<RefreshableTokenEventMap>}
42
+ */
43
+ export class RefreshableToken extends EventEmitter {
44
+ /** @type {string} */
45
+ #clientId
46
+ /** @type {string} */
47
+ #refreshToken
48
+ /** @type {string} */
49
+ #accessToken
50
+ /** @type {number} - Unix timestamp in seconds (from JWT exp claim) */
51
+ #expiresAt
52
+ /** @type {number} - Buffer time in seconds before expiration */
53
+ #bufferSeconds
54
+ /** @type {boolean} */
55
+ #invalid = false
56
+ /** @type {Promise<string> | null} */
57
+ #inFlightRefresh = null
58
+
59
+ /**
60
+ * @param {RefreshableTokenOpts} opts
61
+ */
62
+ constructor ({ clientId, refreshToken, accessToken, bufferSeconds = 30 }) {
63
+ super()
64
+ this.#clientId = clientId
65
+ this.#refreshToken = refreshToken
66
+ this.#accessToken = accessToken
67
+ this.#bufferSeconds = bufferSeconds
68
+
69
+ // Decode the JWT to get expiration
70
+ try {
71
+ const decoded = jwtDecode(accessToken)
72
+ if (!decoded.exp) {
73
+ throw new Error('Access token does not contain expiration claim (exp)')
74
+ }
75
+ this.#expiresAt = decoded.exp
76
+ } catch (err) {
77
+ const error = /** @type {Error} */ (err)
78
+ throw new Error(`Failed to decode access token: ${error.message}`)
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Get a valid access token, refreshing if necessary.
84
+ * @returns {Promise<string>} Valid access token
85
+ * @throws {Error} If token is invalid or refresh fails
86
+ */
87
+ async getAccessToken () {
88
+ // Check if token has been marked invalid
89
+ if (this.#invalid) {
90
+ throw new Error('Token is invalid. Refresh token has expired or been revoked.')
91
+ }
92
+
93
+ const now = Math.floor(Date.now() / 1000)
94
+ const needsRefresh = now >= (this.#expiresAt - this.#bufferSeconds)
95
+
96
+ if (!needsRefresh) {
97
+ return this.#accessToken
98
+ }
99
+
100
+ return await this.#refreshAccessToken()
101
+ }
102
+
103
+ /**
104
+ * Refresh the access token using the refresh token.
105
+ * Handles in-flight request deduplication.
106
+ * @returns {Promise<string>} New access token
107
+ */
108
+ async #refreshAccessToken () {
109
+ // If a refresh is already in flight, await the existing one
110
+ if (this.#inFlightRefresh) {
111
+ return await this.#inFlightRefresh
112
+ }
113
+
114
+ // Create the refresh promise and set it on the class variable
115
+ try {
116
+ this.#inFlightRefresh = this.#performRefresh()
117
+ const newAccessToken = await this.#inFlightRefresh
118
+ return newAccessToken
119
+ } finally {
120
+ // Clear the in-flight refresh once done
121
+ this.#inFlightRefresh = null
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Perform the actual token refresh.
127
+ * @returns {Promise<string>} New access token
128
+ */
129
+ async #performRefresh () {
130
+ this.emit('refresh:start')
131
+
132
+ try {
133
+ const tokens = await exchangeToken({
134
+ grantType: 'refresh_token',
135
+ refreshToken: this.#refreshToken,
136
+ clientId: this.#clientId
137
+ })
138
+
139
+ // Update the access token and expiration
140
+ this.#accessToken = tokens.access_token
141
+
142
+ // If we got a new refresh token, update it
143
+ if (tokens.refresh_token) {
144
+ this.#refreshToken = tokens.refresh_token
145
+ }
146
+
147
+ // Decode the new token to get expiration
148
+ const decoded = jwtDecode(tokens.access_token)
149
+ if (!decoded.exp) {
150
+ throw new Error('Refreshed access token does not contain expiration claim (exp)')
151
+ }
152
+ this.#expiresAt = decoded.exp
153
+
154
+ this.emit('refresh:success', {
155
+ clientId: this.#clientId,
156
+ accessToken: this.#accessToken,
157
+ refreshToken: this.#refreshToken,
158
+ expiresAt: this.#expiresAt
159
+ })
160
+
161
+ return this.#accessToken
162
+ } catch (err) {
163
+ const error = /** @type {any} */ (err)
164
+
165
+ // Check for errors that indicate the refresh token is invalid
166
+ const invalidRefreshErrors = [
167
+ 'invalid_grant',
168
+ 'invalid_token',
169
+ 'expired_token',
170
+ 'token_expired',
171
+ 'refresh_token_expired'
172
+ ]
173
+
174
+ if (error.body?.error && invalidRefreshErrors.includes(error.body.error)) {
175
+ // Mark this token as permanently invalid
176
+ this.#invalid = true
177
+ const statusCode = error.statusCode ? ` (${error.statusCode})` : ''
178
+ const invalidError = new Error(`Refresh token is invalid or expired${statusCode}: ${error.body.error}${error.body.error_description ? ` - ${error.body.error_description}` : ''}`)
179
+ this.emit('invalid', invalidError)
180
+ throw invalidError
181
+ }
182
+
183
+ // For other errors, rethrow without marking as invalid (might be transient)
184
+ this.emit('refresh:error', error)
185
+ throw error
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Check if the token is currently valid (not expired and not marked invalid).
191
+ * @returns {boolean} True if token is valid
192
+ */
193
+ isValid () {
194
+ if (this.#invalid) {
195
+ return false
196
+ }
197
+
198
+ const now = Math.floor(Date.now() / 1000)
199
+ return now < (this.#expiresAt - this.#bufferSeconds)
200
+ }
201
+
202
+ /**
203
+ * Get the expiration timestamp of the current access token.
204
+ * @returns {number} Unix timestamp (seconds since epoch)
205
+ */
206
+ getExpiresAt () {
207
+ return this.#expiresAt
208
+ }
209
+
210
+ /**
211
+ * Get the time remaining until token expiration.
212
+ * @returns {number} Seconds until expiration (may be negative if expired)
213
+ */
214
+ getTimeRemaining () {
215
+ const now = Math.floor(Date.now() / 1000)
216
+ return this.#expiresAt - now
217
+ }
218
+
219
+ /**
220
+ * Manually trigger a token refresh, regardless of expiration status.
221
+ * Useful for proactive refresh or testing.
222
+ * @returns {Promise<RefreshSuccessEvent>} Token information including clientId, accessToken, refreshToken, and expiresAt
223
+ * @throws {Error} If token is invalid or refresh fails
224
+ */
225
+ async refresh () {
226
+ // Check if token has been marked invalid
227
+ if (this.#invalid) {
228
+ throw new Error('Token is invalid. Refresh token has expired or been revoked.')
229
+ }
230
+
231
+ await this.#refreshAccessToken()
232
+
233
+ return {
234
+ clientId: this.#clientId,
235
+ accessToken: this.#accessToken,
236
+ refreshToken: this.#refreshToken,
237
+ expiresAt: this.#expiresAt
238
+ }
239
+ }
240
+ }