import Tracker from './Tracker.js';
import EJSON from 'ejson';
import DDP from '../lib/ddp.js';
import Random from '../lib/Random';
import Data from './Data';
import Mongo from './Mongo';
import { Collection, getObservers, localCollections } from './Collection';
import call from './Call';
import withTracker from './components/withTracker';
import useTracker from './components/useTracker';
import ReactiveDict from './ReactiveDict';
/**
* @namespace Meteor
* @type {object}
* @summary the main Object to interact with this library
*/
const Meteor = {
isVerbose: false,
/**
* Calling this enables extended internal logging to console
*/
enableVerbose() {
this.isVerbose = true;
},
_reactiveDict: new ReactiveDict(),
Random,
Mongo,
Tracker,
EJSON,
ReactiveDict,
Collection,
collection() {
throw new Error('Meteor.collection is deprecated. Use Mongo.Collection');
},
withTracker,
useTracker,
/**
* returns the Data layer implementation
* @returns {Data}
*/
getData() {
return Data;
},
/**
* Reactive. Returns the current connection status.
* @returns {object} `{connected: boolean, status: string}`
*/
status() {
return {
connected: !!this._reactiveDict.get('connected'),
status: Data.ddp ? Data.ddp.status : 'disconnected',
//retryCount: 0
//retryTime:
//reason:
};
},
removing: {},
call: call,
disconnect() {
if (Data.ddp) {
Data.ddp.disconnect();
}
},
_subscriptionsRestart() {
for (var i in Data.subscriptions) {
const sub = Data.subscriptions[i];
Data.ddp.unsub(sub.subIdRemember);
this.removing[sub.subIdRemember] = true;
sub.subIdRemember = Data.ddp.sub(sub.name, sub.params);
}
// If we get a double restart, make sure we keep track and
// remove it later
Object.keys(this.removing).forEach((item) => {
Data.ddp.unsub(item);
});
},
waitDdpConnected: Data.waitDdpConnected.bind(Data),
reconnect() {
Data.ddp && Data.ddp.connect();
},
packageInterface: () => {
return {
AsyncStorage:
Data._options.AsyncStorage ||
require('@react-native-async-storage/async-storage').default,
};
},
/**
* Connect to a Meteor server using a given websocket endpoint.
* The endpoint needs to start with `ws://` or `wss://`
* and has to end with `/websocket`.
*
* @param endpoint {string} required, websocket of Meteor server to connect with
* @param options {object=} optional options
* @param options.suppressUrlErrors {boolean=} suppress error when websocket endpoint is invalid
* @param options.AsyncStorage {AsyncStorage=} suppress error when websocket endpoint is invalid
* @param options.reachabilityUrl {string=} a URL that is used by @react-native-community/netinfo to run a connection
* check using a 204 request
*/
connect(endpoint, options) {
if (!endpoint) endpoint = Data._endpoint;
if (!options) options = Data._options;
if (
(!endpoint.startsWith('ws') || !endpoint.endsWith('/websocket')) &&
!options.suppressUrlErrors
) {
throw new Error(
`Your url "${endpoint}" may be in the wrong format. It should start with "ws://" or "wss://" and end with "/websocket", e.g. "wss://myapp.meteor.com/websocket". To disable this warning, connect with option "suppressUrlErrors" as true, e.g. Meteor.connect("${endpoint}", {suppressUrlErrors:true});`
);
}
if (!options.AsyncStorage) {
const AsyncStorage =
require('@react-native-async-storage/async-storage').default;
if (AsyncStorage) {
options.AsyncStorage = AsyncStorage;
} else {
throw new Error(
'No AsyncStorage detected. Import an AsyncStorage package and add to `options` in the Meteor.connect() method'
);
}
}
Data._endpoint = endpoint;
Data._options = options;
const ddp = new DDP({
endpoint: endpoint,
SocketConstructor: WebSocket,
...options,
});
Data.ddp = ddp;
this.ddp = ddp;
Data.ddp.on('connected', () => {
// Clear the collections of any stale data in case this is a reconnect
if (Data.db && Data.db.collections) {
for (var collection in Data.db.collections) {
if (!localCollections.includes(collection)) {
// Dont clear data from local collections
Data.db[collection].remove({});
}
}
}
if (this.isVerbose) {
console.info('Connected to DDP server.');
}
this._loadInitialUser().then(() => {
this._subscriptionsRestart();
});
this._reactiveDict.set('connected', true);
this.connected = true;
Data.notify('change');
});
let lastDisconnect = null;
Data.ddp.on('disconnected', () => {
this.connected = false;
this._reactiveDict.set('connected', false);
Data.notify('change');
if (this.isVerbose) {
console.info('Disconnected from DDP server.');
}
// Mark subscriptions as ready=false
for (var i in Data.subscriptions) {
const sub = Data.subscriptions[i];
sub.ready = false;
sub.readyDeps.changed();
}
if (!Data.ddp.autoReconnect) return;
if (!lastDisconnect || new Date() - lastDisconnect > 3000) {
Data.ddp.connect();
}
lastDisconnect = new Date();
});
Data.ddp.on('added', (message) => {
if (!Data.db[message.collection]) {
Data.db.addCollection(message.collection);
}
const document = {
_id: message.id,
...message.fields,
};
Data.db[message.collection].upsert(document);
let observers = getObservers('added', message.collection, document);
observers.forEach((callback) => {
try {
callback(document, null);
} catch (e) {
console.error('Error in observe callback', e);
}
});
});
Data.ddp.on('ready', (message) => {
const idsMap = new Map();
for (var i in Data.subscriptions) {
const sub = Data.subscriptions[i];
idsMap.set(sub.subIdRemember, sub.id);
}
for (var i in message.subs) {
const subId = idsMap.get(message.subs[i]);
if (subId) {
const sub = Data.subscriptions[subId];
sub.ready = true;
sub.readyDeps.changed();
sub.readyCallback && sub.readyCallback();
}
}
});
Data.ddp.on('changed', (message) => {
const unset = {};
if (message.cleared) {
message.cleared.forEach((field) => {
unset[field] = null;
});
}
if (Data.db[message.collection]) {
const document = {
_id: message.id,
...message.fields,
...unset,
};
const oldDocument = Data.db[message.collection].findOne({
_id: message.id,
});
Data.db[message.collection].upsert(document);
let observers = getObservers('changed', message.collection, document);
observers.forEach((callback) => {
try {
callback(document, oldDocument);
} catch (e) {
console.error('Error in observe callback', e);
}
});
}
});
Data.ddp.on('removed', (message) => {
if (Data.db[message.collection]) {
const oldDocument = Data.db[message.collection].findOne({
_id: message.id,
});
let observers = getObservers(
'removed',
message.collection,
oldDocument
);
Data.db[message.collection].del(message.id);
observers.forEach((callback) => {
try {
callback(null, oldDocument);
} catch (e) {
console.error('Error in observe callback', e);
}
});
}
});
Data.ddp.on('result', (message) => {
const call = Data.calls.find((call) => call.id == message.id);
if (typeof call.callback == 'function')
call.callback(message.error, message.result);
Data.calls.splice(
Data.calls.findIndex((call) => call.id == message.id),
1
);
});
Data.ddp.on('nosub', (message) => {
if (this.removing[message.id]) {
delete this.removing[message.id];
}
for (var i in Data.subscriptions) {
const sub = Data.subscriptions[i];
if (sub.subIdRemember == message.id) {
console.warn('No subscription existing for', sub.name);
}
}
});
if (options.NetInfo !== null) {
try {
const NetInfo = getNetInfo(options.NetInfo);
if (options.reachabilityUrl) {
NetInfo.configure({
reachabilityUrl: options.reachabilityUrl,
useNativeReachability: true,
});
}
// Reconnect if we lose internet
NetInfo.addEventListener(
({ type, isConnected, isInternetReachable, isWifiEnabled }) => {
if (isConnected && Data.ddp.autoReconnect) {
Data.ddp.connect();
}
}
);
} catch (e) {
console.warn(
'Warning: NetInfo not installed, so DDP will not automatically reconnect'
);
}
}
},
subscribe(name) {
let params = Array.prototype.slice.call(arguments, 1);
let callbacks = {};
if (params.length) {
let lastParam = params[params.length - 1];
if (typeof lastParam == 'function') {
callbacks.onReady = params.pop();
} else if (
lastParam &&
(typeof lastParam.onReady == 'function' ||
typeof lastParam.onError == 'function' ||
typeof lastParam.onStop == 'function')
) {
callbacks = params.pop();
}
}
// Is there an existing sub with the same name and param, run in an
// invalidated Computation? This will happen if we are rerunning an
// existing computation.
//
// For example, consider a rerun of:
//
// Tracker.autorun(function () {
// Meteor.subscribe("foo", Session.get("foo"));
// Meteor.subscribe("bar", Session.get("bar"));
// });
//
// If "foo" has changed but "bar" has not, we will match the "bar"
// subcribe to an existing inactive subscription in order to not
// unsub and resub the subscription unnecessarily.
//
// We only look for one such sub; if there are N apparently-identical subs
// being invalidated, we will require N matching subscribe calls to keep
// them all active.
let existing = false;
for (let i in Data.subscriptions) {
const sub = Data.subscriptions[i];
if (sub.inactive && sub.name === name && EJSON.equals(sub.params, params))
existing = sub;
}
let id;
if (existing) {
id = existing.id;
existing.inactive = false;
if (callbacks.onReady) {
// If the sub is not already ready, replace any ready callback with the
// one provided now. (It's not really clear what users would expect for
// an onReady callback inside an autorun; the semantics we provide is
// that at the time the sub first becomes ready, we call the last
// onReady callback provided, if any.)
if (!existing.ready) existing.readyCallback = callbacks.onReady;
}
if (callbacks.onStop) {
existing.stopCallback = callbacks.onStop;
}
} else {
// New sub! Generate an id, save it locally, and send message.
id = Random.id();
const subIdRemember = Data.ddp.sub(name, params);
// TODO subscription object should be represented by
// a Subscription data-class
Data.subscriptions[id] = {
id: id,
subIdRemember: subIdRemember,
name: name,
params: EJSON.clone(params),
inactive: false,
ready: false,
readyDeps: new Tracker.Dependency(),
readyCallback: callbacks.onReady,
stopCallback: callbacks.onStop,
stop: function () {
Data.ddp.unsub(this.subIdRemember);
delete Data.subscriptions[this.id];
this.ready && this.readyDeps.changed();
if (callbacks.onStop) {
callbacks.onStop();
}
},
};
}
// return a handle to the application.
// TODO represent handle by a SubscriptionHandle class
var handle = {
stop: function () {
if (Data.subscriptions[id]) Data.subscriptions[id].stop();
},
ready: function () {
if (!Data.subscriptions[id]) return false;
let record = Data.subscriptions[id];
record.readyDeps.depend();
return record.ready;
},
subscriptionId: id,
};
if (Tracker.active) {
// We're in a reactive computation, so we'd like to unsubscribe when the
// computation is invalidated... but not if the rerun just re-subscribes
// to the same subscription! When a rerun happens, we use onInvalidate
// as a change to mark the subscription "inactive" so that it can
// be reused from the rerun. If it isn't reused, it's killed from
// an afterFlush.
Tracker.onInvalidate(function (c) {
if (Data.subscriptions[id]) {
Data.subscriptions[id].inactive = true;
}
Tracker.afterFlush(function () {
if (Data.subscriptions[id] && Data.subscriptions[id].inactive) {
handle.stop();
}
});
});
} else {
if (Data.subscriptions[id]) {
Data.subscriptions[id].inactive = true;
}
}
return handle;
},
};
const getNetInfo = (NetInfo) =>
NetInfo ? NetInfo : require('@react-native-community/netinfo').default;
export default Meteor;