@qikdev/sdk 1.0.10 → 1.0.13

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.
@@ -3,12 +3,37 @@ import { EventDispatcher } from './qik.utils.js';
3
3
  ///////////////////////////////////////////////////
4
4
 
5
5
  /**
6
- * Creates a new instance of QikSocket a module of the SDK
7
- * that contains all helper functions to do with realtime sockets
6
+ * Realtime updates over a WebSocket. Subscribe to a channel to be told when something changes, then
7
+ * re-read what you need with sdk.content.get / list: content messages say THAT a record changed, not what it
8
+ * now contains (the read applies the viewer's field permissions). Needs a signed-in user or application
9
+ * token; the socket connects on first use, reconnects automatically with back-off, resubscribes its channels
10
+ * after reconnecting, and reconnects when the user switches organisation. Changes made from this same
11
+ * browser window are not echoed back to it. Subscriptions are permission-checked by the server: a channel
12
+ * you may not use is simply never delivered.
13
+ * Channels (use these names; don't invent others):
14
+ * '<recordID>:edit' and '<recordID>:delete' — that record saved or deleted (needs view access to it);
15
+ * '<type or definition key>:create' / ':edit' / ':delete' — any record of that type, e.g. 'profile:edit' (needs viewany or editany on it);
16
+ * '<recordID>:restore' and '<type or definition key>:restore' — restored from the trash;
17
+ * '<eventID>:checkins', '<profileID>:checkins', '<eventID>:assignments' — check-ins and roster assignments;
18
+ * '<recordID>:comment' — comments posted on a record (the full comment);
19
+ * '<type or definition key>:definition' — that type's fields changed;
20
+ * 'notifications:<your persona, profile or user id>' and 'presence:<your organisation id>'.
21
+ * Each message is { channel, message }. For create and edit, message is
22
+ * { user: { name, _id, persona }, item: { _id, meta: { type, definition, uhash, updated, deleted } }, truncated: true }:
23
+ * compare item.meta.uhash with your copy and refetch when it differs. For delete and restore, message.item is just the id.
24
+ * Connection events: sdk.socket.addEventListener('connected' | 'disconnected' | 'error' | 'message', handler);
25
+ * 'message' receives every incoming message on every channel.
8
26
  * @name socket
9
27
  * @constructor
10
28
  * @hideconstructor
11
29
  * @param {QikAPI} qik A reference to the parent instance of the QikCore module. This module is usually created by a QikCore instance that passes itself in as the first argument.
30
+ * @example
31
+ * const channel = await sdk.socket.channel(`${recordID}:edit`);
32
+ * channel.addEventListener('message', async ({ message }) => {
33
+ * record = await sdk.content.get(recordID);
34
+ * });
35
+ * // when finished
36
+ * channel.destroy();
12
37
  */
13
38
 
14
39
  const QikSocket = function(qik, mode) {
@@ -34,9 +59,19 @@ const QikSocket = function(qik, mode) {
34
59
 
35
60
  let socket;
36
61
  let timer;
62
+ let connecting;
63
+ let reconnectTimer;
64
+ let reconnectAttempts = 0;
65
+ let pendingSubscribes = [];
66
+ let flushScheduled = false;
37
67
  let buckets = {};
38
68
  const channels = {};
39
69
 
70
+ // Reconnect backoff: 1s, 2s, 4s ... capped, with jitter so a fleet of tabs
71
+ // that lost the socket together doesn't return in lockstep.
72
+ const RECONNECT_BASE_MS = 1000;
73
+ const RECONNECT_MAX_MS = 30000;
74
+
40
75
 
41
76
 
42
77
  //Create a new dispatcher
@@ -53,19 +88,27 @@ const QikSocket = function(qik, mode) {
53
88
 
54
89
 
55
90
  /**
56
- * @description Create an event dispatcher instance and subscribe to a new socket channel.
57
- * This can then be used to receive socket events for this channel
91
+ * @description Subscribes to a channel and returns a listener object for it, connecting first if needed.
92
+ * This is the easiest way to use the socket. Calling channel() again with the same key returns the same
93
+ * object, so it is shared by everything on the page that uses that key.
94
+ * The returned object has addEventListener('message', handler), removeEventListener('message', handler),
95
+ * removeAllListeners() and destroy(). Handlers receive { channel, message }. destroy() unsubscribes the
96
+ * channel and removes every handler on it — including other components' — so when the key may be shared,
97
+ * remove just your own handler instead.
58
98
  * @alias socket.channel
59
- * @param {String} key The key or name of the channel to subscribe to
99
+ * @param {String} key The channel name, e.g. `${recordID}:edit` or 'profile:create' (see the channel list above).
100
+ * @returns {Promise<Object>} The channel's listener object. If nobody is signed in the socket can't connect, and the returned object never receives messages.
60
101
  * @example
61
- * const socketChannel = await sdk.socket.channel('awesome:notifications')
62
- * socketChannel.addEventListener('message', function(message) {
63
- * console.log('Received a new message from the socket')
64
- * })
102
+ * const channel = await sdk.socket.channel('profile:create');
103
+ * async function onCreated({ message }) {
104
+ * const profile = await sdk.content.get(message.item._id);
105
+ * }
106
+ * channel.addEventListener('message', onCreated);
65
107
  *
66
- * // Disconnect from this channel and remove all listeners
67
- * socketChannel.destroy();
68
- *
108
+ * // Later: stop listening (keeps the channel for anyone else using it)
109
+ * channel.removeEventListener('message', onCreated);
110
+ * // Or: unsubscribe and remove all listeners on this channel
111
+ * channel.destroy();
69
112
  */
70
113
  service.channel = async function(key) {
71
114
  if (buckets[key]) {
@@ -138,17 +181,14 @@ const QikSocket = function(qik, mode) {
138
181
  function socketOpened(event) {
139
182
  service.debug ? console.log("[socket] Connection open", event) : null;
140
183
  service.connected = true;
184
+ reconnectAttempts = 0;
141
185
 
142
186
  dispatcher.dispatch('connected', event);
143
187
 
144
188
  // Replay every recorded subscription — frames broadcast before the
145
189
  // connection opened were dropped, and reconnects need them resent.
146
- Object.keys(channels).forEach(function(channel) {
147
- broadcast({
148
- action: 'subscribe',
149
- channel,
150
- });
151
- });
190
+ // One batched frame: the server writes them in a single update.
191
+ queueSubscribe(Object.keys(channels));
152
192
 
153
193
  // startHeartbeat();
154
194
  }
@@ -167,12 +207,28 @@ const QikSocket = function(qik, mode) {
167
207
  socket = null;
168
208
 
169
209
  if(service.autoreconnect) {
170
- service.reconnect();
210
+ scheduleReconnect();
211
+ }
212
+ }
213
+
214
+ function scheduleReconnect() {
215
+ if (reconnectTimer) {
216
+ return;
171
217
  }
218
+
219
+ const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts));
220
+ const jittered = Math.round(delay * (0.5 + Math.random() / 2));
221
+ reconnectAttempts++;
222
+
223
+ service.debug ? console.log(`[socket] reconnecting in ${jittered}ms`) : null;
224
+ reconnectTimer = setTimeout(function() {
225
+ reconnectTimer = null;
226
+ service.reconnect();
227
+ }, jittered);
172
228
  }
173
229
 
174
230
  function socketError(error) {
175
- console.log('[event] socketError', event);
231
+ console.log('[event] socketError', error);
176
232
  service.debug ? console.log("[socket] Error", error) : null;
177
233
  dispatcher.dispatch('error', error);
178
234
  }
@@ -187,6 +243,14 @@ const QikSocket = function(qik, mode) {
187
243
  if (eventData.channel) {
188
244
  dispatcher.dispatch(eventData.channel, eventData);
189
245
  }
246
+
247
+ // The server says this person's access changed (a role or access pass
248
+ // they hold was edited). Reload the session so what they can see
249
+ // follows; a failed reload keeps them signed in as they were.
250
+ const message = eventData.message;
251
+ if (message && message.type === 'session' && message.action === 'changed') {
252
+ Promise.resolve(qik.auth.sync({ keepOnError: true })).catch(function() {});
253
+ }
190
254
  }
191
255
 
192
256
  async function broadcast(data) {
@@ -203,28 +267,34 @@ const QikSocket = function(qik, mode) {
203
267
 
204
268
  ///////////////////////////////////////////////////
205
269
 
270
+ // Resolves once the socket is open. Waits on the 'connected' event rather
271
+ // than polling: a polling timer kept running forever if the socket was
272
+ // closed before it opened, and added up to a second to every connect.
206
273
  function wait() {
207
- return new Promise(function(resolve) {
208
- function check() {
209
- if (service.connected) {
210
- return resolve(socket);
211
- }
274
+ if (service.connected) {
275
+ return Promise.resolve(socket);
276
+ }
212
277
 
213
- setTimeout(check, 1000);
278
+ return new Promise(function(resolve) {
279
+ function opened() {
280
+ service.removeEventListener('connected', opened);
281
+ resolve(socket);
214
282
  }
215
283
 
216
- check();
284
+ service.addEventListener('connected', opened);
217
285
  })
218
286
  }
219
287
 
220
288
  ///////////////////////////////////////////////////
221
289
 
222
290
  /**
223
- * @description Connect to the socket service
291
+ * @description Opens the socket connection, first refreshing the access token if it has expired. Resolves once
292
+ * connected. Calls made while a connection is being opened share it. channel() connects for you, so this is
293
+ * rarely needed. Resolves with nothing (and doesn't connect) when there's no access token.
224
294
  * @alias socket.connect
295
+ * @returns {Promise<WebSocket|undefined>} The underlying WebSocket once open, or undefined without a token.
225
296
  * @example
226
- * const connected = await sdk.socket.connect()
227
- *
297
+ * await sdk.socket.connect();
228
298
  */
229
299
  service.connect = async function() {
230
300
  if (socket) {
@@ -237,6 +307,31 @@ const QikSocket = function(qik, mode) {
237
307
  return socket;
238
308
  }
239
309
 
310
+ // Callers arriving while we refresh the token share this attempt
311
+ // instead of each opening their own socket.
312
+ if (connecting) {
313
+ return connecting;
314
+ }
315
+
316
+ connecting = openSocket();
317
+ try {
318
+ return await connecting;
319
+ } finally {
320
+ connecting = null;
321
+ }
322
+ }
323
+
324
+ async function openSocket() {
325
+ // The socket authenticates once, with the token in its URL. Tokens
326
+ // expire (3 h), and a reconnect after the connection went idle would
327
+ // otherwise present a stale one: the server refuses to register it
328
+ // and the socket silently receives nothing. Refresh first if needed.
329
+ try {
330
+ await qik.auth.ensureValidToken();
331
+ } catch (err) {
332
+ service.debug ? console.log('[socket] - could not refresh token before connecting', err) : null;
333
+ }
334
+
240
335
  const accessToken = qik.auth.getCurrentToken();
241
336
 
242
337
  if (!accessToken) {
@@ -256,11 +351,12 @@ const QikSocket = function(qik, mode) {
256
351
 
257
352
 
258
353
  /**
259
- * @description Reconnect to the socket service
354
+ * @description Connects again after the connection was lost or closed; does nothing if already connected.
355
+ * Channels that were subscribed are subscribed again once the connection opens.
260
356
  * @alias socket.reconnect
357
+ * @returns {Promise<WebSocket|undefined>} As for connect(); undefined when already connected.
261
358
  * @example
262
- * const connected = await sdk.socket.reconnect()
263
- *
359
+ * await sdk.socket.reconnect();
264
360
  */
265
361
  service.reconnect = async function() {
266
362
  if(service.connected) {
@@ -278,11 +374,12 @@ const QikSocket = function(qik, mode) {
278
374
  ///////////////////////////////////////////////////
279
375
 
280
376
  /**
281
- * @description Close the connection to the socket service
377
+ * @description Closes the connection without reconnecting automatically. Channel subscriptions are
378
+ * remembered and resent by a later connect() or reconnect(). Also available as sdk.socket.disconnect().
282
379
  * @alias socket.close
380
+ * @returns {Promise<undefined>}
283
381
  * @example
284
- * const closed = await sdk.socket.close()
285
- *
382
+ * await sdk.socket.close();
286
383
  */
287
384
  service.close = async function() {
288
385
  if (!socket) {
@@ -290,6 +387,11 @@ const QikSocket = function(qik, mode) {
290
387
  return;
291
388
  }
292
389
 
390
+ if (reconnectTimer) {
391
+ clearTimeout(reconnectTimer);
392
+ reconnectTimer = null;
393
+ }
394
+
293
395
  const previousSetting = service.autoreconnect;
294
396
  service.autoreconnect = false;
295
397
  socket.close();
@@ -302,17 +404,68 @@ const QikSocket = function(qik, mode) {
302
404
  service.autoreconnect = previousSetting;
303
405
  }
304
406
 
407
+ /**
408
+ * @description Same as close(): closes the connection without reconnecting automatically.
409
+ * @alias socket.disconnect
410
+ * @returns {Promise<undefined>}
411
+ * @example
412
+ * await sdk.socket.disconnect();
413
+ */
305
414
  service.disconnect = service.close;
306
415
 
307
416
  ///////////////////////////////////////////////////
308
417
 
418
+ // Subscribes requested in the same tick go out as one frame. A page opening
419
+ // several channels at once used to send one frame each; the server then
420
+ // updated the same connection row from several places at the same moment
421
+ // and those requests queued behind each other for seconds.
422
+ function queueSubscribe(list) {
423
+ list.forEach(function(channel) {
424
+ if (!pendingSubscribes.includes(channel)) {
425
+ pendingSubscribes.push(channel);
426
+ }
427
+ });
428
+
429
+ if (flushScheduled) {
430
+ return;
431
+ }
432
+
433
+ flushScheduled = true;
434
+ setTimeout(flushSubscribes, 0);
435
+ }
436
+
437
+ function flushSubscribes() {
438
+ flushScheduled = false;
439
+ const list = pendingSubscribes.filter(function(channel) {
440
+ return channels[channel];
441
+ });
442
+ pendingSubscribes = [];
443
+
444
+ if (!list.length) {
445
+ return;
446
+ }
447
+
448
+ if (list.length === 1) {
449
+ broadcast({ action: 'subscribe', channel: list[0] });
450
+ } else {
451
+ broadcast({ action: 'subscribe', channels: list });
452
+ }
453
+ }
454
+
455
+ ///////////////////////////////////////////////////
456
+
309
457
  /**
310
- * @description Subscribe to a socket channel
458
+ * @description Asks the server to send this channel's messages to this connection. Takes no callback: listen with
459
+ * sdk.socket.addEventListener(channel, handler), which receives { channel, message }. Only works while the
460
+ * socket is connected (otherwise it logs and does nothing) — channel() handles connecting, subscribing and
461
+ * listening in one step and is usually the better choice. Subscriptions made in the same moment are sent together.
311
462
  * @alias socket.subscribe
312
- * @param {String} key The name of the channel to subscribe to
463
+ * @param {String} channel The channel name.
464
+ * @returns {Promise<undefined>}
313
465
  * @example
314
- * const subscribed = await sdk.socket.subscribe('some:cool:alert')
315
- *
466
+ * await sdk.socket.connect();
467
+ * sdk.socket.subscribe(`${eventID}:checkins`);
468
+ * sdk.socket.addEventListener(`${eventID}:checkins`, ({ message }) => refresh());
316
469
  */
317
470
  service.subscribe = async function(channel) {
318
471
  if (!socket) {
@@ -320,12 +473,8 @@ const QikSocket = function(qik, mode) {
320
473
  return;
321
474
  }
322
475
 
323
- broadcast({
324
- action: 'subscribe',
325
- channel,
326
- })
327
-
328
476
  channels[channel] = true;
477
+ queueSubscribe([channel]);
329
478
 
330
479
  dispatcher.dispatch('subscribe', channel)
331
480
  service.debug ? console.log(`[socket] - subscribed to ${channel}`) : null;
@@ -334,12 +483,13 @@ const QikSocket = function(qik, mode) {
334
483
  ///////////////////////////////////////////////////
335
484
 
336
485
  /**
337
- * @description Unsubscribe from a socket channel
486
+ * @description Tells the server to stop sending this channel's messages. Listeners added with addEventListener
487
+ * stay attached (remove them yourself). Only works while connected.
338
488
  * @alias socket.unsubscribe
339
- * @param {String} key The name of the channel to unsubscribe from
489
+ * @param {String} channel The channel name.
490
+ * @returns {Promise<undefined>}
340
491
  * @example
341
- * const unsubscribed = await sdk.socket.unsubscribe('some:cool:alert')
342
- *
492
+ * sdk.socket.unsubscribe(`${eventID}:checkins`);
343
493
  */
344
494
  service.unsubscribe = async function(channel) {
345
495
  if (!socket) {
@@ -360,31 +510,47 @@ const QikSocket = function(qik, mode) {
360
510
 
361
511
  ///////////////////////////////////////////////////
362
512
 
363
- // Reconnect the socket whenever the authenticated organisation changes.
364
- // The WebSocket authenticates with an access token at connect time, so the
365
- // connection stays bound to whichever organisation was active when it
366
- // opened. Switching organisations issues a new token but leaves the socket
367
- // untouched, so without this it keeps streaming the previous
368
- // organisation's events. Reconnecting re-authenticates with the new token.
369
- let currentOrganisationID = qik.utils.id(qik.auth.getCurrentUser()?.organisation);
513
+ // Reconnect the socket whenever who is signed in changes: signing in,
514
+ // signing out, switching organisation or switching person. The WebSocket
515
+ // authenticates with an access token at connect time, so the connection
516
+ // stays bound to whoever was signed in when it opened — channels opened
517
+ // before sign-in stay refused, and an organisation switch keeps streaming
518
+ // the previous organisation's events. Reconnecting re-authenticates with
519
+ // the new token and replays every channel.
520
+ //
521
+ // The stored user is { session, token }; who someone is lives on the
522
+ // session. Only these ids are read — sessions and tokens are untouched.
523
+ function identityOf(user) {
524
+ const session = user && user.session;
525
+ if (!session) {
526
+ return '';
527
+ }
528
+ return [
529
+ qik.utils.id(session.organisation),
530
+ qik.utils.id(session.persona),
531
+ qik.utils.id(session._id),
532
+ ].join('|');
533
+ }
534
+
535
+ let currentIdentity = identityOf(qik.auth.getCurrentUser());
370
536
 
371
537
  qik.auth.addEventListener('change', async function(user) {
372
- const organisationID = qik.utils.id(user?.organisation);
538
+ const identity = identityOf(user);
373
539
 
374
- // Ignore auth changes that don't change the organisation (e.g. routine
375
- // access token refreshes) so we don't churn the connection needlessly.
376
- if (organisationID === currentOrganisationID) {
540
+ // Ignore auth changes that leave the same person signed in (e.g.
541
+ // routine access token refreshes) so we don't churn the connection.
542
+ if (identity === currentIdentity) {
377
543
  return;
378
544
  }
379
545
 
380
- currentOrganisationID = organisationID;
546
+ currentIdentity = identity;
381
547
 
382
548
  // Nothing to reconnect if the socket was never opened.
383
549
  if (!socket) {
384
550
  return;
385
551
  }
386
552
 
387
- service.debug ? console.log('[socket] - organisation changed, reconnecting') : null;
553
+ service.debug ? console.log('[socket] - signed-in identity changed, reconnecting') : null;
388
554
  await service.close();
389
555
  await service.reconnect();
390
556
  });
@@ -1,5 +1,9 @@
1
1
  /**
2
- * Storage abstraction layer for handling both localStorage and httpOnly cookie storage
2
+ * Where the SDK keeps the signed-in session. Not a namespace of its own: sdk.auth creates one adapter,
3
+ * available as sdk.auth.storageAdapter, and uses it for getCurrentUser, set and logout.
4
+ * LocalStorageAdapter (the default, despite its name) keeps the session in memory only, so it is lost on page
5
+ * reload unless your app saves and restores it with sdk.auth.set. CookieStorageAdapter (useHttpOnlyCookies)
6
+ * keeps the session details in memory and leaves the tokens to httpOnly cookies set by the server.
3
7
  * @module QikStorage
4
8
  */
5
9
 
@@ -70,7 +74,7 @@ class StorageAdapter {
70
74
  }
71
75
 
72
76
  /**
73
- * localStorage-based storage adapter (current behavior)
77
+ * The default adapter. Despite the name it does not use window.localStorage: the session is held in memory.
74
78
  */
75
79
  class LocalStorageAdapter extends StorageAdapter {
76
80
  constructor() {
@@ -111,7 +115,8 @@ class LocalStorageAdapter extends StorageAdapter {
111
115
  }
112
116
 
113
117
  /**
114
- * Cookie-based storage adapter for httpOnly cookies
118
+ * Adapter used with useHttpOnlyCookies: session details in memory, tokens in httpOnly cookies the server sets
119
+ * (so getAccessToken, getRefreshToken and getTokenExpiry always return null).
115
120
  */
116
121
  class CookieStorageAdapter extends StorageAdapter {
117
122
  constructor(options = {}) {
@@ -281,11 +286,15 @@ class CookieStorageAdapter extends StorageAdapter {
281
286
  }
282
287
 
283
288
  /**
284
- * Factory function to create appropriate storage adapter
285
- * @param {Object} options Configuration options
286
- * @param {Boolean} options.useHttpOnlyCookies Whether to use cookie storage
287
- * @param {Object} options.cookieConfig Cookie configuration options
288
- * @returns {StorageAdapter} Storage adapter instance
289
+ * Creates the adapter sdk.auth uses: a CookieStorageAdapter when useHttpOnlyCookies is set and the browser
290
+ * accepts cookies, otherwise a LocalStorageAdapter (in memory).
291
+ * @param {Object} [options] Configuration.
292
+ * @param {Boolean} [options.useHttpOnlyCookies] Use cookie mode.
293
+ * @param {Object} [options.cookieConfig] Cookie settings: { domain, secure, sameSite }.
294
+ * @returns {StorageAdapter} The adapter.
295
+ * @example
296
+ * const adapter = createStorageAdapter({ useHttpOnlyCookies: false });
297
+ * adapter.setUser(session);
289
298
  */
290
299
  export function createStorageAdapter(options = {}) {
291
300
  if (options.useHttpOnlyCookies) {
@@ -1,8 +1,7 @@
1
1
  ///////////////////////////////////////////////////
2
2
 
3
3
  /**
4
- * Creates a new QikSystem instance.
5
- * This module provides a number of helper functions for the system
4
+ * Platform reference data that isn't specific to an organisation.
6
5
  * @alias system
7
6
  * @constructor
8
7
  * @hideconstructor
@@ -21,10 +20,12 @@ export default function(qik) {
21
20
 
22
21
  /**
23
22
  * @alias system.countries
24
- * @description Helper function for retrieving a full list of all countries, their ISO codes
25
- * and phone number prefixes. Helpful for populating dropdown lists.
23
+ * @description Retrieves every country (GET /system/countries) with its ISO codes, name, phone calling
24
+ * codes and currencies, e.g. to fill a country picker. Loaded once and shared by later calls.
25
+ * @returns {Promise<Array<Object>>} Countries, each with alpha2 (e.g. 'AU'), alpha3, name, countryCallingCodes (e.g. ['+61']) and currencies.
26
26
  * @example
27
- * const countries = await sdk.system.countries()
27
+ * const countries = await sdk.system.countries();
28
+ * const options = countries.map((country) => ({ title: country.name, value: country.alpha2 }));
28
29
  */
29
30
  service.countries = async function() {
30
31