@openfin/cloud-notification-core-api 0.0.1-alpha.babe94a
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/LICENSE.md +4 -0
- package/README.md +14 -0
- package/bundle.d.ts +334 -0
- package/index.cjs +728 -0
- package/index.mjs +719 -0
- package/package.json +18 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
import { Buffer } from 'buffer';
|
|
2
|
+
import z from 'zod';
|
|
3
|
+
import mqtt from 'mqtt';
|
|
4
|
+
|
|
5
|
+
class CloudNotificationAPIError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(message = 'An unexpected error has occurred', code = 'UNEXPECTED_ERROR', cause) {
|
|
8
|
+
super(message, { cause: cause });
|
|
9
|
+
this.name = this.constructor.name;
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
class AuthorizationError extends CloudNotificationAPIError {
|
|
14
|
+
constructor(message = 'Not authorized', code = 'ERR_UNAUTHORIZED') {
|
|
15
|
+
super(message, code, undefined);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
class SessionNotConnectedError extends CloudNotificationAPIError {
|
|
19
|
+
constructor(message = 'Session not connected', code = 'ERR_SESSION_NOT_CONNECTED') {
|
|
20
|
+
super(message, code, undefined);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
class PublishError extends CloudNotificationAPIError {
|
|
24
|
+
constructor(message = 'Publish error', code = 'ERR_PUBLISH_ERROR', cause) {
|
|
25
|
+
super(message, code, cause);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
class EventRetrievalError extends CloudNotificationAPIError {
|
|
29
|
+
constructor(message = 'Event Retrieval error', code = 'ERR_EVENT_RETRIEVAL_ERROR', cause) {
|
|
30
|
+
super(message, code, cause);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
class InvalidMessageFormatError extends CloudNotificationAPIError {
|
|
34
|
+
constructor(zodParseResult) {
|
|
35
|
+
super(`Message Format Error: ${zodParseResult.error?.toString()}`, 'ERR_MESSAGE_FORMAT_ERROR', undefined);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class EventController {
|
|
40
|
+
#eventListeners = new Map();
|
|
41
|
+
addEventListener(type, callback) {
|
|
42
|
+
const listeners = this.#eventListeners.get(type) || [];
|
|
43
|
+
listeners.push(callback);
|
|
44
|
+
this.#eventListeners.set(type, listeners);
|
|
45
|
+
}
|
|
46
|
+
removeEventListener(type, callback) {
|
|
47
|
+
const listeners = this.#eventListeners.get(type) || [];
|
|
48
|
+
const index = listeners.indexOf(callback);
|
|
49
|
+
if (index !== -1) {
|
|
50
|
+
listeners.splice(index, 1);
|
|
51
|
+
}
|
|
52
|
+
this.#eventListeners.set(type, listeners);
|
|
53
|
+
}
|
|
54
|
+
once(type, callback) {
|
|
55
|
+
const listener = (...args) => {
|
|
56
|
+
this.removeEventListener(type, listener);
|
|
57
|
+
// @ts-expect-error - TS doesn't like the spread operator here
|
|
58
|
+
callback(...args);
|
|
59
|
+
};
|
|
60
|
+
this.addEventListener(type, listener);
|
|
61
|
+
}
|
|
62
|
+
emitEvent(type, ...args) {
|
|
63
|
+
const listeners = this.#eventListeners.get(type) || [];
|
|
64
|
+
listeners.forEach((listener) => listener(...args));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getRequestHeaders(connectionParameters) {
|
|
69
|
+
const headers = {};
|
|
70
|
+
headers['Content-Type'] = 'application/json';
|
|
71
|
+
if (connectionParameters.authenticationType === 'jwt' && connectionParameters.jwtAuthenticationParameters) {
|
|
72
|
+
const tokenResult = connectionParameters.jwtAuthenticationParameters.jwtRequestCallback();
|
|
73
|
+
if (!tokenResult) {
|
|
74
|
+
throw new Error('jwtRequestCallback must return a token');
|
|
75
|
+
}
|
|
76
|
+
headers['x-of-auth-id'] = connectionParameters.jwtAuthenticationParameters.authenticationId;
|
|
77
|
+
headers['Authorization'] =
|
|
78
|
+
typeof tokenResult === 'string' ? `Bearer ${tokenResult}` : `Bearer ${Buffer.from(JSON.stringify(tokenResult)).toString('base64')}`;
|
|
79
|
+
}
|
|
80
|
+
if (connectionParameters.authenticationType === 'basic' && connectionParameters.basicAuthenticationParameters) {
|
|
81
|
+
const { username, password } = connectionParameters.basicAuthenticationParameters;
|
|
82
|
+
headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
|
83
|
+
}
|
|
84
|
+
return headers;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Simple set-like structure that allows you to add values with a TTL (time to live).
|
|
88
|
+
* Values are automatically removed from the set after the TTL expires.
|
|
89
|
+
*/
|
|
90
|
+
class SetWithTTL {
|
|
91
|
+
ttl;
|
|
92
|
+
store;
|
|
93
|
+
nextCollection;
|
|
94
|
+
collectionDelay;
|
|
95
|
+
constructor(ttl, collectionCheckInterval) {
|
|
96
|
+
this.ttl = ttl;
|
|
97
|
+
this.store = new Map();
|
|
98
|
+
this.nextCollection = null;
|
|
99
|
+
this.collectionDelay = collectionCheckInterval || 100;
|
|
100
|
+
}
|
|
101
|
+
get size() {
|
|
102
|
+
return this.store.size;
|
|
103
|
+
}
|
|
104
|
+
add(value, customTtl) {
|
|
105
|
+
this.store.set(value, Date.now() + (customTtl || this.ttl));
|
|
106
|
+
this.#scheduleCollection();
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
has(value) {
|
|
110
|
+
return this.store.has(value);
|
|
111
|
+
}
|
|
112
|
+
delete(value) {
|
|
113
|
+
if (this.store.has(value)) {
|
|
114
|
+
this.store.delete(value);
|
|
115
|
+
this.#scheduleCollection(true);
|
|
116
|
+
}
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
#scheduleCollection(force = false) {
|
|
120
|
+
if (force && this.nextCollection) {
|
|
121
|
+
clearTimeout(this.nextCollection);
|
|
122
|
+
this.nextCollection = null;
|
|
123
|
+
}
|
|
124
|
+
if (this.nextCollection !== null || this.store.size === 0) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
this.nextCollection = setTimeout(() => {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
const toKeepStore = new Map();
|
|
130
|
+
this.store.forEach((expiry, value) => {
|
|
131
|
+
if (expiry >= now) {
|
|
132
|
+
toKeepStore.set(value, expiry);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
this.store = toKeepStore;
|
|
136
|
+
this.nextCollection = null;
|
|
137
|
+
this.#scheduleCollection();
|
|
138
|
+
}, this.collectionDelay);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
class TimeDifferenceTracker {
|
|
143
|
+
#timeServerUrl;
|
|
144
|
+
#intervalMs;
|
|
145
|
+
#samples;
|
|
146
|
+
#connectionParams;
|
|
147
|
+
#currentOffset;
|
|
148
|
+
#timerId;
|
|
149
|
+
#logger;
|
|
150
|
+
constructor(timeServerUrl, connectionParameters, logger, intervalMs = 60 * 60 * 1000, samples = 10) {
|
|
151
|
+
this.#timeServerUrl = timeServerUrl;
|
|
152
|
+
this.#logger = logger;
|
|
153
|
+
this.#connectionParams = connectionParameters;
|
|
154
|
+
this.#intervalMs = intervalMs;
|
|
155
|
+
this.#samples = samples;
|
|
156
|
+
}
|
|
157
|
+
async start() {
|
|
158
|
+
await this.updateTimeOffset();
|
|
159
|
+
}
|
|
160
|
+
stop() {
|
|
161
|
+
if (this.#timerId) {
|
|
162
|
+
clearInterval(this.#timerId);
|
|
163
|
+
this.#timerId = undefined;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async updateTimeOffset() {
|
|
167
|
+
if (this.#timerId) {
|
|
168
|
+
clearInterval(this.#timerId);
|
|
169
|
+
this.#timerId = undefined;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
this.#currentOffset = await this.#getAverageOffset();
|
|
173
|
+
this.#logger('debug', `Time offset: ${this.#currentOffset} ms`);
|
|
174
|
+
this.#timerId = setInterval(async () => {
|
|
175
|
+
this.#currentOffset = await this.#getAverageOffset();
|
|
176
|
+
this.#logger('debug', `Time offset: ${this.#currentOffset} ms`);
|
|
177
|
+
}, this.#intervalMs);
|
|
178
|
+
return this.#currentOffset;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
this.#logger('error', `Failed to update time offset`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
get currentOffset() {
|
|
185
|
+
return this.#currentOffset;
|
|
186
|
+
}
|
|
187
|
+
async #measureOffset() {
|
|
188
|
+
if (!this.#connectionParams) {
|
|
189
|
+
throw new Error('Connection details not set');
|
|
190
|
+
}
|
|
191
|
+
const start = Date.now();
|
|
192
|
+
const response = await fetch(`${this.#timeServerUrl}/api/time`, {
|
|
193
|
+
method: 'GET',
|
|
194
|
+
headers: getRequestHeaders(this.#connectionParams),
|
|
195
|
+
});
|
|
196
|
+
const end = Date.now();
|
|
197
|
+
const data = await response.json();
|
|
198
|
+
const rtt = end - start;
|
|
199
|
+
const clientMidpoint = (start + end) / 2;
|
|
200
|
+
return {
|
|
201
|
+
offset: data.serverTime - clientMidpoint,
|
|
202
|
+
rtt,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
async #getAverageOffset() {
|
|
206
|
+
const results = [];
|
|
207
|
+
for (let index = 0; index < this.#samples; index++) {
|
|
208
|
+
try {
|
|
209
|
+
const result = await this.#measureOffset();
|
|
210
|
+
results.push(result);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Just ignore any time get failures
|
|
214
|
+
}
|
|
215
|
+
await new Promise((res) => setTimeout(res, 50));
|
|
216
|
+
}
|
|
217
|
+
// Filter out top 20% RTTs as outliers
|
|
218
|
+
const filtered = results.sort((a, b) => a.rtt - b.rtt).slice(0, Math.floor(results.length * 0.8));
|
|
219
|
+
return filtered.reduce((sum, r) => sum + r.offset, 0) / filtered.length;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Error codes as defined in https://docs.emqx.com/en/cloud/latest/connect_to_deployments/mqtt_client_error_codes.html
|
|
224
|
+
const BadUserNamePasswordError = 134;
|
|
225
|
+
const forwardedNotificationEventSchema = z.object({
|
|
226
|
+
notificationId: z.string().uuid(),
|
|
227
|
+
category: z.string(),
|
|
228
|
+
type: z.string(),
|
|
229
|
+
payload: z.unknown().optional(),
|
|
230
|
+
correlationId: z.string().optional(),
|
|
231
|
+
});
|
|
232
|
+
const forwardedMessageSchema = z.object({
|
|
233
|
+
action: z.enum(['new', 'update']),
|
|
234
|
+
notificationId: z.string().uuid(),
|
|
235
|
+
originatingSessionId: z.string().uuid(),
|
|
236
|
+
correlationId: z.string().optional(),
|
|
237
|
+
target: z.string(),
|
|
238
|
+
targetType: z.enum(['user', 'group']),
|
|
239
|
+
payload: z.unknown(),
|
|
240
|
+
});
|
|
241
|
+
const notificationEventDetailSchema = z.object({
|
|
242
|
+
userId: z.string().uuid(),
|
|
243
|
+
platformId: z.string(),
|
|
244
|
+
sourceId: z.string(),
|
|
245
|
+
notificationId: z.string().uuid(),
|
|
246
|
+
category: z.string(),
|
|
247
|
+
type: z.string(),
|
|
248
|
+
sessionId: z.string().uuid(),
|
|
249
|
+
payload: z.unknown().optional(),
|
|
250
|
+
correlationId: z.string().optional(),
|
|
251
|
+
timestamp: z.string().datetime(),
|
|
252
|
+
});
|
|
253
|
+
/**
|
|
254
|
+
* Represents a single connection to a Cloud Notification service
|
|
255
|
+
*
|
|
256
|
+
* @public
|
|
257
|
+
* @class
|
|
258
|
+
*/
|
|
259
|
+
class CloudNotificationAPI {
|
|
260
|
+
#cloudNotificationSettings;
|
|
261
|
+
#sessionDetails;
|
|
262
|
+
#mqttClient;
|
|
263
|
+
#reconnectRetryLimit = 30;
|
|
264
|
+
#keepAliveIntervalSeconds = 30;
|
|
265
|
+
#timeDifferenceTracker;
|
|
266
|
+
#newNotificationsDeDuplicator = new SetWithTTL(10_000);
|
|
267
|
+
#logger = (level, message) => {
|
|
268
|
+
console[level](message);
|
|
269
|
+
};
|
|
270
|
+
#reconnectRetries = 0;
|
|
271
|
+
#connectionParams;
|
|
272
|
+
#attemptingToReconnect = false;
|
|
273
|
+
#events = new EventController();
|
|
274
|
+
#defaultSubscriptionOptions = { qos: 2, nl: true };
|
|
275
|
+
constructor(cloudNotificationSettings) {
|
|
276
|
+
this.#cloudNotificationSettings = cloudNotificationSettings;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Connects and creates a session on the Cloud Notifications service
|
|
280
|
+
*
|
|
281
|
+
* @param {ConnectParameters} parameters - The parameters to use to connect
|
|
282
|
+
* @returns {*} {Promise<void>}
|
|
283
|
+
* @memberof CloudNotificationAPI
|
|
284
|
+
* @throws {CloudNotificationAPIError} - If an error occurs during connection
|
|
285
|
+
* @throws {AuthorizationError} - If the connection is unauthorized
|
|
286
|
+
*/
|
|
287
|
+
async connect(parameters) {
|
|
288
|
+
this.#validateConnectParams(parameters);
|
|
289
|
+
this.#connectionParams = parameters;
|
|
290
|
+
this.#reconnectRetryLimit = parameters.reconnectRetryLimit || this.#reconnectRetryLimit;
|
|
291
|
+
this.#keepAliveIntervalSeconds = parameters.keepAliveIntervalSeconds || this.#keepAliveIntervalSeconds;
|
|
292
|
+
this.#logger = parameters.logger || this.#logger;
|
|
293
|
+
if (this.#timeDifferenceTracker) {
|
|
294
|
+
this.#timeDifferenceTracker.stop();
|
|
295
|
+
this.#timeDifferenceTracker = undefined;
|
|
296
|
+
}
|
|
297
|
+
this.#timeDifferenceTracker = new TimeDifferenceTracker(this.#cloudNotificationSettings.url, parameters, this.#logger);
|
|
298
|
+
if (parameters.syncTime !== false) {
|
|
299
|
+
await this.#timeDifferenceTracker.start();
|
|
300
|
+
}
|
|
301
|
+
const { platformId, sourceId } = this.#connectionParams;
|
|
302
|
+
const timeOffset = Number.isFinite(this.#timeDifferenceTracker?.currentOffset) ? this.#timeDifferenceTracker.currentOffset : 0;
|
|
303
|
+
const createSessionResponse = await fetch(`${this.#cloudNotificationSettings.url}/api/sessions`, {
|
|
304
|
+
method: 'POST',
|
|
305
|
+
headers: getRequestHeaders(this.#connectionParams),
|
|
306
|
+
body: JSON.stringify({
|
|
307
|
+
platformId,
|
|
308
|
+
sourceId,
|
|
309
|
+
timeOffset,
|
|
310
|
+
isFullApi: true,
|
|
311
|
+
}),
|
|
312
|
+
});
|
|
313
|
+
this.#validateResponse(createSessionResponse);
|
|
314
|
+
if (createSessionResponse.status !== 201) {
|
|
315
|
+
throw new CloudNotificationAPIError(`Failed to connect to the Cloud Notification service: ${this.#cloudNotificationSettings.url}`, 'ERR_CONNECT', new Error(createSessionResponse.statusText));
|
|
316
|
+
}
|
|
317
|
+
this.#sessionDetails = (await createSessionResponse.json());
|
|
318
|
+
// Now have the details from the server about where to connect to and a token to connect with
|
|
319
|
+
// we can go ahead and connect to the MQTT server
|
|
320
|
+
await this.#connectToMQTT();
|
|
321
|
+
return { sessionId: this.#sessionDetails.sessionId, platformId, sourceId, userId: this.#sessionDetails.userId, groups: this.#sessionDetails.groups };
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Disconnects from the Cloud Notification service
|
|
325
|
+
*
|
|
326
|
+
* @returns {*} {Promise<void>}
|
|
327
|
+
* @memberof CloudNotificationAPI
|
|
328
|
+
* @throws {CloudNotificationAPIError} - If an error occurs during disconnection
|
|
329
|
+
*/
|
|
330
|
+
async disconnect() {
|
|
331
|
+
await this.#disconnect(true);
|
|
332
|
+
}
|
|
333
|
+
async postNotificationEvent(event) {
|
|
334
|
+
if (!this.#sessionDetails || !this.#connectionParams) {
|
|
335
|
+
this.#logger('error', 'Invalid Session');
|
|
336
|
+
throw new SessionNotConnectedError();
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
const timeOffset = Number.isFinite(this.#timeDifferenceTracker?.currentOffset) ? this.#timeDifferenceTracker?.currentOffset : 0;
|
|
340
|
+
const publishPayload = {
|
|
341
|
+
sessionId: this.#sessionDetails.sessionId,
|
|
342
|
+
timeOffset,
|
|
343
|
+
event,
|
|
344
|
+
};
|
|
345
|
+
const publishResponse = await fetch(`${this.#cloudNotificationSettings.url}/api/publish/events`, {
|
|
346
|
+
method: 'POST',
|
|
347
|
+
headers: getRequestHeaders(this.#connectionParams),
|
|
348
|
+
body: JSON.stringify(publishPayload),
|
|
349
|
+
});
|
|
350
|
+
this.#validateResponse(publishResponse);
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
this.#handleAPIException(error, 'Error posting notification event', PublishError);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async getNotificationEvents(notificationIds) {
|
|
357
|
+
if (!this.#sessionDetails || !this.#connectionParams) {
|
|
358
|
+
this.#logger('error', 'Invalid Session');
|
|
359
|
+
throw new SessionNotConnectedError();
|
|
360
|
+
}
|
|
361
|
+
if (!Array.isArray(notificationIds) || notificationIds.length === 0) {
|
|
362
|
+
this.#logger('error', 'No notification IDs provided');
|
|
363
|
+
throw new EventRetrievalError('No notification IDs provided');
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
const url = new URL(`${this.#cloudNotificationSettings.url}/api/publish/events`);
|
|
367
|
+
url.searchParams.append('sessionId', this.#sessionDetails.sessionId);
|
|
368
|
+
for (const notificationId of notificationIds) {
|
|
369
|
+
url.searchParams.append('id', notificationId);
|
|
370
|
+
}
|
|
371
|
+
const getResponse = await fetch(url, {
|
|
372
|
+
method: 'GET',
|
|
373
|
+
headers: getRequestHeaders(this.#connectionParams),
|
|
374
|
+
});
|
|
375
|
+
this.#validateResponse(getResponse);
|
|
376
|
+
const events = (await getResponse.json());
|
|
377
|
+
return events;
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
this.#handleAPIException(error, 'Error retrieving notification events', EventRetrievalError);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// NOTE: will see if we can strongly type the payload in a future PR OR it will be layered above this core api
|
|
384
|
+
async raiseNotification(options, payload) {
|
|
385
|
+
if (!this.#sessionDetails || !this.#connectionParams) {
|
|
386
|
+
this.#logger('error', 'Invalid Session');
|
|
387
|
+
throw new SessionNotConnectedError();
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
const timeOffset = Number.isFinite(this.#timeDifferenceTracker?.currentOffset) ? this.#timeDifferenceTracker?.currentOffset : 0;
|
|
391
|
+
// NOTE: Will be strongly typed and moved to shared in a future PR
|
|
392
|
+
const publishPayload = {
|
|
393
|
+
sessionId: this.#sessionDetails.sessionId,
|
|
394
|
+
timeOffset,
|
|
395
|
+
notification: {
|
|
396
|
+
correlationId: options.correlationId,
|
|
397
|
+
ttl: options.ttl,
|
|
398
|
+
targets: options.targets,
|
|
399
|
+
class: 'interactive',
|
|
400
|
+
payload,
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
const publishResponse = await fetch(`${this.#cloudNotificationSettings.url}/api/publish`, {
|
|
404
|
+
method: 'POST',
|
|
405
|
+
headers: getRequestHeaders(this.#connectionParams),
|
|
406
|
+
body: JSON.stringify(publishPayload),
|
|
407
|
+
});
|
|
408
|
+
this.#validateResponse(publishResponse);
|
|
409
|
+
const publishResponseBody = (await publishResponse.json());
|
|
410
|
+
return publishResponseBody;
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
this.#handleAPIException(error, 'Error publishing notification', PublishError);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async updateNotification() {
|
|
417
|
+
// TODO: SAAS-2559
|
|
418
|
+
}
|
|
419
|
+
async deleteNotification() {
|
|
420
|
+
// TODO: SAAS-2551
|
|
421
|
+
}
|
|
422
|
+
addEventListener(type, callback) {
|
|
423
|
+
this.#events.addEventListener(type, callback);
|
|
424
|
+
}
|
|
425
|
+
removeEventListener(type, callback) {
|
|
426
|
+
this.#events.removeEventListener(type, callback);
|
|
427
|
+
}
|
|
428
|
+
once(type, callback) {
|
|
429
|
+
this.#events.once(type, callback);
|
|
430
|
+
}
|
|
431
|
+
async #connectToMQTT() {
|
|
432
|
+
if (!this.#sessionDetails) {
|
|
433
|
+
this.#logger('error', 'Invalid Session');
|
|
434
|
+
throw new SessionNotConnectedError();
|
|
435
|
+
}
|
|
436
|
+
const clientOptions = {
|
|
437
|
+
keepalive: this.#keepAliveIntervalSeconds,
|
|
438
|
+
clientId: this.#sessionDetails.sessionId,
|
|
439
|
+
clean: true,
|
|
440
|
+
protocolVersion: 5,
|
|
441
|
+
// The "will" message will automatically be published on an unexpected disconnection allowing the server to know that this client is no longer connected and to clean up its session
|
|
442
|
+
will: {
|
|
443
|
+
topic: this.#sessionDetails.lastWillTopic,
|
|
444
|
+
payload: Buffer.from(JSON.stringify(this.#sessionDetails)),
|
|
445
|
+
qos: 2,
|
|
446
|
+
retain: false,
|
|
447
|
+
},
|
|
448
|
+
username: this.#sessionDetails.token,
|
|
449
|
+
};
|
|
450
|
+
this.#mqttClient = await mqtt.connectAsync(this.#sessionDetails.url, clientOptions);
|
|
451
|
+
this.#logger('debug', `Cloud Notifications successfully connected to notification backbone`);
|
|
452
|
+
this.#mqttClient.on('error', async (error) => this.#mqttErrorHandler(error));
|
|
453
|
+
this.#mqttClient.on('reconnect', () => this.#mqttReconnectionHandler());
|
|
454
|
+
// Does not fire on initial connection, only successful reconnection attempts
|
|
455
|
+
this.#mqttClient.on('connect', () => this.#mqttConnectionHandler());
|
|
456
|
+
this.#mqttClient.on('message', (topic, message) => this.#mqttMessageHandler(topic, message));
|
|
457
|
+
// Subscribe any session specific topics
|
|
458
|
+
this.#mqttClient.subscribe(`${this.#sessionDetails.sessionRootTopic}/#`);
|
|
459
|
+
// Subscribe to the user notification delivery topics for groups and users
|
|
460
|
+
await this.#subscribeToNotificationTopics();
|
|
461
|
+
}
|
|
462
|
+
// async #connect(parameters: ConnectParameters): Promise<ConnectionResult> {}
|
|
463
|
+
async #mqttConnectionHandler() {
|
|
464
|
+
this.#logger('debug', `Cloud Notifications successfully reconnected after ${this.#reconnectRetries} attempts`);
|
|
465
|
+
this.#reconnectRetries = 0;
|
|
466
|
+
this.#attemptingToReconnect = false;
|
|
467
|
+
this.#events.emitEvent('reconnected');
|
|
468
|
+
}
|
|
469
|
+
async #mqttReconnectionHandler() {
|
|
470
|
+
this.#attemptingToReconnect = true;
|
|
471
|
+
this.#reconnectRetries += 1;
|
|
472
|
+
this.#logger('debug', `Cloud Notifications attempting reconnection - ${this.#reconnectRetries}...`);
|
|
473
|
+
if (this.#reconnectRetries === this.#reconnectRetryLimit) {
|
|
474
|
+
this.#logger('warn', `Cloud Notifications reached max reconnection attempts - ${this.#reconnectRetryLimit}...`);
|
|
475
|
+
this.#disconnect(true);
|
|
476
|
+
}
|
|
477
|
+
this.#events.emitEvent('reconnecting', this.#reconnectRetries);
|
|
478
|
+
}
|
|
479
|
+
async #mqttMessageHandler(topic, message) {
|
|
480
|
+
if (!this.#sessionDetails) {
|
|
481
|
+
this.#logger('error', 'Invalid Session');
|
|
482
|
+
throw new SessionNotConnectedError();
|
|
483
|
+
}
|
|
484
|
+
this.#handleMessage(topic, message, this.#sessionDetails);
|
|
485
|
+
}
|
|
486
|
+
async #mqttErrorHandler(error) {
|
|
487
|
+
if (error instanceof mqtt.ErrorWithReasonCode) {
|
|
488
|
+
switch (error.code) {
|
|
489
|
+
case BadUserNamePasswordError: {
|
|
490
|
+
this.#logger('debug', `Session expired`);
|
|
491
|
+
this.#events.emitEvent('session-expired');
|
|
492
|
+
// TODO: Request new JWT if using JWT authentication
|
|
493
|
+
await this.#refreshSession();
|
|
494
|
+
this.#logger('debug', `Session extended`);
|
|
495
|
+
this.#events.emitEvent('session-extended');
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
default: {
|
|
499
|
+
this.#logger('error', `Unknown Infrastructure Error Code ${error.code} : ${error.message}${this.#attemptingToReconnect ? ' during reconnection attempt' : ''}`);
|
|
500
|
+
// As we are in the middle of a reconnect, lets not emit an error to cut down on the event noise
|
|
501
|
+
if (!this.#attemptingToReconnect) {
|
|
502
|
+
this.#events.emitEvent('error', new CloudNotificationAPIError(`Unknown Infrastructure Error Code ${error.code} : ${error.message}`, 'ERR_INFRASTRUCTURE', error));
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
this.#logger('error', `Unknown Error${this.#attemptingToReconnect ? ' during reconnection attempt' : ''}: ${error}`);
|
|
510
|
+
// As we are in the middle of a reconnect, lets not emit an error to cut down on the event noise
|
|
511
|
+
if (!this.#attemptingToReconnect) {
|
|
512
|
+
this.#events.emitEvent('error', new CloudNotificationAPIError(`Unknown Error`, 'ERR_UNKNOWN', error));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
async #sendNotificationDeliveredStatus(notificationId) {
|
|
517
|
+
return this.postNotificationEvent({ notificationId, category: 'delivery', type: 'ack' });
|
|
518
|
+
}
|
|
519
|
+
async #subscribeToNotificationTopics() {
|
|
520
|
+
if (!this.#sessionDetails) {
|
|
521
|
+
this.#logger('error', 'Invalid Session');
|
|
522
|
+
throw new SessionNotConnectedError();
|
|
523
|
+
}
|
|
524
|
+
// Group target notifications
|
|
525
|
+
const subscribePromises = this.#sessionDetails.groups.map((group) => {
|
|
526
|
+
if (group.topic) {
|
|
527
|
+
return this.#mqttClient?.subscribeAsync(group.topic, this.#defaultSubscriptionOptions);
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
this.#logger('warn', `Group ${group.uuid} does not have a topic to subscribe to`);
|
|
531
|
+
return Promise.resolve();
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
subscribePromises.push(
|
|
535
|
+
// Notification directly to the user
|
|
536
|
+
this.#mqttClient?.subscribeAsync(this.#sessionDetails.userNotificationTopic, this.#defaultSubscriptionOptions),
|
|
537
|
+
// The users notifications status updates
|
|
538
|
+
this.#mqttClient?.subscribeAsync(this.#sessionDetails.userNotificationEventsTopic, this.#defaultSubscriptionOptions),
|
|
539
|
+
// All notification events for the user
|
|
540
|
+
this.#mqttClient?.subscribeAsync(this.#sessionDetails.allNotificationEventsTopic, this.#defaultSubscriptionOptions));
|
|
541
|
+
await Promise.all(subscribePromises);
|
|
542
|
+
}
|
|
543
|
+
async #refreshSession() {
|
|
544
|
+
if (!this.#sessionDetails) {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
if (!this.#connectionParams) {
|
|
549
|
+
throw new Error('Connect parameters must be provided');
|
|
550
|
+
}
|
|
551
|
+
this.#mqttClient?.removeAllListeners();
|
|
552
|
+
await this.#mqttClient?.endAsync(true);
|
|
553
|
+
this.#mqttClient = undefined;
|
|
554
|
+
this.#reconnectRetries = 0;
|
|
555
|
+
this.#attemptingToReconnect = false;
|
|
556
|
+
const extendResponse = await fetch(`${this.#cloudNotificationSettings.url}/api/sessions`, {
|
|
557
|
+
method: 'PUT',
|
|
558
|
+
headers: getRequestHeaders(this.#connectionParams),
|
|
559
|
+
body: JSON.stringify({
|
|
560
|
+
sessionId: this.#sessionDetails.sessionId,
|
|
561
|
+
}),
|
|
562
|
+
});
|
|
563
|
+
if (extendResponse.status !== 200) {
|
|
564
|
+
throw new CloudNotificationAPIError(`Error during session extend - unexpected status ${extendResponse.status}`, 'ERR_SESSION_EXTEND', new Error(extendResponse.statusText));
|
|
565
|
+
}
|
|
566
|
+
this.#sessionDetails = (await extendResponse.json());
|
|
567
|
+
this.#attemptingToReconnect = true;
|
|
568
|
+
await this.#connectToMQTT();
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
throw new CloudNotificationAPIError('Error during session refresh', 'ERR_SESSION_EXTEND', error);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
async #disconnect(fireDisconnectedEvent) {
|
|
575
|
+
if (!this.#sessionDetails) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
if (!this.#connectionParams) {
|
|
580
|
+
throw new Error('Connect parameters must be provided');
|
|
581
|
+
}
|
|
582
|
+
// Clean up our session on the server
|
|
583
|
+
const disconnectResponse = await fetch(`${this.#cloudNotificationSettings.url}/api/sessions/${this.#sessionDetails.sessionId}`, {
|
|
584
|
+
method: 'DELETE',
|
|
585
|
+
headers: getRequestHeaders(this.#connectionParams),
|
|
586
|
+
});
|
|
587
|
+
if (disconnectResponse.status !== 200) {
|
|
588
|
+
throw new CloudNotificationAPIError('Error during session tear down - unexpected status', 'ERR_DISCONNECT', new Error(disconnectResponse.statusText));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
throw new CloudNotificationAPIError('Error during disconnection', 'ERR_DISCONNECT', error);
|
|
593
|
+
}
|
|
594
|
+
finally {
|
|
595
|
+
this.#mqttClient?.removeAllListeners();
|
|
596
|
+
await this.#mqttClient?.endAsync(true);
|
|
597
|
+
this.#sessionDetails = undefined;
|
|
598
|
+
this.#mqttClient = undefined;
|
|
599
|
+
this.#reconnectRetries = 0;
|
|
600
|
+
this.#attemptingToReconnect = false;
|
|
601
|
+
if (fireDisconnectedEvent) {
|
|
602
|
+
this.#events.emitEvent('disconnected');
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
#handleMessage(topic, message, sessionDetails) {
|
|
607
|
+
if (message.length === 0 || !sessionDetails) {
|
|
608
|
+
// Ignore clean up messages
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
const messagePayload = JSON.parse(message.toString());
|
|
613
|
+
if (topic.startsWith(sessionDetails.channelsRootTopic)) {
|
|
614
|
+
this.#handleNotificationMessage(messagePayload);
|
|
615
|
+
}
|
|
616
|
+
else if (topic === sessionDetails.userNotificationEventsTopic) {
|
|
617
|
+
this.#handleUserNotificationEventMessage(messagePayload);
|
|
618
|
+
}
|
|
619
|
+
else if (topic === sessionDetails.allNotificationEventsTopic) {
|
|
620
|
+
this.#handleAllNotificationEventMessage(messagePayload);
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
this.#logger('warn', `Received message on unknown topic ${topic}`);
|
|
624
|
+
this.#events.emitEvent('error', new CloudNotificationAPIError(`Received message on unknown topic ${topic}`, 'ERR_UNKNOWN_TOPIC'));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
catch (error) {
|
|
628
|
+
this.#logger('warn', `Received invalid message - ${error}`);
|
|
629
|
+
this.#events.emitEvent('error', new CloudNotificationAPIError(`Received invalid message`, 'ERR_INVALID_MESSAGE', error));
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
#handleNotificationMessage(messagePayload) {
|
|
633
|
+
const parseResult = forwardedMessageSchema.safeParse(messagePayload);
|
|
634
|
+
if (!parseResult.success) {
|
|
635
|
+
this.#logger('warn', `Received invalid notification message payload format ${parseResult.error?.toString()}`);
|
|
636
|
+
throw new InvalidMessageFormatError(parseResult);
|
|
637
|
+
}
|
|
638
|
+
const { action, notificationId, originatingSessionId, correlationId, target, targetType, payload } = parseResult.data;
|
|
639
|
+
// Ignore if its one we sent ourselves
|
|
640
|
+
if (originatingSessionId === this.#sessionDetails?.sessionId) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
// Ignore if this a multi-receive of a new notification across multiple groups
|
|
644
|
+
if (action === 'new') {
|
|
645
|
+
if (this.#newNotificationsDeDuplicator.has(notificationId)) {
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
this.#newNotificationsDeDuplicator.add(notificationId);
|
|
649
|
+
}
|
|
650
|
+
this.#sendNotificationDeliveredStatus(notificationId);
|
|
651
|
+
let targetName = target;
|
|
652
|
+
if (targetType === 'group') {
|
|
653
|
+
targetName = this.#lookupGroupNameByUuid(target);
|
|
654
|
+
}
|
|
655
|
+
if (action === 'new') {
|
|
656
|
+
this.#events.emitEvent('new-notification', { action, notificationId, correlationId, target, targetName, targetType, payload });
|
|
657
|
+
}
|
|
658
|
+
else if (action === 'update') {
|
|
659
|
+
this.#events.emitEvent('update-notification', { action, notificationId, correlationId, target, targetName, targetType, payload });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
#handleUserNotificationEventMessage(messagePayload) {
|
|
663
|
+
const parseResult = forwardedNotificationEventSchema.safeParse(messagePayload);
|
|
664
|
+
if (!parseResult.success) {
|
|
665
|
+
this.#logger('warn', `Received invalid notification event message payload format ${parseResult.error?.toString()}`);
|
|
666
|
+
throw new InvalidMessageFormatError(parseResult);
|
|
667
|
+
}
|
|
668
|
+
this.#events.emitEvent('notification-event', parseResult.data);
|
|
669
|
+
}
|
|
670
|
+
#handleAllNotificationEventMessage(messagePayload) {
|
|
671
|
+
const parseResult = forwardedNotificationEventSchema.safeParse(messagePayload);
|
|
672
|
+
if (!parseResult.success) {
|
|
673
|
+
this.#logger('warn', `Received invalid notification event message payload format ${parseResult.error?.toString()}`);
|
|
674
|
+
throw new InvalidMessageFormatError(parseResult);
|
|
675
|
+
}
|
|
676
|
+
this.#events.emitEvent('notification-event-all', parseResult.data);
|
|
677
|
+
}
|
|
678
|
+
#validateConnectParams = (parameters) => {
|
|
679
|
+
if (!parameters) {
|
|
680
|
+
throw new Error('Connect parameters must be provided');
|
|
681
|
+
}
|
|
682
|
+
if (!parameters.platformId) {
|
|
683
|
+
throw new Error('platformId must be provided');
|
|
684
|
+
}
|
|
685
|
+
if (parameters.authenticationType === 'jwt' &&
|
|
686
|
+
(!parameters.jwtAuthenticationParameters?.jwtRequestCallback || !parameters.jwtAuthenticationParameters?.authenticationId)) {
|
|
687
|
+
throw new Error('jwtAuthenticationParameters must be provided when using jwt authentication');
|
|
688
|
+
}
|
|
689
|
+
if (parameters.authenticationType === 'basic' &&
|
|
690
|
+
(!parameters.basicAuthenticationParameters?.username || !parameters.basicAuthenticationParameters?.password)) {
|
|
691
|
+
throw new Error('basicAuthenticationParameters must be provided when using basic authentication');
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
#handleAPIException(error, message, exceptionConstructor) {
|
|
695
|
+
if (error instanceof Error) {
|
|
696
|
+
this.#logger('error', `${message} - ${error.message}`);
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
this.#logger('error', `${message} - ${error}`);
|
|
700
|
+
}
|
|
701
|
+
throw new exceptionConstructor(undefined, undefined, error);
|
|
702
|
+
}
|
|
703
|
+
#validateResponse(response) {
|
|
704
|
+
if (!response.ok) {
|
|
705
|
+
if (response.status === 401 || response.status === 403) {
|
|
706
|
+
throw new AuthorizationError();
|
|
707
|
+
}
|
|
708
|
+
throw new CloudNotificationAPIError();
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
#lookupGroupNameByUuid(uuid) {
|
|
712
|
+
if (!this.#sessionDetails) {
|
|
713
|
+
return undefined;
|
|
714
|
+
}
|
|
715
|
+
return this.#sessionDetails.groups.find((g) => g.uuid === uuid)?.name;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export { AuthorizationError, CloudNotificationAPI, CloudNotificationAPIError, EventRetrievalError, InvalidMessageFormatError, PublishError, SessionNotConnectedError, notificationEventDetailSchema };
|