@openfin/cloud-interop-core-api 0.0.1-alpha.e6793f0 → 0.0.1-alpha.e6a1a71
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 +2 -2
- package/bundle.d.ts +883 -0
- package/index.cjs +758 -0
- package/index.mjs +754 -0
- package/package.json +16 -229
- package/dist/api.d.ts +0 -70
- package/dist/errors/api.error.d.ts +0 -7
- package/dist/index.cjs +0 -255
- package/dist/index.d.ts +0 -3
- package/dist/index.mjs +0 -4017
- package/dist/interfaces.d.ts +0 -133
package/index.cjs
ADDED
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var buffer = require('buffer');
|
|
4
|
+
var mqtt = require('mqtt');
|
|
5
|
+
|
|
6
|
+
var l=t=>{let e=t.replaceAll("-","+").replaceAll("_","/");return e.padEnd(e.length+(4-e.length%4)%4,"=")};
|
|
7
|
+
|
|
8
|
+
class CloudInteropAPIError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(message = 'An unexpected error has occurred', code = 'UNEXPECTED_ERROR', cause) {
|
|
11
|
+
super(message, { cause: cause });
|
|
12
|
+
this.name = this.constructor.name;
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
class AuthorizationError extends CloudInteropAPIError {
|
|
17
|
+
constructor(message = 'Not authorized', code = 'ERR_UNAUTHORIZED') {
|
|
18
|
+
super(message, code, undefined);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class EventController {
|
|
23
|
+
#eventListeners = new Map();
|
|
24
|
+
addEventListener(type, callback) {
|
|
25
|
+
const listeners = this.#eventListeners.get(type) || [];
|
|
26
|
+
listeners.push(callback);
|
|
27
|
+
this.#eventListeners.set(type, listeners);
|
|
28
|
+
}
|
|
29
|
+
removeEventListener(type, callback) {
|
|
30
|
+
const listeners = this.#eventListeners.get(type) || [];
|
|
31
|
+
const index = listeners.indexOf(callback);
|
|
32
|
+
if (index !== -1) {
|
|
33
|
+
listeners.splice(index, 1);
|
|
34
|
+
}
|
|
35
|
+
this.#eventListeners.set(type, listeners);
|
|
36
|
+
}
|
|
37
|
+
once(type, callback) {
|
|
38
|
+
const listener = (...args) => {
|
|
39
|
+
this.removeEventListener(type, listener);
|
|
40
|
+
// @ts-expect-error - TS doesn't like the spread operator here
|
|
41
|
+
callback(...args);
|
|
42
|
+
};
|
|
43
|
+
this.addEventListener(type, listener);
|
|
44
|
+
}
|
|
45
|
+
emitEvent(type, ...args) {
|
|
46
|
+
const listeners = this.#eventListeners.get(type) || [];
|
|
47
|
+
listeners.forEach((listener) => listener(...args));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const isErrorIntentResult = (result) => 'error' in result;
|
|
52
|
+
|
|
53
|
+
const APP_ID_DELIM = '::';
|
|
54
|
+
const getRequestHeaders = (connectionParameters) => {
|
|
55
|
+
const headers = {};
|
|
56
|
+
headers['Content-Type'] = 'application/json';
|
|
57
|
+
if (connectionParameters.authenticationType === 'jwt' && connectionParameters.jwtAuthenticationParameters) {
|
|
58
|
+
const tokenResult = connectionParameters.jwtAuthenticationParameters.jwtRequestCallback();
|
|
59
|
+
if (!tokenResult) {
|
|
60
|
+
throw new Error('jwtRequestCallback must return a token');
|
|
61
|
+
}
|
|
62
|
+
headers['x-of-auth-id'] = connectionParameters.jwtAuthenticationParameters.authenticationId;
|
|
63
|
+
headers['Authorization'] =
|
|
64
|
+
typeof tokenResult === 'string' ? `Bearer ${tokenResult}` : `Bearer ${buffer.Buffer.from(JSON.stringify(tokenResult)).toString('base64')}`;
|
|
65
|
+
}
|
|
66
|
+
if (connectionParameters.authenticationType === 'basic' && connectionParameters.basicAuthenticationParameters) {
|
|
67
|
+
const { username, password } = connectionParameters.basicAuthenticationParameters;
|
|
68
|
+
headers['Authorization'] = `Basic ${buffer.Buffer.from(`${username}:${password}`).toString('base64')}`;
|
|
69
|
+
}
|
|
70
|
+
return headers;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Encodes all app intents in the format: `appId::sourceId::sessionId`,
|
|
74
|
+
* where sourceId and sessionId are URI encoded
|
|
75
|
+
* @param appIntents
|
|
76
|
+
* @param source
|
|
77
|
+
* @returns
|
|
78
|
+
*/
|
|
79
|
+
const encodeAppIntents = (appIntents, source) => appIntents.map((intent) => ({
|
|
80
|
+
...intent,
|
|
81
|
+
apps: intent.apps.map((app) => ({ ...app, appId: encodeAppId(app.appId, source) })),
|
|
82
|
+
}));
|
|
83
|
+
/**
|
|
84
|
+
* Decodes all app intents by URI decoding the parts previously encoded by `encodeAppIntents`
|
|
85
|
+
* @param appIntents
|
|
86
|
+
* @returns
|
|
87
|
+
*/
|
|
88
|
+
const decodeAppIntents = (appIntents) => appIntents.map((intent) => ({
|
|
89
|
+
...intent,
|
|
90
|
+
apps: intent.apps.map((app) => ({ ...app, appId: decodeAppId(app.appId) })),
|
|
91
|
+
}));
|
|
92
|
+
const encodeAppId = (appIdString, { sessionId, sourceId }) => {
|
|
93
|
+
const id = encodeURIComponent(appIdString);
|
|
94
|
+
const sId = encodeURIComponent(sourceId);
|
|
95
|
+
return `${id}${APP_ID_DELIM}${sId}${APP_ID_DELIM}${sessionId}`;
|
|
96
|
+
};
|
|
97
|
+
const decodeAppId = (appId) => {
|
|
98
|
+
const [encodedAppId, encodedSourceId, sessionId] = appId.split(APP_ID_DELIM);
|
|
99
|
+
const id = decodeURIComponent(encodedAppId);
|
|
100
|
+
const sourceId = decodeURIComponent(encodedSourceId);
|
|
101
|
+
return `${id}${APP_ID_DELIM}${sourceId}${APP_ID_DELIM}${sessionId}`;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Decodes the AppIdentifier to extract the appId, sourceId, and sessionId.
|
|
105
|
+
* @returns an object with:
|
|
106
|
+
* - appId: The appId, or the original appId if unable to parse.
|
|
107
|
+
* - sourceId: The sourceId, or an '' if unable to parse.
|
|
108
|
+
* - sessionId: The sessionId, or an '' if unable to parse.
|
|
109
|
+
*/
|
|
110
|
+
const parseCloudAppId = (appId = '') => {
|
|
111
|
+
const originalAppString = typeof appId === 'string' ? appId : (appId.appId ?? '');
|
|
112
|
+
const parts = originalAppString.split(APP_ID_DELIM);
|
|
113
|
+
return {
|
|
114
|
+
appId: parts[0]?.trim() ?? originalAppString,
|
|
115
|
+
sourceId: parts[1]?.trim() ?? '',
|
|
116
|
+
sessionId: parts[2]?.trim() ?? '',
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
const getSourceFromSession = (sessionDetails) => ({
|
|
120
|
+
sessionId: sessionDetails.sessionId,
|
|
121
|
+
sourceId: sessionDetails.sourceId,
|
|
122
|
+
userId: sessionDetails.sub,
|
|
123
|
+
orgId: sessionDetails.orgId,
|
|
124
|
+
platformId: sessionDetails.platformId,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const MIN_TIMEOUT = 500;
|
|
128
|
+
const DEFAULT_TIMEOUT = 3000;
|
|
129
|
+
const newDiscovery = () => ({
|
|
130
|
+
id: undefined,
|
|
131
|
+
pendingIntentDetailsEvents: [],
|
|
132
|
+
sessionCount: 0,
|
|
133
|
+
responseCount: 0,
|
|
134
|
+
state: 'not-started',
|
|
135
|
+
});
|
|
136
|
+
class IntentController {
|
|
137
|
+
#url;
|
|
138
|
+
#mqttClient;
|
|
139
|
+
#sessionDetails;
|
|
140
|
+
#connectionParams;
|
|
141
|
+
#events;
|
|
142
|
+
#logger;
|
|
143
|
+
#discovery = newDiscovery();
|
|
144
|
+
#discoveryTimeout;
|
|
145
|
+
constructor(url, mqttClient, sessionDetails, connectionParameters, events, logger) {
|
|
146
|
+
this.#url = url;
|
|
147
|
+
this.#mqttClient = mqttClient;
|
|
148
|
+
this.#sessionDetails = sessionDetails;
|
|
149
|
+
this.#connectionParams = connectionParameters;
|
|
150
|
+
this.#events = events;
|
|
151
|
+
this.#logger = logger;
|
|
152
|
+
}
|
|
153
|
+
async startIntentDiscovery(options) {
|
|
154
|
+
if (this.#discovery.state === 'in-progress') {
|
|
155
|
+
throw new Error('Intent discovery already in progress');
|
|
156
|
+
}
|
|
157
|
+
const { timeout = DEFAULT_TIMEOUT, findOptions } = options;
|
|
158
|
+
// clamp min value to 500ms
|
|
159
|
+
const clampedTimeout = Math.max(timeout, MIN_TIMEOUT);
|
|
160
|
+
try {
|
|
161
|
+
const startResponse = await fetch(`${this.#url}/api/intents/${this.#sessionDetails.sessionId}`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: getRequestHeaders(this.#connectionParams),
|
|
164
|
+
body: JSON.stringify({ findOptions }),
|
|
165
|
+
});
|
|
166
|
+
if (!startResponse.ok) {
|
|
167
|
+
throw new Error(`Error creating intent discovery record: ${startResponse.statusText}`);
|
|
168
|
+
}
|
|
169
|
+
// TODO: type this response?
|
|
170
|
+
const json = await startResponse.json();
|
|
171
|
+
this.#discovery.id = json.discoveryId;
|
|
172
|
+
this.#discovery.sessionCount = json.sessionCount;
|
|
173
|
+
this.#discovery.state = 'in-progress';
|
|
174
|
+
if (this.#discovery.sessionCount === 1) {
|
|
175
|
+
// since we have no other connected sessions, we can end discovery immediately
|
|
176
|
+
await this.#endIntentDiscovery(false);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// Listen out for discovery results directly sent to us
|
|
180
|
+
await this.#mqttClient.subscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`);
|
|
181
|
+
this.#discoveryTimeout = setTimeout(() => this.#endIntentDiscovery(), clampedTimeout);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
// Clean up any ongoing discoveries
|
|
185
|
+
this.#endIntentDiscovery();
|
|
186
|
+
throw new CloudInteropAPIError('Error starting intent discovery', 'ERR_STARTING_INTENT_DISCOVERY', error);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async #endIntentDiscovery(mqttUnsubscribe = true) {
|
|
190
|
+
if (this.#discovery.state !== 'in-progress') {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (this.#discoveryTimeout) {
|
|
194
|
+
clearTimeout(this.#discoveryTimeout);
|
|
195
|
+
this.#discoveryTimeout = undefined;
|
|
196
|
+
}
|
|
197
|
+
this.#discovery.state = 'ended';
|
|
198
|
+
// emit our aggregated events
|
|
199
|
+
this.#events.emitEvent('aggregate-intent-details', { responses: this.#discovery.pendingIntentDetailsEvents });
|
|
200
|
+
if (mqttUnsubscribe) {
|
|
201
|
+
// gracefully end discovery
|
|
202
|
+
await this.#mqttClient.unsubscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`).catch(() => {
|
|
203
|
+
this.#logger('warn', `Error ending intent discovery: could not unsubscribe from discovery id ${this.#discovery.id}`);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
await fetch(`${this.#url}/api/intents/${this.#sessionDetails.sessionId}/${this.#discovery.id}`, {
|
|
207
|
+
method: 'DELETE',
|
|
208
|
+
headers: getRequestHeaders(this.#connectionParams),
|
|
209
|
+
})
|
|
210
|
+
.then((deleteResponse) => {
|
|
211
|
+
if (!deleteResponse.ok) {
|
|
212
|
+
throw new Error(`Error ending intent discovery: ${deleteResponse.statusText}`);
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
.catch((error) => {
|
|
216
|
+
this.#logger('warn', `Error ending intent discovery: ${error}`);
|
|
217
|
+
});
|
|
218
|
+
// clean up
|
|
219
|
+
this.#discovery = newDiscovery();
|
|
220
|
+
}
|
|
221
|
+
async raiseIntent({ raiseOptions, appId }) {
|
|
222
|
+
const targetSessionId = parseCloudAppId(appId).sessionId;
|
|
223
|
+
if (!targetSessionId) {
|
|
224
|
+
// TODO: should we add more info here about the format?
|
|
225
|
+
throw new CloudInteropAPIError(`Invalid AppId specified, must be encoded as a cloud-session app id`, 'ERR_INVALID_TARGET_SESSION_ID');
|
|
226
|
+
}
|
|
227
|
+
const postResponse = await fetch(`${this.#url}/api/intents/${this.#sessionDetails.sessionId}/sessions/${targetSessionId}`, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: getRequestHeaders(this.#connectionParams),
|
|
230
|
+
body: JSON.stringify({ raiseOptions }),
|
|
231
|
+
});
|
|
232
|
+
if (!postResponse.ok) {
|
|
233
|
+
// TODO: maybe add a debug flag to print these when dev'ing?
|
|
234
|
+
// console.log(`Error raising intent: ${await postResponse.text()}`);
|
|
235
|
+
throw new CloudInteropAPIError(`Error raising intent`, 'ERR_RAISING_INTENT', new Error(postResponse.statusText));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async reportAppIntents(discoveryId, intents) {
|
|
239
|
+
intents = encodeAppIntents(intents, getSourceFromSession(this.#sessionDetails));
|
|
240
|
+
try {
|
|
241
|
+
const reportResponse = await fetch(`${this.#url}/api/intents/${this.#sessionDetails.sessionId}/${discoveryId}`, {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: getRequestHeaders(this.#connectionParams),
|
|
244
|
+
body: JSON.stringify({ intents }),
|
|
245
|
+
});
|
|
246
|
+
if (reportResponse.ok) {
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
throw new CloudInteropAPIError('Error reporting intents', 'ERR_REPORTING_INTENTS', new Error(reportResponse.statusText));
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
this.#logger('warn', `Error reporting intents for discovery ID ${discoveryId}: ${error}`);
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
async sendIntentResult(initiatingSessionId, result) {
|
|
257
|
+
if (!isErrorIntentResult(result)) {
|
|
258
|
+
// cloud-encode the source app id to support chained intent actions over cloud
|
|
259
|
+
// https://fdc3.finos.org/docs/2.0/api/spec#resolution-object -> "Use metadata about the resolving app instance to target a further intent"
|
|
260
|
+
const source = getSourceFromSession(this.#sessionDetails);
|
|
261
|
+
const encoded = encodeAppId(typeof result.source === 'string' ? result.source : result.source.appId, source);
|
|
262
|
+
result.source = typeof result.source === 'string' ? encoded : { ...result.source, appId: encoded };
|
|
263
|
+
}
|
|
264
|
+
const { sessionId } = getSourceFromSession(this.#sessionDetails);
|
|
265
|
+
const resultResponse = await fetch(`${this.#url}/api/intents/${initiatingSessionId}/result/${sessionId}`, {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
headers: getRequestHeaders(this.#connectionParams),
|
|
268
|
+
body: JSON.stringify({ result }),
|
|
269
|
+
});
|
|
270
|
+
if (!resultResponse.ok) {
|
|
271
|
+
throw new CloudInteropAPIError('Error sending intent result', 'ERR_SENDING_INTENT_RESULT', new Error(resultResponse.statusText));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
handleCommandMessage(message) {
|
|
275
|
+
switch (message.command) {
|
|
276
|
+
case 'report-intents': {
|
|
277
|
+
if (message.initiatingSessionId === this.#sessionDetails?.sessionId) {
|
|
278
|
+
// Ignore if this originated from us
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const { command: _, ...event } = message;
|
|
282
|
+
this.#events.emitEvent('report-intents', event);
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
case 'intent-details': {
|
|
286
|
+
/**
|
|
287
|
+
* We aggregate intent details commands from all connected sessions on the server side
|
|
288
|
+
* to ensure upstream clients don't have to do this. Also given @openfin/cloud-interop
|
|
289
|
+
* exposes FDC3 compliant APIs, there is no concept of streaming available, hence we
|
|
290
|
+
* aggregate the responses here and send them in a synchronous manner.
|
|
291
|
+
*/
|
|
292
|
+
const { command: _, ...event } = message;
|
|
293
|
+
// Decode intents before emitting to client
|
|
294
|
+
event.intents = decodeAppIntents(event.intents);
|
|
295
|
+
// always emit individual intent-details events in addition to aggregate-intent-details
|
|
296
|
+
// for flexibility after intent discovery has ended, this can be useful for late-joining
|
|
297
|
+
// clients nearing the timeout
|
|
298
|
+
this.#events.emitEvent('intent-details', event);
|
|
299
|
+
if (message.discoveryId !== this.#discovery.id || this.#discovery.state !== 'in-progress') {
|
|
300
|
+
// Ignore if its any other discovery id for some reason, or
|
|
301
|
+
// if we're not in the middle of a discovery
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
this.#discovery.responseCount += 1;
|
|
305
|
+
this.#logger('debug', `Received intent details from ${message.source.sessionId}, received: ${this.#discovery.responseCount}, out of connected sessions: ${this.#discovery.sessionCount - 1}`);
|
|
306
|
+
this.#discovery.pendingIntentDetailsEvents.push(event);
|
|
307
|
+
const allResponded = this.#discovery.responseCount === this.#discovery.sessionCount - 1;
|
|
308
|
+
if (allResponded) {
|
|
309
|
+
this.#endIntentDiscovery();
|
|
310
|
+
}
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
case 'raise-intent': {
|
|
314
|
+
if (message.targetSessionId === this.#sessionDetails?.sessionId) {
|
|
315
|
+
const { command: _, ...event } = message;
|
|
316
|
+
this.#events.emitEvent('raise-intent', event);
|
|
317
|
+
}
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
case 'intent-result': {
|
|
321
|
+
if (message.initiatingSessionId === this.#sessionDetails?.sessionId) {
|
|
322
|
+
// Return result to originator and end discovery
|
|
323
|
+
const { command: _, ...resultEvent } = message;
|
|
324
|
+
this.#events.emitEvent('intent-result', resultEvent);
|
|
325
|
+
}
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
default: {
|
|
329
|
+
this.#logger('warn', `Unknown command message received:\n${JSON.stringify(message, null, 2)}`);
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Error codes as defined in https://docs.emqx.com/en/cloud/latest/connect_to_deployments/mqtt_client_error_codes.html
|
|
337
|
+
const BadUserNamePasswordError = 134;
|
|
338
|
+
/**
|
|
339
|
+
* Represents a single connection to a Cloud Interop service
|
|
340
|
+
*
|
|
341
|
+
* @public
|
|
342
|
+
* @class
|
|
343
|
+
*/
|
|
344
|
+
class CloudInteropAPI {
|
|
345
|
+
#cloudInteropSettings;
|
|
346
|
+
#sessionDetails;
|
|
347
|
+
#mqttClient;
|
|
348
|
+
#reconnectRetryLimit = 30;
|
|
349
|
+
#keepAliveIntervalSeconds = 30;
|
|
350
|
+
#logger = (level, message) => {
|
|
351
|
+
console[level](message);
|
|
352
|
+
};
|
|
353
|
+
#reconnectRetries = 0;
|
|
354
|
+
#connectionParams;
|
|
355
|
+
#attemptingToReconnect = false;
|
|
356
|
+
#events = new EventController();
|
|
357
|
+
#intents;
|
|
358
|
+
#sessionTimer;
|
|
359
|
+
constructor(cloudInteropSettings) {
|
|
360
|
+
this.#cloudInteropSettings = cloudInteropSettings;
|
|
361
|
+
}
|
|
362
|
+
get sessionDetails() {
|
|
363
|
+
return this.#sessionDetails;
|
|
364
|
+
}
|
|
365
|
+
get mqttClient() {
|
|
366
|
+
return this.#mqttClient;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Connects and creates a session on the Cloud Interop service
|
|
370
|
+
*
|
|
371
|
+
* @param parameters - The parameters to use to connect
|
|
372
|
+
* @returns Promise that resolves when connection is established
|
|
373
|
+
* @memberof CloudInteropAPI
|
|
374
|
+
* @throws CloudInteropAPIError - If an error occurs during connection
|
|
375
|
+
* @throws AuthorizationError - If the connection is unauthorized
|
|
376
|
+
*/
|
|
377
|
+
async connect(parameters) {
|
|
378
|
+
this.#validateConnectParams(parameters);
|
|
379
|
+
this.#connectionParams = parameters;
|
|
380
|
+
this.#reconnectRetryLimit = parameters.reconnectRetryLimit || this.#reconnectRetryLimit;
|
|
381
|
+
this.#keepAliveIntervalSeconds = parameters.keepAliveIntervalSeconds || this.#keepAliveIntervalSeconds;
|
|
382
|
+
this.#logger = parameters.logger || this.#logger;
|
|
383
|
+
const { sourceId, platformId } = this.#connectionParams;
|
|
384
|
+
const createSessionResponse = await fetch(`${this.#cloudInteropSettings.url}/api/sessions`, {
|
|
385
|
+
method: 'POST',
|
|
386
|
+
headers: getRequestHeaders(this.#connectionParams),
|
|
387
|
+
body: JSON.stringify({ sourceId: sourceId.trim(), platformId }),
|
|
388
|
+
});
|
|
389
|
+
if (!createSessionResponse.ok) {
|
|
390
|
+
if (createSessionResponse.status === 401 || createSessionResponse.status === 403) {
|
|
391
|
+
throw new AuthorizationError();
|
|
392
|
+
}
|
|
393
|
+
throw new CloudInteropAPIError();
|
|
394
|
+
}
|
|
395
|
+
if (createSessionResponse.status !== 201) {
|
|
396
|
+
throw new CloudInteropAPIError(`Failed to connect to the Cloud Interop service: ${this.#cloudInteropSettings.url}`, 'ERR_CONNECT', new Error(createSessionResponse.statusText));
|
|
397
|
+
}
|
|
398
|
+
this.#sessionDetails = (await createSessionResponse.json());
|
|
399
|
+
// If local session expiry handling is enabled, start the session timer
|
|
400
|
+
if (this.#sessionDetails.localSessionExpiryHandling) {
|
|
401
|
+
this.#logger('debug', `Local session expiry handling is enabled`);
|
|
402
|
+
this.#startSessionTimer();
|
|
403
|
+
}
|
|
404
|
+
const sessionRootTopic = this.#sessionDetails.sessionRootTopic;
|
|
405
|
+
const clientOptions = {
|
|
406
|
+
keepalive: this.#keepAliveIntervalSeconds,
|
|
407
|
+
clientId: this.#sessionDetails.sessionId,
|
|
408
|
+
clean: true,
|
|
409
|
+
protocolVersion: 5,
|
|
410
|
+
// The "will" message will be published on an unexpected disconnection
|
|
411
|
+
// The server can then tidy up. So it needs every for this client to do that, the session details is perfect
|
|
412
|
+
will: {
|
|
413
|
+
topic: 'interop/lastwill',
|
|
414
|
+
payload: buffer.Buffer.from(JSON.stringify(this.#sessionDetails)),
|
|
415
|
+
qos: 0,
|
|
416
|
+
retain: false,
|
|
417
|
+
},
|
|
418
|
+
username: this.#sessionDetails.token,
|
|
419
|
+
};
|
|
420
|
+
this.#mqttClient = await mqtt.connectAsync(this.#sessionDetails.url, clientOptions);
|
|
421
|
+
// TODO: Dynamic intent discovery
|
|
422
|
+
// search for any ongoing discoveries in DB and fire report-intents on self
|
|
423
|
+
this.#logger('log', `Cloud Interop successfully connected to ${this.#cloudInteropSettings.url}`);
|
|
424
|
+
this.#mqttClient.on('error', async (error) => {
|
|
425
|
+
// We will receive errors for each failed reconnection attempt
|
|
426
|
+
// We don't want to disconnect on these else we will never reconnect
|
|
427
|
+
if (!this.#attemptingToReconnect) {
|
|
428
|
+
await this.#disconnect(false);
|
|
429
|
+
}
|
|
430
|
+
if (error instanceof mqtt.ErrorWithReasonCode) {
|
|
431
|
+
switch (error.code) {
|
|
432
|
+
case BadUserNamePasswordError: {
|
|
433
|
+
this.#handleSessionExpiry();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
default: {
|
|
437
|
+
this.#logger('error', `Unknown Infrastructure Error Code ${error.code} : ${error.message}${this.#attemptingToReconnect ? ' during reconnection attempt' : ''}`);
|
|
438
|
+
// As we are in the middle of a reconnect, lets not emit an error to cut down on the event noise
|
|
439
|
+
if (!this.#attemptingToReconnect) {
|
|
440
|
+
this.#events.emitEvent('error', new CloudInteropAPIError(`Unknown Infrastructure Error Code ${error.code} : ${error.message}`, 'ERR_INFRASTRUCTURE', error));
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
this.#logger('error', `Unknown Error${this.#attemptingToReconnect ? ' during reconnection attempt' : ''}: ${error}`);
|
|
448
|
+
// As we are in the middle of a reconnect, lets not emit an error to cut down on the event noise
|
|
449
|
+
if (!this.#attemptingToReconnect) {
|
|
450
|
+
this.#events.emitEvent('error', new CloudInteropAPIError(`Unknown Error`, 'ERR_UNKNOWN', error));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
this.#mqttClient.on('reconnect', () => {
|
|
455
|
+
this.#attemptingToReconnect = true;
|
|
456
|
+
this.#reconnectRetries += 1;
|
|
457
|
+
this.#logger('debug', `Cloud Interop attempting reconnection - ${this.#reconnectRetries}...`);
|
|
458
|
+
if (this.#reconnectRetries === this.#reconnectRetryLimit) {
|
|
459
|
+
this.#logger('warn', `Cloud Interop reached max reconnection attempts - ${this.#reconnectRetryLimit}...`);
|
|
460
|
+
this.#disconnect(true);
|
|
461
|
+
}
|
|
462
|
+
this.#events.emitEvent('reconnecting', this.#reconnectRetries);
|
|
463
|
+
});
|
|
464
|
+
// Does not fire on initial connection, only successful reconnection attempts
|
|
465
|
+
this.#mqttClient.on('connect', () => {
|
|
466
|
+
this.#logger('debug', `Cloud Interop successfully reconnected after ${this.#reconnectRetries} attempts`);
|
|
467
|
+
this.#reconnectRetries = 0;
|
|
468
|
+
this.#attemptingToReconnect = false;
|
|
469
|
+
this.#events.emitEvent('reconnected');
|
|
470
|
+
});
|
|
471
|
+
this.#mqttClient.on('message', (topic, message) => {
|
|
472
|
+
if (!this.#sessionDetails) {
|
|
473
|
+
this.#logger('warn', 'Received message when session not connected');
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
this.#handleMessage(topic, message, this.#sessionDetails);
|
|
477
|
+
});
|
|
478
|
+
// Subscribe to all context groups
|
|
479
|
+
this.#mqttClient.subscribe(`${sessionRootTopic}/context-groups/#`);
|
|
480
|
+
// Listen out for global commands
|
|
481
|
+
this.#mqttClient.subscribe(`${sessionRootTopic}/commands`);
|
|
482
|
+
this.#initControllers(this.#mqttClient, this.#sessionDetails, this.#connectionParams);
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Disconnects from the Cloud Interop service
|
|
486
|
+
*
|
|
487
|
+
* @returns Promise that resolves when disconnected
|
|
488
|
+
* @memberof CloudInteropAPI
|
|
489
|
+
* @throws CloudInteropAPIError - If an error occurs during disconnection
|
|
490
|
+
*/
|
|
491
|
+
async disconnect() {
|
|
492
|
+
await this.#disconnect(true);
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Publishes a new context for the given context group to the other connected sessions
|
|
496
|
+
*
|
|
497
|
+
* @param contextGroup - The context group to publish to
|
|
498
|
+
* @param context - The context to publish
|
|
499
|
+
* @returns Promise that resolves when context is published
|
|
500
|
+
* @memberof CloudInteropAPI
|
|
501
|
+
*/
|
|
502
|
+
async setContext(contextGroup, context) {
|
|
503
|
+
// TODO: make context of type OpenFin.Context
|
|
504
|
+
if (!this.#sessionDetails || !this.#connectionParams) {
|
|
505
|
+
throw new Error('Session not connected');
|
|
506
|
+
}
|
|
507
|
+
if (!this.#mqttClient) {
|
|
508
|
+
throw new Error('MQTT client not connected');
|
|
509
|
+
}
|
|
510
|
+
const payload = {
|
|
511
|
+
context,
|
|
512
|
+
timestamp: Date.now(),
|
|
513
|
+
};
|
|
514
|
+
const postResponse = await fetch(`${this.#cloudInteropSettings.url}/api/context-groups/${this.#sessionDetails.sessionId}/${contextGroup}`, {
|
|
515
|
+
method: 'POST',
|
|
516
|
+
headers: getRequestHeaders(this.#connectionParams),
|
|
517
|
+
body: JSON.stringify(payload),
|
|
518
|
+
});
|
|
519
|
+
if (!postResponse.ok) {
|
|
520
|
+
throw new CloudInteropAPIError(`Error setting context for ${contextGroup}`, 'ERR_SETTING_CONTEXT', new Error(postResponse.statusText));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Starts an intent discovery operation
|
|
525
|
+
*
|
|
526
|
+
* @returns Promise that resolves when intent discovery is started
|
|
527
|
+
* @memberof CloudInteropAPI
|
|
528
|
+
* @throws CloudInteropAPIError - If an error occurs during intent discovery
|
|
529
|
+
*/
|
|
530
|
+
async startIntentDiscovery(options) {
|
|
531
|
+
this.#throwIfNotConnected();
|
|
532
|
+
return this.#intents?.startIntentDiscovery(options);
|
|
533
|
+
}
|
|
534
|
+
async raiseIntent(options) {
|
|
535
|
+
this.#throwIfNotConnected();
|
|
536
|
+
return this.#intents?.raiseIntent(options);
|
|
537
|
+
}
|
|
538
|
+
async reportAppIntents(discoveryId, intents) {
|
|
539
|
+
this.#throwIfNotConnected();
|
|
540
|
+
return this.#intents?.reportAppIntents(discoveryId, intents) ?? false;
|
|
541
|
+
}
|
|
542
|
+
async sendIntentResult(initiatingSessionId, result) {
|
|
543
|
+
this.#throwIfNotConnected();
|
|
544
|
+
return this.#intents?.sendIntentResult(initiatingSessionId, result);
|
|
545
|
+
}
|
|
546
|
+
parseSessionId(appId) {
|
|
547
|
+
return parseCloudAppId(appId).sessionId;
|
|
548
|
+
}
|
|
549
|
+
parseAppId(appId) {
|
|
550
|
+
return parseCloudAppId(appId).appId;
|
|
551
|
+
}
|
|
552
|
+
addEventListener(type, callback) {
|
|
553
|
+
this.#events.addEventListener(type, callback);
|
|
554
|
+
}
|
|
555
|
+
removeEventListener(type, callback) {
|
|
556
|
+
this.#events.removeEventListener(type, callback);
|
|
557
|
+
}
|
|
558
|
+
once(type, callback) {
|
|
559
|
+
this.#events.once(type, callback);
|
|
560
|
+
}
|
|
561
|
+
async #disconnect(fireDisconnectedEvent) {
|
|
562
|
+
if (!this.#sessionDetails) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
if (!this.#connectionParams) {
|
|
567
|
+
throw new Error('Connect parameters must be provided');
|
|
568
|
+
}
|
|
569
|
+
// Cancel session timer if it's running
|
|
570
|
+
if (this.#sessionTimer) {
|
|
571
|
+
clearTimeout(this.#sessionTimer);
|
|
572
|
+
this.#sessionTimer = undefined;
|
|
573
|
+
}
|
|
574
|
+
const disconnectResponse = await fetch(`${this.#cloudInteropSettings.url}/api/sessions/${this.#sessionDetails.sessionId}`, {
|
|
575
|
+
method: 'DELETE',
|
|
576
|
+
headers: getRequestHeaders(this.#connectionParams),
|
|
577
|
+
});
|
|
578
|
+
if (disconnectResponse.status !== 200) {
|
|
579
|
+
throw new CloudInteropAPIError('Error during session tear down - unexpected status', 'ERR_DISCONNECT', new Error(disconnectResponse.statusText));
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
throw new CloudInteropAPIError('Error during disconnection', 'ERR_DISCONNECT', error);
|
|
584
|
+
}
|
|
585
|
+
finally {
|
|
586
|
+
this.#destroyControllers();
|
|
587
|
+
this.#mqttClient?.removeAllListeners();
|
|
588
|
+
await this.#mqttClient?.endAsync(true);
|
|
589
|
+
this.#sessionDetails = undefined;
|
|
590
|
+
this.#mqttClient = undefined;
|
|
591
|
+
this.#reconnectRetries = 0;
|
|
592
|
+
this.#attemptingToReconnect = false;
|
|
593
|
+
if (fireDisconnectedEvent) {
|
|
594
|
+
this.#events.emitEvent('disconnected');
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
#handleMessage(topic, message, sessionDetails) {
|
|
599
|
+
if (message.length === 0 || !sessionDetails) {
|
|
600
|
+
// Ignore clean up messages
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const messageEnvelope = JSON.parse(message.toString());
|
|
604
|
+
if (topic.startsWith(`${sessionDetails.sessionRootTopic}/context-groups/`)) {
|
|
605
|
+
const contextEvent = messageEnvelope;
|
|
606
|
+
if (contextEvent.source.sessionId === sessionDetails.sessionId) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const { context, payload, contextGroup, channelName, source, history } = contextEvent;
|
|
610
|
+
this.#events.emitEvent('context', {
|
|
611
|
+
contextGroup: channelName || contextGroup,
|
|
612
|
+
context: payload || context,
|
|
613
|
+
source,
|
|
614
|
+
history: { ...history, clientReceived: Date.now() },
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
else if (topic.startsWith(`${sessionDetails.sessionRootTopic}/commands`)) {
|
|
618
|
+
this.#handleCommandMessage(messageEnvelope);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
#handleCommandMessage(message) {
|
|
622
|
+
switch (message.command) {
|
|
623
|
+
case 'report-intents':
|
|
624
|
+
case 'intent-details':
|
|
625
|
+
case 'raise-intent':
|
|
626
|
+
case 'intent-result': {
|
|
627
|
+
this.#intents?.handleCommandMessage(message);
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
default: {
|
|
631
|
+
this.#logger('warn', `Unknown command message received:\n${JSON.stringify(message, null, 2)}`);
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
#validateConnectParams = (parameters) => {
|
|
637
|
+
if (!parameters) {
|
|
638
|
+
throw new Error('Connect parameters must be provided');
|
|
639
|
+
}
|
|
640
|
+
if (!parameters.sourceId) {
|
|
641
|
+
throw new Error('sourceId must be provided');
|
|
642
|
+
}
|
|
643
|
+
if (parameters.sourceId.includes(APP_ID_DELIM)) {
|
|
644
|
+
throw new Error(`sourceId cannot contain "${APP_ID_DELIM}"`);
|
|
645
|
+
}
|
|
646
|
+
if (!parameters.platformId) {
|
|
647
|
+
throw new Error('platformId must be provided');
|
|
648
|
+
}
|
|
649
|
+
if (parameters.authenticationType === 'jwt' &&
|
|
650
|
+
(!parameters.jwtAuthenticationParameters?.jwtRequestCallback || !parameters.jwtAuthenticationParameters?.authenticationId)) {
|
|
651
|
+
throw new Error('jwtAuthenticationParameters must be provided when using jwt authentication');
|
|
652
|
+
}
|
|
653
|
+
if (parameters.authenticationType === 'basic' &&
|
|
654
|
+
(!parameters.basicAuthenticationParameters?.username || !parameters.basicAuthenticationParameters?.password)) {
|
|
655
|
+
throw new Error('basicAuthenticationParameters must be provided when using basic authentication');
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
#initControllers(mqttClient, sessionDetails, connectionParameters) {
|
|
659
|
+
this.#intents = new IntentController(this.#cloudInteropSettings.url, mqttClient, sessionDetails, connectionParameters, this.#events, this.#logger);
|
|
660
|
+
}
|
|
661
|
+
#destroyControllers() {
|
|
662
|
+
this.#intents = undefined;
|
|
663
|
+
}
|
|
664
|
+
#throwIfNotConnected() {
|
|
665
|
+
if (!this.#sessionDetails || !this.#connectionParams) {
|
|
666
|
+
throw new Error('Session not connected');
|
|
667
|
+
}
|
|
668
|
+
if (!this.#mqttClient) {
|
|
669
|
+
throw new Error('MQTT client not connected');
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Extracts the expiration timestamp from a JWT token.
|
|
674
|
+
*
|
|
675
|
+
* @param token - The JWT token string
|
|
676
|
+
* @returns The expiration timestamp in seconds, or null if extraction fails
|
|
677
|
+
*/
|
|
678
|
+
#extractExpirationFromJwt(token) {
|
|
679
|
+
try {
|
|
680
|
+
// JWT tokens have three parts separated by dots: header.payload.signature
|
|
681
|
+
// The exp claim is in the payload
|
|
682
|
+
const parts = token.split('.');
|
|
683
|
+
if (parts.length < 2) {
|
|
684
|
+
this.#logger('warn', 'Invalid JWT token format: expected at least 2 parts');
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
const payload = parts[1];
|
|
688
|
+
// Decode base64url encoded payload
|
|
689
|
+
const decodedBytes = buffer.Buffer.from(l(payload), 'base64');
|
|
690
|
+
const payloadJson = decodedBytes.toString('utf8');
|
|
691
|
+
// Parse JSON to get the exp claim
|
|
692
|
+
const claims = JSON.parse(payloadJson);
|
|
693
|
+
const exp = claims.exp;
|
|
694
|
+
if (exp === undefined || exp === null) {
|
|
695
|
+
this.#logger('warn', "JWT token does not contain 'exp' claim");
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
if (typeof exp !== 'number') {
|
|
699
|
+
this.#logger('warn', `JWT token 'exp' claim is not a number: ${exp}`);
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
return exp;
|
|
703
|
+
}
|
|
704
|
+
catch (error) {
|
|
705
|
+
this.#logger('error', `Failed to extract expiration from JWT token: ${error instanceof Error ? error.message : error}`);
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Start a session timer that will expire at the time specified in the JWT token's exp claim.
|
|
711
|
+
* When the timer fires, it executes the same actions as the BadUserNamePasswordError case.
|
|
712
|
+
*/
|
|
713
|
+
#startSessionTimer() {
|
|
714
|
+
if (!this.#sessionDetails?.localSessionExpiryHandling) {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const token = this.#sessionDetails.token;
|
|
718
|
+
if (!token) {
|
|
719
|
+
this.#logger('warn', 'Cannot start session timer: token not available');
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
// Extract expiration time from JWT token
|
|
723
|
+
const expTimestamp = this.#extractExpirationFromJwt(token);
|
|
724
|
+
if (expTimestamp === null) {
|
|
725
|
+
this.#logger('warn', 'Cannot start session timer: could not extract expiration from JWT token');
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const currentTimeSeconds = Math.floor(Date.now() / 1000);
|
|
729
|
+
const delaySeconds = expTimestamp - currentTimeSeconds;
|
|
730
|
+
if (delaySeconds <= 0) {
|
|
731
|
+
this.#logger('warn', 'JWT token has already expired or expires immediately');
|
|
732
|
+
// Execute the same actions as BadUserNamePasswordError case
|
|
733
|
+
this.#handleSessionExpiry();
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
// Clear any existing timer
|
|
737
|
+
if (this.#sessionTimer) {
|
|
738
|
+
clearTimeout(this.#sessionTimer);
|
|
739
|
+
}
|
|
740
|
+
const expirationTimeString = new Date(expTimestamp * 1000).toISOString();
|
|
741
|
+
this.#logger('debug', `Starting session timer to expire in ${delaySeconds} seconds (at ${expirationTimeString})`);
|
|
742
|
+
this.#sessionTimer = setTimeout(async () => {
|
|
743
|
+
this.#handleSessionExpiry();
|
|
744
|
+
}, delaySeconds * 1000);
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Handles session expiry by executing the same actions as the BadUserNamePasswordError case.
|
|
748
|
+
*/
|
|
749
|
+
async #handleSessionExpiry() {
|
|
750
|
+
await this.#disconnect(false);
|
|
751
|
+
this.#logger('warn', 'Session expired');
|
|
752
|
+
this.#events.emitEvent('session-expired');
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
exports.AuthorizationError = AuthorizationError;
|
|
757
|
+
exports.CloudInteropAPI = CloudInteropAPI;
|
|
758
|
+
exports.CloudInteropAPIError = CloudInteropAPIError;
|