@nymphjs/client
Version:
Nymph.js - Client
599 lines • 21.2 kB
JavaScript
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