@qikdev/sdk 1.0.12 → 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) {
@@ -63,19 +88,27 @@ const QikSocket = function(qik, mode) {
63
88
 
64
89
 
65
90
  /**
66
- * @description Create an event dispatcher instance and subscribe to a new socket channel.
67
- * 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.
68
98
  * @alias socket.channel
69
- * @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.
70
101
  * @example
71
- * const socketChannel = await sdk.socket.channel('awesome:notifications')
72
- * socketChannel.addEventListener('message', function(message) {
73
- * console.log('Received a new message from the socket')
74
- * })
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);
75
107
  *
76
- * // Disconnect from this channel and remove all listeners
77
- * socketChannel.destroy();
78
- *
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();
79
112
  */
80
113
  service.channel = async function(key) {
81
114
  if (buckets[key]) {
@@ -195,7 +228,7 @@ const QikSocket = function(qik, mode) {
195
228
  }
196
229
 
197
230
  function socketError(error) {
198
- console.log('[event] socketError', event);
231
+ console.log('[event] socketError', error);
199
232
  service.debug ? console.log("[socket] Error", error) : null;
200
233
  dispatcher.dispatch('error', error);
201
234
  }
@@ -210,6 +243,14 @@ const QikSocket = function(qik, mode) {
210
243
  if (eventData.channel) {
211
244
  dispatcher.dispatch(eventData.channel, eventData);
212
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
+ }
213
254
  }
214
255
 
215
256
  async function broadcast(data) {
@@ -247,11 +288,13 @@ const QikSocket = function(qik, mode) {
247
288
  ///////////////////////////////////////////////////
248
289
 
249
290
  /**
250
- * @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.
251
294
  * @alias socket.connect
295
+ * @returns {Promise<WebSocket|undefined>} The underlying WebSocket once open, or undefined without a token.
252
296
  * @example
253
- * const connected = await sdk.socket.connect()
254
- *
297
+ * await sdk.socket.connect();
255
298
  */
256
299
  service.connect = async function() {
257
300
  if (socket) {
@@ -308,11 +351,12 @@ const QikSocket = function(qik, mode) {
308
351
 
309
352
 
310
353
  /**
311
- * @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.
312
356
  * @alias socket.reconnect
357
+ * @returns {Promise<WebSocket|undefined>} As for connect(); undefined when already connected.
313
358
  * @example
314
- * const connected = await sdk.socket.reconnect()
315
- *
359
+ * await sdk.socket.reconnect();
316
360
  */
317
361
  service.reconnect = async function() {
318
362
  if(service.connected) {
@@ -330,11 +374,12 @@ const QikSocket = function(qik, mode) {
330
374
  ///////////////////////////////////////////////////
331
375
 
332
376
  /**
333
- * @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().
334
379
  * @alias socket.close
380
+ * @returns {Promise<undefined>}
335
381
  * @example
336
- * const closed = await sdk.socket.close()
337
- *
382
+ * await sdk.socket.close();
338
383
  */
339
384
  service.close = async function() {
340
385
  if (!socket) {
@@ -359,6 +404,13 @@ const QikSocket = function(qik, mode) {
359
404
  service.autoreconnect = previousSetting;
360
405
  }
361
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
+ */
362
414
  service.disconnect = service.close;
363
415
 
364
416
  ///////////////////////////////////////////////////
@@ -403,12 +455,17 @@ const QikSocket = function(qik, mode) {
403
455
  ///////////////////////////////////////////////////
404
456
 
405
457
  /**
406
- * @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.
407
462
  * @alias socket.subscribe
408
- * @param {String} key The name of the channel to subscribe to
463
+ * @param {String} channel The channel name.
464
+ * @returns {Promise<undefined>}
409
465
  * @example
410
- * const subscribed = await sdk.socket.subscribe('some:cool:alert')
411
- *
466
+ * await sdk.socket.connect();
467
+ * sdk.socket.subscribe(`${eventID}:checkins`);
468
+ * sdk.socket.addEventListener(`${eventID}:checkins`, ({ message }) => refresh());
412
469
  */
413
470
  service.subscribe = async function(channel) {
414
471
  if (!socket) {
@@ -426,12 +483,13 @@ const QikSocket = function(qik, mode) {
426
483
  ///////////////////////////////////////////////////
427
484
 
428
485
  /**
429
- * @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.
430
488
  * @alias socket.unsubscribe
431
- * @param {String} key The name of the channel to unsubscribe from
489
+ * @param {String} channel The channel name.
490
+ * @returns {Promise<undefined>}
432
491
  * @example
433
- * const unsubscribed = await sdk.socket.unsubscribe('some:cool:alert')
434
- *
492
+ * sdk.socket.unsubscribe(`${eventID}:checkins`);
435
493
  */
436
494
  service.unsubscribe = async function(channel) {
437
495
  if (!socket) {
@@ -452,31 +510,47 @@ const QikSocket = function(qik, mode) {
452
510
 
453
511
  ///////////////////////////////////////////////////
454
512
 
455
- // Reconnect the socket whenever the authenticated organisation changes.
456
- // The WebSocket authenticates with an access token at connect time, so the
457
- // connection stays bound to whichever organisation was active when it
458
- // opened. Switching organisations issues a new token but leaves the socket
459
- // untouched, so without this it keeps streaming the previous
460
- // organisation's events. Reconnecting re-authenticates with the new token.
461
- 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());
462
536
 
463
537
  qik.auth.addEventListener('change', async function(user) {
464
- const organisationID = qik.utils.id(user?.organisation);
538
+ const identity = identityOf(user);
465
539
 
466
- // Ignore auth changes that don't change the organisation (e.g. routine
467
- // access token refreshes) so we don't churn the connection needlessly.
468
- 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) {
469
543
  return;
470
544
  }
471
545
 
472
- currentOrganisationID = organisationID;
546
+ currentIdentity = identity;
473
547
 
474
548
  // Nothing to reconnect if the socket was never opened.
475
549
  if (!socket) {
476
550
  return;
477
551
  }
478
552
 
479
- service.debug ? console.log('[socket] - organisation changed, reconnecting') : null;
553
+ service.debug ? console.log('[socket] - signed-in identity changed, reconnecting') : null;
480
554
  await service.close();
481
555
  await service.reconnect();
482
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