UNPKG

@nymphjs/client

Version:

Nymph.js - Client

599 lines 21.2 kB
import { InvalidRequestError } from './Nymph.js'; import { entityConstructorsToClassNames } from './utils.js'; import { ClientError } from './HttpRequester.js'; export default class PubSub { nymph; authToken = null; switchToken = null; connection; waitForConnectionTimeout; pubsubUrl; WebSocket; subscriptions = { queries: {}, uids: {}, }; connectCallbacks = []; disconnectCallbacks = []; errorCallbacks = []; noConsole = false; constructor(nymphOptions, nymph) { this.nymph = nymph; this.nymph.pubsub = this; this.pubsubUrl = nymphOptions.pubsubUrl; this.WebSocket = nymphOptions.WebSocket ?? WebSocket; this.noConsole = !!nymphOptions.noConsole; if (!this.WebSocket) { throw new Error('Nymph-PubSub requires WebSocket!'); } if (typeof addEventListener !== 'undefined') { addEventListener('online', () => this.connect()); } if (!nymphOptions.noAutoconnect && (typeof navigator === 'undefined' || navigator.onLine)) { this.connect(); } } subscribeEntities(options, ...selectors) { const query = [ entityConstructorsToClassNames(options), ...entityConstructorsToClassNames(selectors), ]; const jsonQuery = JSON.stringify(query); const subscribe = (resolve, reject, count) => { const callbacks = [resolve, reject, count]; if (!this.isConnection()) { // Fall back to a regular query if we're not connected. this.nymph.getEntities(options, ...selectors).then(resolve, reject); } this._subscribeQuery(jsonQuery, callbacks); return new PubSubSubscription(jsonQuery, callbacks, () => { this._unsubscribeQuery(jsonQuery, callbacks); }); }; return subscribe; } subscribeEntity(options, ...selectors) { const query = [ { ...entityConstructorsToClassNames(options), limit: 1 }, ...entityConstructorsToClassNames(selectors), ]; const jsonQuery = JSON.stringify(query); const subscribe = (resolve, reject, count) => { const newResolve = (args) => { if (!args.length) { if (resolve) { resolve(null); } } else { if (resolve) { resolve(args[0]); } } }; const callbacks = [newResolve, reject, count]; if (!this.isConnection()) { // Fall back to a regular query if we're not connected. this.nymph.getEntity(options, ...selectors).then(resolve, reject); } this._subscribeQuery(jsonQuery, callbacks); return new PubSubSubscription(jsonQuery, callbacks, () => { this._unsubscribeQuery(jsonQuery, callbacks); }); }; return subscribe; } subscribeUID(name) { const subscribe = (resolve, reject, count) => { const callbacks = [resolve, reject, count]; if (!this.isConnection()) { // Fall back to a regular query if we're not connected. this.nymph.getUID(name).then(resolve, reject); } this._subscribeUID(name, callbacks); return { unsubscribe: () => { this._unsubscribeUID(name, callbacks); }, }; }; return subscribe; } subscribeWith(entity, resolve, reject, count) { if (!entity.guid) { throw new InvalidRequestError("You can't subscribe to an entity with no GUID."); } const query = [ { class: entity.constructor.class, limit: 1 }, { type: '&', guid: entity.guid }, ]; const jsonQuery = JSON.stringify(query); const newResolve = (args) => { if (Array.isArray(args)) { if (args.length) { entity.$init(args[0].toJSON()); } else { entity.guid = null; } } else if ('removed' in args) { entity.guid = null; } else { entity.$init(args.data); } if (resolve) { resolve(entity); } }; const callbacks = [newResolve, reject, count]; this._subscribeQuery(jsonQuery, callbacks); return new PubSubSubscription(jsonQuery, callbacks, () => { this._unsubscribeQuery(jsonQuery, callbacks); }); } connect() { // Are we already connected? if (this.connection && (this.connection.readyState === this.WebSocket.OPEN || this.connection.readyState === this.WebSocket.CONNECTING)) { return; } this._waitForConnection(); this._attemptConnect(); } close() { if (this.waitForConnectionTimeout) { clearTimeout(this.waitForConnectionTimeout); } if (!this.connection) { return; } this.connection.close(1000, 'Closure requested by application.'); } _waitForConnection(attempts = 1) { // Wait 5 seconds, then check and attempt connection again if unsuccessful. // Keep repeating, adding attempts^2*5 seconds each time to a max of ten // minutes, until successful. this.waitForConnectionTimeout = setTimeout(() => { if (this.connection) { if (this.connection.readyState !== this.WebSocket.OPEN) { if (this.connection.readyState !== this.WebSocket.CONNECTING) { this.connection.close(); this._waitForConnection(attempts + 1); this._attemptConnect(); } else { this._waitForConnection(attempts + 1); } } } else { this._attemptConnect(); } }, Math.max(Math.pow(attempts, 2) * 5000, 1000 * 60 * 10)); } _attemptConnect() { // Attempt to connect. if (this.pubsubUrl != null) { this.connection = new this.WebSocket(this.pubsubUrl, 'nymph'); this.connection.onopen = this._onopen.bind(this); this.connection.onmessage = this._onmessage.bind(this); } } _onopen() { if (typeof console !== 'undefined' && !this.noConsole) { console.log('Nymph-PubSub connection established!'); } if (this.waitForConnectionTimeout) { clearTimeout(this.waitForConnectionTimeout); } for (let i = 0; i < this.connectCallbacks.length; i++) { const callback = this.connectCallbacks[i]; if (callback) { callback(); } } if (this.authToken != null) { this._send({ action: 'authenticate', authToken: this.authToken, switchToken: this.switchToken, }); } for (let query in this.subscriptions.queries) { if (!this.subscriptions.queries.hasOwnProperty(query)) { continue; } let count = false; for (let callbacks = 0; callbacks < this.subscriptions.queries[query].length; callbacks++) { if (this.subscriptions.queries[query][callbacks][2]) { count = true; break; } } this._sendQuery(query, count); } for (let name in this.subscriptions.uids) { if (!this.subscriptions.uids.hasOwnProperty(name)) { continue; } let count = false; for (let callbacks = 0; callbacks < this.subscriptions.uids[name].length; callbacks++) { if (this.subscriptions.uids[name][callbacks][2]) { count = true; break; } } this._sendUID(name, count); } if (this.connection) { this.connection.onclose = this._onclose.bind(this); } } _onmessage(e) { let data = JSON.parse(e.data); let subs = []; let set = 'set' in data && data.set; let count = 'count' in data; let error = 'error' in data; if (data.hasOwnProperty('query') && this.subscriptions.queries.hasOwnProperty(data.query)) { subs = [...this.subscriptions.queries[data.query]]; if (!count && !error) { for (let i = 0; i < subs.length; i++) { const callback = subs[i][0]; if (typeof callback === 'function') { callback(set ? data.data.map((e) => this.nymph.initEntity(e)) : data); } } } } else if (data.hasOwnProperty('uid') && this.subscriptions.uids.hasOwnProperty(data.uid)) { subs = [...this.subscriptions.uids[data.uid]]; if (!count && !error) { for (let i = 0; i < subs.length; i++) { const callback = subs[i][0]; const errCallback = subs[i][1]; if (set && data.data == null) { if (typeof errCallback === 'function') { errCallback(new ClientError({ status: 404, statusText: 'Not Found' }, { textStatus: 'Not Found' })); } } else if (typeof callback === 'function') { if (set) { callback(data.data); } else { callback(data.value ?? null, data.event); } } } } } else if (error) { for (let i = 0; i < this.errorCallbacks.length; i++) { const callback = this.errorCallbacks[i]; if (callback) { callback(data.error); } } return; } if (count) { for (let i = 0; i < subs.length; i++) { const callback = subs[i][2]; if (typeof callback === 'function') { callback(data.count); } } } if (error) { for (let i = 0; i < subs.length; i++) { const callback = subs[i][1]; if (typeof callback === 'function') { callback(data.error); } } } } _onclose(e) { if (typeof console !== 'undefined' && !this.noConsole) { console.log(`Nymph-PubSub connection closed: ${e.code} ${e.reason}`); } for (let i = 0; i < this.disconnectCallbacks.length; i++) { const callback = this.disconnectCallbacks[i]; if (callback) { callback(); } } if (e.code !== 1000 && (typeof navigator === 'undefined' || navigator.onLine)) { if (this.connection) { this.connection.close(); } this._waitForConnection(); this._attemptConnect(); } } _send(data) { if (this.connection) { this.connection.send(JSON.stringify(data)); } } isConnectionOpen() { return !!(this.connection && this.connection.readyState === this.WebSocket.OPEN); } isConnectionConnecting() { return !!(this.connection && this.connection.readyState === this.WebSocket.CONNECTING); } isConnection() { return this.isConnectionOpen() || this.isConnectionConnecting(); } _subscribeQuery(query, callbacks) { let isNewSubscription = false; if (!this.subscriptions.queries.hasOwnProperty(query)) { this.subscriptions.queries[query] = []; isNewSubscription = true; } let isCountSubscribed = isNewSubscription ? false : this._isCountSubscribedQuery(query); this.subscriptions.queries[query].push(callbacks); if (this.isConnectionOpen()) { if (isNewSubscription) { this._sendQuery(query, !!callbacks[2]); } else if (!isCountSubscribed && callbacks[2]) { this._sendUnQuery(query); this._sendQuery(query, true); } } } _subscribeUID(name, callbacks) { let isNewSubscription = false; if (!this.subscriptions.uids.hasOwnProperty(name)) { this.subscriptions.uids[name] = []; isNewSubscription = true; } let isCountSubscribed = isNewSubscription ? false : this._isCountSubscribedUID(name); this.subscriptions.uids[name].push(callbacks); if (this.isConnectionOpen()) { if (isNewSubscription) { this._sendUID(name, !!callbacks[2]); } else if (!isCountSubscribed && callbacks[2]) { this._sendUnUID(name); this._sendUID(name, true); } } } _sendQuery(query, count) { this._send({ action: 'subscribe', query, count, }); } _sendUID(name, count) { this._send({ action: 'subscribe', uid: name, count, }); } _isCountSubscribedQuery(query) { if (!this.subscriptions.queries.hasOwnProperty(query)) { return false; } for (let callbacks = 0; callbacks < this.subscriptions.queries[query].length; callbacks++) { if (this.subscriptions.queries[query][callbacks][2]) { return true; } } return false; } _isCountSubscribedUID(name) { if (!this.subscriptions.uids.hasOwnProperty(name)) { return false; } for (let callbacks = 0; callbacks < this.subscriptions.uids[name].length; callbacks++) { if (this.subscriptions.uids[name][callbacks][2]) { return true; } } return false; } _unsubscribeQuery(query, callbacks) { if (!this.subscriptions.queries.hasOwnProperty(query)) { return; } const idx = this.subscriptions.queries[query].indexOf(callbacks); if (idx === -1) { return; } this.subscriptions.queries[query].splice(idx, 1); if (!this.subscriptions.queries[query].length) { delete this.subscriptions.queries[query]; if (this.isConnectionOpen()) { this._sendUnQuery(query); } } } _unsubscribeUID(name, callbacks) { if (!this.subscriptions.uids.hasOwnProperty(name)) { return; } const idx = this.subscriptions.uids[name].indexOf(callbacks); if (idx === -1) { return; } this.subscriptions.uids[name].splice(idx, 1); if (!this.subscriptions.uids[name].length) { delete this.subscriptions.uids[name]; if (this.isConnectionOpen()) { this._sendUnUID(name); } } } _sendUnQuery(query) { this._send({ action: 'unsubscribe', query: query, }); } _sendUnUID(name) { this._send({ action: 'unsubscribe', uid: name, }); } updateArray(current, update) { if (Array.isArray(update)) { const newArr = [...update]; if (current.length === 0) { // This will happen on the first update from a subscribe. current.splice(0, 0, ...newArr); return; } const idMap = {}; const newEntities = []; while (current.length) { const first = current.shift(); if (!first) { continue; } const guid = first.guid; if (!guid) { newEntities.push(first); continue; } idMap[guid] = first; } for (let i = 0; i < newArr.length; i++) { const entity = newArr[i]; const guid = entity.guid; if (guid == null) { continue; } if (!idMap.hasOwnProperty(guid)) { // It was added. current.push(entity); } else if (idMap[guid].mdate !== entity.mdate) { // It was modified. idMap[guid].$init(entity.toJSON()); current.push(idMap[guid]); } else { // Item wasn't modified. current.push(idMap[guid]); } } current.splice(current.length, 0, ...newEntities); } else if (update != null && update.hasOwnProperty('query')) { if ('removed' in update) { for (let i = 0; i < current.length; i++) { if (current[i] != null && current[i].guid === update.removed) { current.splice(i, 1); return; } } } // Get the entity. let entity = null; if ('added' in update) { // Check for it in the array already. for (let i = 0; i < current.length; i++) { if (current[i] != null && current[i].guid === update.added) { entity = current.splice(i, 1)[0].$init(update.data); } } if (entity == null) { // A new entity. entity = this.nymph.initEntity(update.data); } } if ('updated' in update) { // Extract it from the array. for (let i = 0; i < current.length; i++) { if (current[i] != null && current[i].guid === update.updated) { entity = current.splice(i, 1)[0].$init(update.data); } } } const query = JSON.parse(update.query); if (entity != null) { // Insert the entity in order. const sort = 'sort' in query[0] ? query[0].sort : 'cdate'; const reverse = query[0].hasOwnProperty('reverse') ? query[0].reverse : false; let i; if (reverse) { for (i = 0; ((current[i] ?? {})[sort] ?? 0) >= (entity[sort] ?? 0) && i < current.length; i++) ; } else { for (i = 0; ((current[i] ?? {})[sort] ?? 0) < (entity[sort] ?? 0) && i < current.length; i++) ; } current.splice(i, 0, entity); } } } on(event, callback) { const prop = (event + 'Callbacks'); if (!(prop in this)) { throw new Error('Invalid event type.'); } // @ts-ignore: The callback should always be the right type here. this[prop].push(callback); return () => this.off(event, callback); } off(event, callback) { const prop = (event + 'Callbacks'); if (!(prop in this)) { return false; } // @ts-ignore: The callback should always be the right type here. const i = this[prop].indexOf(callback); if (i > -1) { this[prop].splice(i, 1); } return true; } authenticate(authToken, switchToken = null) { this.authToken = authToken; this.switchToken = switchToken; if (this.isConnectionOpen()) { this._send({ action: 'authenticate', authToken: this.authToken, switchToken: this.switchToken, }); } } } export class PubSubSubscription { query; callbacks; unsubscribe; constructor(query, callbacks, unsubscribe) { this.query = query; this.callbacks = callbacks; this.unsubscribe = unsubscribe; } } //# sourceMappingURL=PubSub.js.map