Skip to content

Instantly share code, notes, and snippets.

@wearhere
Last active October 4, 2021 18:35
Show Gist options
  • Save wearhere/acf71ceba51a2e77c8ec to your computer and use it in GitHub Desktop.
Save wearhere/acf71ceba51a2e77c8ec to your computer and use it in GitHub Desktop.
A small, browser-only, read-only client for a Meteor backend. More info and examples at https://mixmax.com/blog/meteor-and-backbone.
/**
* A very lightweight version of Meteor's Minimongo collections,
* a local store for records sent from the Meteor backend.
*/
var LocalCollection = function() {
this._initialize();
};
_.extend(LocalCollection.prototype, Backbone.Events, {
_name: null,
_docs: null,
_initialize: function() {
this._docs = {};
},
_onAdded: function(id, fields) {
var doc = this._docs[id] = _.extend(_.clone(fields), { _id: id });
this.trigger('added', doc);
},
_onChanged: function(id, fields, cleared) {
var doc = this._docs[id];
if (!doc) {
throw new Error('Document has been changed without having been added!');
}
doc = this._docs[id] = _.chain(doc).extend(fields).omit(cleared).value();
// If a field was removed from the document then it should be present in _fields_ with a
// value of `undefined`.
var changeset = _.clone(fields);
if (!_.isEmpty(cleared)) {
_.extend(changeset,
_.object(cleared, _.times(cleared.length, _.constant(undefined))));
}
this.trigger('changed', doc, changeset);
},
_onRemoved: function(id) {
var doc = this._docs[id];
if (!doc) {
throw new Error('Document has been removed without having been added!');
}
delete this._docs[id];
this.trigger('removed', doc);
},
find: function(selector) {
return new ReactiveQuery(this, selector);
},
toArray: function() {
return _.values(this._docs);
}
});
window.LocalCollection = LocalCollection;
/**
* The interface to Meteor collections and subscriptions.
*/
var Meteor = {
_endpoint: null,
_connectedDeferred: null,
_loggedInDeferred: null,
_loginToken: null,
_connection: null,
_subscriptions: null,
_collections: null,
initialize: function(host, ssl) {
this._endpoint = (ssl ? "https://" : "http://") + host + "/sockjs";
this._connectedDeferred = new Deferred();
this._loggedInDeferred = new Deferred();
this._subscriptions = {};
this._collections = {};
},
whenConnected: function() {
return this._connectedDeferred.promise;
},
whenLoggedIn: function() {
return this._loggedInDeferred.promise;
},
connectUsingToken: function(loginToken) {
if (this._connection) throw new Error('Already connected!');
this._loginToken = loginToken;
this._connection = new DDP({
endpoint: this._endpoint,
SocketConstructor: SockJS
});
this._connection.once('connected', this._onConnectionSuccess.bind(this));
this._connection.once('failed', this._onConnectionFailure.bind(this));
// The Meteor instance observes the collection-change callbacks so that it can create
// `LocalCollection` instances to receive the changes if they don't already exist.
this._connection.on('added', this._onAdded.bind(this));
this._connection.on('changed', this._onChanged.bind(this));
this._connection.on('removed', this._onRemoved.bind(this));
},
subscribe: function(name /*, param1, param2… optional */) {
var params = _.rest(arguments);
// Hash the name and params to cache the subscription.
var subscriptionKey = JSON.stringify(_.toArray(arguments));
var subscription = this._subscriptions[subscriptionKey];
if (!subscription) {
subscription = this._subscriptions[subscriptionKey] = new Subscription(name, params, this);
}
return subscription;
},
getCollection: function(name) {
var collection = this._collections[name];
if (!collection) {
collection = this._collections[name] = new LocalCollection(name, this);
}
return collection;
},
_onConnectionSuccess: function() {
this._connectedDeferred.resolve();
this._login();
},
_onConnectionFailure: function() {
this._connectedDeferred.reject();
},
_login: function() {
var methodId = this._connection.method('login', [{ resume: this._loginToken }]);
var onResult = function(message) {
if (message.id === methodId) {
this._onLoginResult(message);
this._connection.off('result', onResult);
}
}.bind(this);
this._connection.on('result', onResult);
},
_onLoginResult: function(result) {
if (result.error) {
this._loggedInDeferred.reject(result.error);
} else {
this._loggedInDeferred.resolve();
}
},
// For some reason, document IDs may come prefixed with '-'.
// https://github.com/mondora/asteroid/issues/40
_fixMessageId: function(message) {
if (message.id[0] === '-') {
message.id = message.id.slice(1);
}
},
_onAdded: function(message) {
this._fixMessageId(message);
var collectionName = message.collection;
var id = message.id;
var fields = message.fields;
var collection = this.getCollection(collectionName);
collection._onAdded(id, fields);
},
_onChanged: function(message) {
this._fixMessageId(message);
var collectionName = message.collection;
var id = message.id;
var fields = message.fields;
var cleared = message.cleared;
var collection = this.getCollection(collectionName);
collection._onChanged(id, fields, cleared);
},
_onRemoved: function(message) {
this._fixMessageId(message);
var collectionName = message.collection;
var id = message.id;
var collection = this.getCollection(collectionName);
collection._onRemoved(id);
}
};
window.Meteor = Meteor;
/**
* Creates a reactive query.
*
* The query will emit 'added', 'changed', and 'removed' events with the same semantics as
* Meteor's `Mongo.Cursor.prototype.observeChanges` callbacks.
*
* @param {LocalCollection} collection - The collection to observe.
* @param {object} selector - The selector against which to match the records in the collection.
* The only form of selector supported at present is a simple set of key-value properties.
*/
var ReactiveQuery = function(collection, selector) {
this._initialize(collection, selector);
};
_.extend(ReactiveQuery.prototype, Backbone.Events, {
_collection: null,
_selector: null,
_initialize: function(collection, selector) {
this._collection = collection;
this._selector = selector;
this._collection.on('added', this._onAdded.bind(this));
this._collection.on('changed', this._onChanged.bind(this));
this._collection.on('removed', this._onRemoved.bind(this));
},
_onAdded: function(doc) {
if (_.isMatch(doc, this._selector)) {
// _fields_ should have all fields of the document excluding the `_id` field.
this.trigger('added', doc._id, _.omit(doc, '_id'));
}
},
_onChanged: function(doc, changeset) {
if (_.isMatch(doc, this._selector)) {
this.trigger('changed', doc._id, changeset);
}
},
_onRemoved: function(doc) {
if (_.isMatch(doc, this._selector)) {
this.trigger('removed', doc._id);
}
},
fetch: function() {
return _.filter(this._collection.toArray(), _.matcher(this._selector));
}
});
window.ReactiveQuery = ReactiveQuery;
/**
* The object returned by `Meteor.subscribe`.
*/
var Subscription = function(name, params, meteor) {
this._initialize(name, params, meteor);
};
_.extend(Subscription.prototype, {
_meteor: null,
_readyDeferred: null,
_boundOnReady: null,
whenReady: function() {
return this._readyDeferred.promise;
},
_initialize: function(name, params, meteor) {
this._meteor = meteor;
this._readyDeferred = new Deferred();
// HACK(jeff): We _technically_ only need to be connected in order to subscribe. However, it's
// almost certain that the subscription won't return with any data until the user's logged in,
// so the 'ready' event won't be that useful unless we wait.
this._meteor.whenLoggedIn().then(function() {
this._id = this._meteor._connection.sub(name, params);
this._meteor._connection.on('ready', this._boundOnReady);
}.bind(this));
this._boundOnReady = this._onReady.bind(this);
},
_onReady: function(message) {
var readySubs = message.subs;
if (_.contains(readySubs, this._id)) {
this._readyDeferred.resolve();
this._meteor._connection.off('ready', this._boundOnReady);
}
}
});
window.Subscription = Subscription;
@wearhere
Copy link
Author

This will be renamed/polished/packaged as a full-fledge open-source project soon. In the meantime, this requires:

And a Deferred implementation e.g. ES6 Promises (already implemented in all modern browsers) plus this:

var Deferred = function() {
  this.promise = new Promise(function(resolve, reject) {
    this.resolve = resolve;
    this.reject = reject;
  }.bind(this));
};

window.Deferred = Deferred;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment