diff --git a/npm-packages/eslint-plugin-meteor/scripts/dev-bundle-tool-package.js b/npm-packages/eslint-plugin-meteor/scripts/dev-bundle-tool-package.js index 6d107c5bf8..17b63e201f 100644 --- a/npm-packages/eslint-plugin-meteor/scripts/dev-bundle-tool-package.js +++ b/npm-packages/eslint-plugin-meteor/scripts/dev-bundle-tool-package.js @@ -15,7 +15,7 @@ var packageJson = { "node-gyp": "8.0.0", "node-pre-gyp": "0.15.0", typescript: "4.5.4", - "@meteorjs/babel": "7.16.0-beta.1", + "@meteorjs/babel": "7.16.0-beta.7", // Keep the versions of these packages consistent with the versions // found in dev-bundle-server-package.js. "meteor-promise": "0.9.0", diff --git a/npm-packages/meteor-babel/package-lock.json b/npm-packages/meteor-babel/package-lock.json index 7fec2af89a..8e5d6a7e5d 100644 --- a/npm-packages/meteor-babel/package-lock.json +++ b/npm-packages/meteor-babel/package-lock.json @@ -1,6 +1,6 @@ { "name": "@meteorjs/babel", - "version": "7.16.0-beta.1", + "version": "7.16.0-beta.7", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/npm-packages/meteor-babel/package.json b/npm-packages/meteor-babel/package.json index a896f5a3a1..5fbdf188b9 100644 --- a/npm-packages/meteor-babel/package.json +++ b/npm-packages/meteor-babel/package.json @@ -1,7 +1,7 @@ { "name": "@meteorjs/babel", "author": "Meteor ", - "version": "7.16.0-beta.1", + "version": "7.16.0-beta.7", "license": "MIT", "description": "Babel wrapper package for use with Meteor", "keywords": [ @@ -37,7 +37,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.16.8", "@babel/plugin-transform-runtime": "^7.17.0", "@babel/preset-react": "^7.16.7", - "@babel/runtime": "^7.17.2", + "@babel/runtime": "7.17.2", "@babel/template": "^7.16.7", "@babel/traverse": "^7.17.0", "@babel/types": "^7.17.0", diff --git a/npm-packages/meteor-babel/runtime.js b/npm-packages/meteor-babel/runtime.js index c4e69bd468..438c9ef09d 100644 --- a/npm-packages/meteor-babel/runtime.js +++ b/npm-packages/meteor-babel/runtime.js @@ -11,7 +11,7 @@ Module.prototype.resolve = function (id) { require("@meteorjs/reify/lib/runtime").enable(Module.prototype); -if (!!!process.env.DISABLE_FIBERS) { +if (!process.env.DISABLE_FIBERS) { require("meteor-promise").makeCompatible( global.Promise = global.Promise || require("promise/lib/es6-extensions"), diff --git a/packages/accounts-base/package.js b/packages/accounts-base/package.js index 755421c580..32a6df946c 100644 --- a/packages/accounts-base/package.js +++ b/packages/accounts-base/package.js @@ -24,11 +24,8 @@ Package.onUse(api => { // need this because of the Meteor.users collection but in the future // we'd probably want to abstract this away - if (!process.env.DISABLE_FIBERS) { - api.use('mongo', ['client', 'server']); - } else { - api.use('mongo-async', ['client', 'server']); - } + api.use('mongo', ['client', 'server']); + // If the 'blaze' package is loaded, we'll define some helpers like // {{currentUser}}. If not, no biggie. api.use('blaze@2.5.0', 'client', { weak: true }); diff --git a/packages/accounts-password/password_server.js b/packages/accounts-password/password_server.js index 812bf848d1..2e6d57c075 100644 --- a/packages/accounts-password/password_server.js +++ b/packages/accounts-password/password_server.js @@ -1025,7 +1025,8 @@ Accounts.createUserAsync = async (options, callback) => { // method calling Accounts.createUser could set? // -Accounts.createUser = (options, callback) => { +Accounts.createUser = !Meteor._isFibersEnabled ? Accounts.createUserAsync + : (options, callback) => { return Promise.await(Accounts.createUserAsync(options, callback)); }; diff --git a/packages/babel-compiler/package.js b/packages/babel-compiler/package.js index 40999ff266..32515319fb 100644 --- a/packages/babel-compiler/package.js +++ b/packages/babel-compiler/package.js @@ -5,7 +5,7 @@ Package.describe({ }); Npm.depends({ - '@meteorjs/babel': '7.16.0-beta.1', + '@meteorjs/babel': '7.16.0-beta.7', 'json5': '2.1.1' }); diff --git a/packages/ddp-client-async/.npm/package/.gitignore b/packages/ddp-client-async/.npm/package/.gitignore new file mode 100644 index 0000000000..3c3629e647 --- /dev/null +++ b/packages/ddp-client-async/.npm/package/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/packages/ddp-client-async/.npm/package/README b/packages/ddp-client-async/.npm/package/README new file mode 100644 index 0000000000..3d492553a4 --- /dev/null +++ b/packages/ddp-client-async/.npm/package/README @@ -0,0 +1,7 @@ +This directory and the files immediately inside it are automatically generated +when you change this package's NPM dependencies. Commit the files in this +directory (npm-shrinkwrap.json, .gitignore, and this README) to source control +so that others run the same versions of sub-dependencies. + +You should NOT check in the node_modules directory that Meteor automatically +creates; if you are using git, the .gitignore file tells git to ignore it. diff --git a/packages/ddp-client-async/.npm/package/npm-shrinkwrap.json b/packages/ddp-client-async/.npm/package/npm-shrinkwrap.json new file mode 100644 index 0000000000..28feb731a6 --- /dev/null +++ b/packages/ddp-client-async/.npm/package/npm-shrinkwrap.json @@ -0,0 +1,25 @@ +{ + "lockfileVersion": 1, + "dependencies": { + "@sinonjs/commons": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", + "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==" + }, + "@sinonjs/fake-timers": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-7.0.5.tgz", + "integrity": "sha512-fUt6b15bjV/VW93UP5opNXJxdwZSbK1EdiwnhN7XrQrcpaOhMJpZ/CjwFpM3THpxwA+YviBUJKSuEqKlCK5alw==" + }, + "es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==" + } + } +} diff --git a/packages/ddp-client-async/README.md b/packages/ddp-client-async/README.md new file mode 100644 index 0000000000..9daa0a6277 --- /dev/null +++ b/packages/ddp-client-async/README.md @@ -0,0 +1,4 @@ +# ddp-client +[Source code of released version](https://github.com/meteor/meteor/tree/master/packages/ddp-client) | [Source code of development version](https://github.com/meteor/meteor/tree/devel/packages/ddp-client) +*** + diff --git a/packages/ddp-client-async/client/client.js b/packages/ddp-client-async/client/client.js new file mode 100644 index 0000000000..fd9c746bbc --- /dev/null +++ b/packages/ddp-client-async/client/client.js @@ -0,0 +1,6 @@ +export { DDP } from '../common/namespace.js'; + +import '../common/livedata_connection'; + +// Initialize the default server connection and put it on Meteor.connection +import './client_convenience'; diff --git a/packages/ddp-client-async/client/client_convenience.js b/packages/ddp-client-async/client/client_convenience.js new file mode 100644 index 0000000000..7fc3222106 --- /dev/null +++ b/packages/ddp-client-async/client/client_convenience.js @@ -0,0 +1,59 @@ +import { DDP } from '../common/namespace.js'; +import { Meteor } from 'meteor/meteor'; + +// Meteor.refresh can be called on the client (if you're in common code) but it +// only has an effect on the server. +Meteor.refresh = () => {}; + +// By default, try to connect back to the same endpoint as the page +// was served from. +// +// XXX We should be doing this a different way. Right now we don't +// include ROOT_URL_PATH_PREFIX when computing ddpUrl. (We don't +// include it on the server when computing +// DDP_DEFAULT_CONNECTION_URL, and we don't include it in our +// default, '/'.) We get by with this because DDP.connect then +// forces the URL passed to it to be interpreted relative to the +// app's deploy path, even if it is absolute. Instead, we should +// make DDP_DEFAULT_CONNECTION_URL, if set, include the path prefix; +// make the default ddpUrl be '' rather that '/'; and make +// _translateUrl in stream_client_common.js not force absolute paths +// to be treated like relative paths. See also +// stream_client_common.js #RationalizingRelativeDDPURLs +const runtimeConfig = typeof __meteor_runtime_config__ !== 'undefined' ? __meteor_runtime_config__ : Object.create(null); +const ddpUrl = runtimeConfig.DDP_DEFAULT_CONNECTION_URL || '/'; + +const retry = new Retry(); + +function onDDPVersionNegotiationFailure(description) { + Meteor._debug(description); + if (Package.reload) { + const migrationData = Package.reload.Reload._migrationData('livedata') || Object.create(null); + let failures = migrationData.DDPVersionNegotiationFailures || 0; + ++failures; + Package.reload.Reload._onMigrate('livedata', () => [true, { DDPVersionNegotiationFailures: failures }]); + retry.retryLater(failures, () => { + Package.reload.Reload._reload({ immediateMigration: true }); + }); + } +} + +Meteor.connection = DDP.connect(ddpUrl, { + onDDPVersionNegotiationFailure: onDDPVersionNegotiationFailure +}); + +// Proxy the public methods of Meteor.connection so they can +// be called directly on Meteor. +[ + 'subscribe', + 'methods', + 'call', + 'callAsync', + 'apply', + 'applyAsync', + 'status', + 'reconnect', + 'disconnect' +].forEach(name => { + Meteor[name] = Meteor.connection[name].bind(Meteor.connection); +}); diff --git a/packages/ddp-client-async/common/MethodInvoker.js b/packages/ddp-client-async/common/MethodInvoker.js new file mode 100644 index 0000000000..f2490b92f8 --- /dev/null +++ b/packages/ddp-client-async/common/MethodInvoker.js @@ -0,0 +1,85 @@ +// A MethodInvoker manages sending a method to the server and calling the user's +// callbacks. On construction, it registers itself in the connection's +// _methodInvokers map; it removes itself once the method is fully finished and +// the callback is invoked. This occurs when it has both received a result, +// and the data written by it is fully visible. +export default class MethodInvoker { + constructor(options) { + // Public (within this file) fields. + this.methodId = options.methodId; + this.sentMessage = false; + + this._callback = options.callback; + this._connection = options.connection; + this._message = options.message; + this._onResultReceived = options.onResultReceived || (() => {}); + this._wait = options.wait; + this.noRetry = options.noRetry; + this._methodResult = null; + this._dataVisible = false; + + // Register with the connection. + this._connection._methodInvokers[this.methodId] = this; + } + // Sends the method message to the server. May be called additional times if + // we lose the connection and reconnect before receiving a result. + sendMessage() { + // This function is called before sending a method (including resending on + // reconnect). We should only (re)send methods where we don't already have a + // result! + if (this.gotResult()) + throw new Error('sendingMethod is called on method with result'); + + // If we're re-sending it, it doesn't matter if data was written the first + // time. + this._dataVisible = false; + this.sentMessage = true; + + // If this is a wait method, make all data messages be buffered until it is + // done. + if (this._wait) + this._connection._methodsBlockingQuiescence[this.methodId] = true; + + // Actually send the message. + this._connection._send(this._message); + } + // Invoke the callback, if we have both a result and know that all data has + // been written to the local cache. + _maybeInvokeCallback() { + if (this._methodResult && this._dataVisible) { + // Call the callback. (This won't throw: the callback was wrapped with + // bindEnvironment.) + this._callback(this._methodResult[0], this._methodResult[1]); + + // Forget about this method. + delete this._connection._methodInvokers[this.methodId]; + + // Let the connection know that this method is finished, so it can try to + // move on to the next block of methods. + this._connection._outstandingMethodFinished(); + } + } + // Call with the result of the method from the server. Only may be called + // once; once it is called, you should not call sendMessage again. + // If the user provided an onResultReceived callback, call it immediately. + // Then invoke the main callback if data is also visible. + receiveResult(err, result) { + if (this.gotResult()) + throw new Error('Methods should only receive results once'); + this._methodResult = [err, result]; + this._onResultReceived(err, result); + this._maybeInvokeCallback(); + } + // Call this when all data written by the method is visible. This means that + // the method has returns its "data is done" message *AND* all server + // documents that are buffered at that time have been written to the local + // cache. Invokes the main callback if the result has been received. + dataVisible() { + this._dataVisible = true; + this._maybeInvokeCallback(); + } + // True if receiveResult has been called. + gotResult() { + return !!this._methodResult; + } +} diff --git a/packages/ddp-client-async/common/livedata_connection.js b/packages/ddp-client-async/common/livedata_connection.js new file mode 100644 index 0000000000..c30ff6f48d --- /dev/null +++ b/packages/ddp-client-async/common/livedata_connection.js @@ -0,0 +1,1899 @@ +import { Meteor } from 'meteor/meteor'; +import { DDPCommon } from 'meteor/ddp-common'; +import { Tracker } from 'meteor/tracker'; +import { EJSON } from 'meteor/ejson'; +import { Random } from 'meteor/random'; +import { Hook } from 'meteor/callback-hook'; +import { MongoID } from 'meteor/mongo-id'; +import { DDP } from './namespace.js'; +import MethodInvoker from './MethodInvoker.js'; +import { + hasOwn, + slice, + keys, + isEmpty, + last, +} from "meteor/ddp-common/utils.js"; + +let Fiber; +let Future; +if (Meteor.isServer) { + Fiber = Npm.require('fibers'); + Future = Npm.require('fibers/future'); +} + +class MongoIDMap extends IdMap { + constructor() { + super(MongoID.idStringify, MongoID.idParse); + } +} + +// @param url {String|Object} URL to Meteor app, +// or an object as a test hook (see code) +// Options: +// reloadWithOutstanding: is it OK to reload if there are outstanding methods? +// headers: extra headers to send on the websockets connection, for +// server-to-server DDP only +// _sockjsOptions: Specifies options to pass through to the sockjs client +// onDDPNegotiationVersionFailure: callback when version negotiation fails. +// +// XXX There should be a way to destroy a DDP connection, causing all +// outstanding method calls to fail. +// +// XXX Our current way of handling failure and reconnection is great +// for an app (where we want to tolerate being disconnected as an +// expect state, and keep trying forever to reconnect) but cumbersome +// for something like a command line tool that wants to make a +// connection, call a method, and print an error if connection +// fails. We should have better usability in the latter case (while +// still transparently reconnecting if it's just a transient failure +// or the server migrating us). +export class Connection { + constructor(url, options) { + const self = this; + + this.options = options = { + onConnected() {}, + onDDPVersionNegotiationFailure(description) { + Meteor._debug(description); + }, + heartbeatInterval: 17500, + heartbeatTimeout: 15000, + npmFayeOptions: Object.create(null), + // These options are only for testing. + reloadWithOutstanding: false, + supportedDDPVersions: DDPCommon.SUPPORTED_DDP_VERSIONS, + retry: true, + respondToPings: true, + // When updates are coming within this ms interval, batch them together. + bufferedWritesInterval: 5, + // Flush buffers immediately if writes are happening continuously for more than this many ms. + bufferedWritesMaxAge: 500, + + ...options + }; + + // If set, called when we reconnect, queuing method calls _before_ the + // existing outstanding ones. + // NOTE: This feature has been preserved for backwards compatibility. The + // preferred method of setting a callback on reconnect is to use + // DDP.onReconnect. + self.onReconnect = null; + + // as a test hook, allow passing a stream instead of a url. + if (typeof url === 'object') { + self._stream = url; + } else { + const { ClientStream } = require("meteor/socket-stream-client"); + self._stream = new ClientStream(url, { + retry: options.retry, + ConnectionError: DDP.ConnectionError, + headers: options.headers, + _sockjsOptions: options._sockjsOptions, + // Used to keep some tests quiet, or for other cases in which + // the right thing to do with connection errors is to silently + // fail (e.g. sending package usage stats). At some point we + // should have a real API for handling client-stream-level + // errors. + _dontPrintErrors: options._dontPrintErrors, + connectTimeoutMs: options.connectTimeoutMs, + npmFayeOptions: options.npmFayeOptions + }); + } + + self._lastSessionId = null; + self._versionSuggestion = null; // The last proposed DDP version. + self._version = null; // The DDP version agreed on by client and server. + self._stores = Object.create(null); // name -> object with methods + self._methodHandlers = Object.create(null); // name -> func + self._nextMethodId = 1; + self._supportedDDPVersions = options.supportedDDPVersions; + + self._heartbeatInterval = options.heartbeatInterval; + self._heartbeatTimeout = options.heartbeatTimeout; + + // Tracks methods which the user has tried to call but which have not yet + // called their user callback (ie, they are waiting on their result or for all + // of their writes to be written to the local cache). Map from method ID to + // MethodInvoker object. + self._methodInvokers = Object.create(null); + + // Tracks methods which the user has called but whose result messages have not + // arrived yet. + // + // _outstandingMethodBlocks is an array of blocks of methods. Each block + // represents a set of methods that can run at the same time. The first block + // represents the methods which are currently in flight; subsequent blocks + // must wait for previous blocks to be fully finished before they can be sent + // to the server. + // + // Each block is an object with the following fields: + // - methods: a list of MethodInvoker objects + // - wait: a boolean; if true, this block had a single method invoked with + // the "wait" option + // + // There will never be adjacent blocks with wait=false, because the only thing + // that makes methods need to be serialized is a wait method. + // + // Methods are removed from the first block when their "result" is + // received. The entire first block is only removed when all of the in-flight + // methods have received their results (so the "methods" list is empty) *AND* + // all of the data written by those methods are visible in the local cache. So + // it is possible for the first block's methods list to be empty, if we are + // still waiting for some objects to quiesce. + // + // Example: + // _outstandingMethodBlocks = [ + // {wait: false, methods: []}, + // {wait: true, methods: []}, + // {wait: false, methods: [, + // ]}] + // This means that there were some methods which were sent to the server and + // which have returned their results, but some of the data written by + // the methods may not be visible in the local cache. Once all that data is + // visible, we will send a 'login' method. Once the login method has returned + // and all the data is visible (including re-running subs if userId changes), + // we will send the 'foo' and 'bar' methods in parallel. + self._outstandingMethodBlocks = []; + + // method ID -> array of objects with keys 'collection' and 'id', listing + // documents written by a given method's stub. keys are associated with + // methods whose stub wrote at least one document, and whose data-done message + // has not yet been received. + self._documentsWrittenByStub = {}; + // collection -> IdMap of "server document" object. A "server document" has: + // - "document": the version of the document according the + // server (ie, the snapshot before a stub wrote it, amended by any changes + // received from the server) + // It is undefined if we think the document does not exist + // - "writtenByStubs": a set of method IDs whose stubs wrote to the document + // whose "data done" messages have not yet been processed + self._serverDocuments = {}; + + // Array of callbacks to be called after the next update of the local + // cache. Used for: + // - Calling methodInvoker.dataVisible and sub ready callbacks after + // the relevant data is flushed. + // - Invoking the callbacks of "half-finished" methods after reconnect + // quiescence. Specifically, methods whose result was received over the old + // connection (so we don't re-send it) but whose data had not been made + // visible. + self._afterUpdateCallbacks = []; + + // In two contexts, we buffer all incoming data messages and then process them + // all at once in a single update: + // - During reconnect, we buffer all data messages until all subs that had + // been ready before reconnect are ready again, and all methods that are + // active have returned their "data done message"; then + // - During the execution of a "wait" method, we buffer all data messages + // until the wait method gets its "data done" message. (If the wait method + // occurs during reconnect, it doesn't get any special handling.) + // all data messages are processed in one update. + // + // The following fields are used for this "quiescence" process. + + // This buffers the messages that aren't being processed yet. + self._messagesBufferedUntilQuiescence = []; + // Map from method ID -> true. Methods are removed from this when their + // "data done" message is received, and we will not quiesce until it is + // empty. + self._methodsBlockingQuiescence = {}; + // map from sub ID -> true for subs that were ready (ie, called the sub + // ready callback) before reconnect but haven't become ready again yet + self._subsBeingRevived = {}; // map from sub._id -> true + // if true, the next data update should reset all stores. (set during + // reconnect.) + self._resetStores = false; + + // name -> array of updates for (yet to be created) collections + self._updatesForUnknownStores = {}; + // if we're blocking a migration, the retry func + self._retryMigrate = null; + + self.__flushBufferedWrites = Meteor.bindEnvironment( + self._flushBufferedWrites, + 'flushing DDP buffered writes', + self + ); + // Collection name -> array of messages. + self._bufferedWrites = {}; + // When current buffer of updates must be flushed at, in ms timestamp. + self._bufferedWritesFlushAt = null; + // Timeout handle for the next processing of all pending writes + self._bufferedWritesFlushHandle = null; + + self._bufferedWritesInterval = options.bufferedWritesInterval; + self._bufferedWritesMaxAge = options.bufferedWritesMaxAge; + + // metadata for subscriptions. Map from sub ID to object with keys: + // - id + // - name + // - params + // - inactive (if true, will be cleaned up if not reused in re-run) + // - ready (has the 'ready' message been received?) + // - readyCallback (an optional callback to call when ready) + // - errorCallback (an optional callback to call if the sub terminates with + // an error, XXX COMPAT WITH 1.0.3.1) + // - stopCallback (an optional callback to call when the sub terminates + // for any reason, with an error argument if an error triggered the stop) + self._subscriptions = {}; + + // Reactive userId. + self._userId = null; + self._userIdDeps = new Tracker.Dependency(); + + // Block auto-reload while we're waiting for method responses. + if (Meteor.isClient && + Package.reload && + ! options.reloadWithOutstanding) { + Package.reload.Reload._onMigrate(retry => { + if (! self._readyToMigrate()) { + self._retryMigrate = retry; + return [false]; + } else { + return [true]; + } + }); + } + + const onDisconnect = () => { + if (self._heartbeat) { + self._heartbeat.stop(); + self._heartbeat = null; + } + }; + + if (Meteor.isServer) { + self._stream.on( + 'message', + Meteor.bindEnvironment( + this.onMessage.bind(this), + 'handling DDP message' + ) + ); + self._stream.on( + 'reset', + Meteor.bindEnvironment(this.onReset.bind(this), 'handling DDP reset') + ); + self._stream.on( + 'disconnect', + Meteor.bindEnvironment(onDisconnect, 'handling DDP disconnect') + ); + } else { + self._stream.on('message', this.onMessage.bind(this)); + self._stream.on('reset', this.onReset.bind(this)); + self._stream.on('disconnect', onDisconnect); + } + } + + // 'name' is the name of the data on the wire that should go in the + // store. 'wrappedStore' should be an object with methods beginUpdate, update, + // endUpdate, saveOriginals, retrieveOriginals. see Collection for an example. + registerStore(name, wrappedStore) { + const self = this; + + if (name in self._stores) return false; + + // Wrap the input object in an object which makes any store method not + // implemented by 'store' into a no-op. + const store = Object.create(null); + const keysOfStore = [ + 'update', + 'beginUpdate', + 'endUpdate', + 'saveOriginals', + 'retrieveOriginals', + 'getDoc', + '_getCollection' + ]; + keysOfStore.forEach((method) => { + store[method] = (...args) => { + if (wrappedStore[method]) { + return wrappedStore[method](...args); + } + }; + }); + self._stores[name] = store; + + const queued = self._updatesForUnknownStores[name]; + if (Array.isArray(queued)) { + store.beginUpdate(queued.length, false); + queued.forEach(msg => { + store.update(msg); + }); + store.endUpdate(); + delete self._updatesForUnknownStores[name]; + } + + return true; + } + + /** + * @memberOf Meteor + * @importFromPackage meteor + * @alias Meteor.subscribe + * @summary Subscribe to a record set. Returns a handle that provides + * `stop()` and `ready()` methods. + * @locus Client + * @param {String} name Name of the subscription. Matches the name of the + * server's `publish()` call. + * @param {EJSONable} [arg1,arg2...] Optional arguments passed to publisher + * function on server. + * @param {Function|Object} [callbacks] Optional. May include `onStop` + * and `onReady` callbacks. If there is an error, it is passed as an + * argument to `onStop`. If a function is passed instead of an object, it + * is interpreted as an `onReady` callback. + */ + subscribe(name /* .. [arguments] .. (callback|callbacks) */) { + const self = this; + + const params = slice.call(arguments, 1); + let callbacks = Object.create(null); + if (params.length) { + const lastParam = params[params.length - 1]; + if (typeof lastParam === 'function') { + callbacks.onReady = params.pop(); + } else if (lastParam && [ + lastParam.onReady, + // XXX COMPAT WITH 1.0.3.1 onError used to exist, but now we use + // onStop with an error callback instead. + lastParam.onError, + lastParam.onStop + ].some(f => typeof f === "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. + const existing = Object.values(self._subscriptions).find( + sub => (sub.inactive && sub.name === name && EJSON.equals(sub.params, params)) + ); + + let id; + if (existing) { + id = existing.id; + existing.inactive = false; // reactivate + + 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 the sub is already ready, run the ready callback right away. + // It seems that users would expect an onReady callback inside an + // autorun to trigger once the the sub first becomes ready and also + // when re-subs happens. + if (existing.ready) { + callbacks.onReady(); + } else { + existing.readyCallback = callbacks.onReady; + } + } + + // XXX COMPAT WITH 1.0.3.1 we used to have onError but now we call + // onStop with an optional error argument + if (callbacks.onError) { + // Replace existing callback if any, so that errors aren't + // double-reported. + existing.errorCallback = callbacks.onError; + } + + if (callbacks.onStop) { + existing.stopCallback = callbacks.onStop; + } + } else { + // New sub! Generate an id, save it locally, and send message. + id = Random.id(); + self._subscriptions[id] = { + id: id, + name: name, + params: EJSON.clone(params), + inactive: false, + ready: false, + readyDeps: new Tracker.Dependency(), + readyCallback: callbacks.onReady, + // XXX COMPAT WITH 1.0.3.1 #errorCallback + errorCallback: callbacks.onError, + stopCallback: callbacks.onStop, + connection: self, + remove() { + delete this.connection._subscriptions[this.id]; + this.ready && this.readyDeps.changed(); + }, + stop() { + this.connection._send({ msg: 'unsub', id: id }); + this.remove(); + + if (callbacks.onStop) { + callbacks.onStop(); + } + } + }; + self._send({ msg: 'sub', id: id, name: name, params: params }); + } + + // return a handle to the application. + const handle = { + stop() { + if (! hasOwn.call(self._subscriptions, id)) { + return; + } + self._subscriptions[id].stop(); + }, + ready() { + // return false if we've unsubscribed. + if (!hasOwn.call(self._subscriptions, id)) { + return false; + } + const record = self._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((c) => { + if (hasOwn.call(self._subscriptions, id)) { + self._subscriptions[id].inactive = true; + } + + Tracker.afterFlush(() => { + if (hasOwn.call(self._subscriptions, id) && + self._subscriptions[id].inactive) { + handle.stop(); + } + }); + }); + } + + return handle; + } + + // options: + // - onLateError {Function(error)} called if an error was received after the ready event. + // (errors received before ready cause an error to be thrown) + _subscribeAndWait(name, args, options) { + const self = this; + const f = new Future(); + let ready = false; + args = args || []; + args.push({ + onReady() { + ready = true; + f['return'](); + }, + onError(e) { + if (!ready) f['throw'](e); + else options && options.onLateError && options.onLateError(e); + } + }); + + const handle = self.subscribe.apply(self, [name].concat(args)); + f.wait(); + return handle; + } + + methods(methods) { + Object.entries(methods).forEach(([name, func]) => { + if (typeof func !== 'function') { + throw new Error("Method '" + name + "' must be a function"); + } + if (this._methodHandlers[name]) { + throw new Error("A method named '" + name + "' is already defined"); + } + this._methodHandlers[name] = func; + }); + } + + _getIsSimulation({isFromCallAsync, alreadyInSimulation}) { + if (!isFromCallAsync) { + return alreadyInSimulation; + } + return alreadyInSimulation && DDP._CurrentMethodInvocation._isCallAsyncMethodRunning(); + } + + /** + * @memberOf Meteor + * @importFromPackage meteor + * @alias Meteor.call + * @summary Invokes a method with a sync stub, passing any number of arguments. + * @locus Anywhere + * @param {String} name Name of method to invoke + * @param {EJSONable} [arg1,arg2...] Optional method arguments + * @param {Function} [asyncCallback] Optional callback, which is called asynchronously with the error or result after the method is complete. If not provided, the method runs synchronously if possible (see below). + */ + call(name /* .. [arguments] .. callback */) { + // if it's a function, the last argument is the result callback, + // not a parameter to the remote method. + const args = slice.call(arguments, 1); + let callback; + if (args.length && typeof args[args.length - 1] === 'function') { + callback = args.pop(); + } + return this.apply(name, args, callback); + } + /** + * @memberOf Meteor + * @importFromPackage meteor + * @alias Meteor.callAsync + * @summary Invokes a method with an async stub, passing any number of arguments. + * @locus Anywhere + * @param {String} name Name of method to invoke + * @param {EJSONable} [arg1,arg2...] Optional method arguments + * @returns {Promise} + */ + async callAsync(name /* .. [arguments] .. */) { + const args = slice.call(arguments, 1); + if (args.length && typeof args[args.length - 1] === 'function') { + throw new Error( + "Meteor.callAsync() does not accept a callback. You should 'await' the result, or use .then()." + ); + } + /* + * This is necessary because when you call a Promise.then, you're actually calling a bound function by Meteor. + * + * This is done by this code https://github.com/meteor/meteor/blob/17673c66878d3f7b1d564a4215eb0633fa679017/npm-packages/meteor-promise/promise_client.js#L1-L16. (All the logic below can be removed in the future, when we stop overwriting the + * Promise.) + * + * When you call a ".then()", like "Meteor.callAsync().then()", the global context (inside currentValues) + * will be from the call of Meteor.callAsync(), and not the context after the promise is done. + * + * This means that without this code if you call a stub inside the ".then()", this stub will act as a simulation + * and won't reach the server. + * + * Inside the function _getIsSimulation(), if isFromCallAsync is false, we continue to consider just the + * alreadyInSimulation, otherwise, isFromCallAsync is true, we also check the value of callAsyncMethodRunning (by + * calling DDP._CurrentMethodInvocation._isCallAsyncMethodRunning()). + * + * With this, if a stub is running inside a ".then()", it'll know it's not a simulation, because callAsyncMethodRunning + * will be false. + * + * DDP._CurrentMethodInvocation._set() is important because without it, if you have a code like: + * + * Meteor.callAsync("m1").then(() => { + * Meteor.callAsync("m2") + * }) + * + * The call the method m2 will act as a simulation and won't reach the server. That's why we reset the context here + * before calling everything else. + * + * */ + DDP._CurrentMethodInvocation._set(); + DDP._CurrentMethodInvocation._setCallAsyncMethodRunning(true); + return new Promise((resolve, reject) => { + this.applyAsync(name, args, { isFromCallAsync: true }, (err, result) => { + DDP._CurrentMethodInvocation._setCallAsyncMethodRunning(false); + if (err) { + reject(err); + return; + } + resolve(result); + }); + }); + } + + /** + * @memberOf Meteor + * @importFromPackage meteor + * @alias Meteor.apply + * @summary Invoke a method passing an array of arguments. + * @locus Anywhere + * @param {String} name Name of method to invoke + * @param {EJSONable[]} args Method arguments + * @param {Object} [options] + * @param {Boolean} options.wait (Client only) If true, don't send this method until all previous method calls have completed, and don't send any subsequent method calls until this one is completed. + * @param {Function} options.onResultReceived (Client only) This callback is invoked with the error or result of the method (just like `asyncCallback`) as soon as the error or result is available. The local cache may not yet reflect the writes performed by the method. + * @param {Boolean} options.noRetry (Client only) if true, don't send this method again on reload, simply call the callback an error with the error code 'invocation-failed'. + * @param {Boolean} options.throwStubExceptions (Client only) If true, exceptions thrown by method stubs will be thrown instead of logged, and the method will not be invoked on the server. + * @param {Boolean} options.returnStubValue (Client only) If true then in cases where we would have otherwise discarded the stub's return value and returned undefined, instead we go ahead and return it. Specifically, this is any time other than when (a) we are already inside a stub or (b) we are in Node and no callback was provided. Currently we require this flag to be explicitly passed to reduce the likelihood that stub return values will be confused with server return values; we may improve this in future. + * @param {Function} [asyncCallback] Optional callback; same semantics as in [`Meteor.call`](#meteor_call). + */ + apply(name, args, options, callback) { + const { stubInvocation, invocation, ...stubOptions } = this._stubCall(name, EJSON.clone(args)); + + if (stubOptions.hasStub) { + if ( + !this._getIsSimulation({ + alreadyInSimulation: stubOptions.alreadyInSimulation, + isFromCallAsync: stubOptions.isFromCallAsync, + }) + ) { + this._saveOriginals(); + } + try { + stubOptions.stubReturnValue = DDP._CurrentMethodInvocation + .withValue(invocation, stubInvocation); + } catch (e) { + stubOptions.exception = e; + } + } + return this._apply(name, stubOptions, args, options, callback); + } + + /** + * @memberOf Meteor + * @importFromPackage meteor + * @alias Meteor.applyAsync + * @summary Invoke a method passing an array of arguments. + * @locus Anywhere + * @param {String} name Name of method to invoke + * @param {EJSONable[]} args Method arguments + * @param {Object} [options] + * @param {Boolean} options.wait (Client only) If true, don't send this method until all previous method calls have completed, and don't send any subsequent method calls until this one is completed. + * @param {Function} options.onResultReceived (Client only) This callback is invoked with the error or result of the method (just like `asyncCallback`) as soon as the error or result is available. The local cache may not yet reflect the writes performed by the method. + * @param {Boolean} options.noRetry (Client only) if true, don't send this method again on reload, simply call the callback an error with the error code 'invocation-failed'. + * @param {Boolean} options.throwStubExceptions (Client only) If true, exceptions thrown by method stubs will be thrown instead of logged, and the method will not be invoked on the server. + * @param {Boolean} options.returnStubValue (Client only) If true then in cases where we would have otherwise discarded the stub's return value and returned undefined, instead we go ahead and return it. Specifically, this is any time other than when (a) we are already inside a stub or (b) we are in Node and no callback was provided. Currently we require this flag to be explicitly passed to reduce the likelihood that stub return values will be confused with server return values; we may improve this in future. + * @param {Function} [asyncCallback] Optional callback. + */ + async applyAsync(name, args, options, callback) { + const { stubInvocation, invocation, ...stubOptions } = this._stubCall(name, EJSON.clone(args), options); + if (stubOptions.hasStub) { + if ( + !this._getIsSimulation({ + alreadyInSimulation: stubOptions.alreadyInSimulation, + isFromCallAsync: stubOptions.isFromCallAsync, + }) + ) { + this._saveOriginals(); + } + try { + /* + * The code below follows the same logic as the function withValues(). + * + * But as the Meteor package is not compiled by ecmascript, it is unable to use newer syntax in the browser, + * such as, the async/await. + * + * So, to keep supporting old browsers, like IE 11, we're creating the logic one level above. + */ + const currentContext = DDP._CurrentMethodInvocation._setNewContextAndGetCurrent( + invocation + ); + try { + stubOptions.stubReturnValue = await stubInvocation(); + } finally { + DDP._CurrentMethodInvocation._set(currentContext); + } + } catch (e) { + stubOptions.exception = e; + } + } + return this._apply(name, stubOptions, args, options, callback); + } + + _apply(name, stubCallValue, args, options, callback) { + const self = this; + + // We were passed 3 arguments. They may be either (name, args, options) + // or (name, args, callback) + if (!callback && typeof options === 'function') { + callback = options; + options = Object.create(null); + } + options = options || Object.create(null); + + if (callback) { + // XXX would it be better form to do the binding in stream.on, + // or caller, instead of here? + // XXX improve error message (and how we report it) + callback = Meteor.bindEnvironment( + callback, + "delivering result of invoking '" + name + "'" + ); + } + + // Keep our args safe from mutation (eg if we don't send the message for a + // while because of a wait method). + args = EJSON.clone(args); + + const { hasStub, exception, stubReturnValue, alreadyInSimulation, randomSeed } = stubCallValue; + + // If we're in a simulation, stop and return the result we have, + // rather than going on to do an RPC. If there was no stub, + // we'll end up returning undefined. + if ( + this._getIsSimulation({ + alreadyInSimulation, + isFromCallAsync: stubCallValue.isFromCallAsync, + }) + ) { + if (callback) { + callback(exception, stubReturnValue); + return undefined; + } + if (exception) throw exception; + return stubReturnValue; + } + + // We only create the methodId here because we don't actually need one if + // we're already in a simulation + const methodId = '' + self._nextMethodId++; + if (hasStub) { + self._retrieveAndStoreOriginals(methodId); + } + + // Generate the DDP message for the method call. Note that on the client, + // it is important that the stub have finished before we send the RPC, so + // that we know we have a complete list of which local documents the stub + // wrote. + const message = { + msg: 'method', + id: methodId, + method: name, + params: args + }; + + // If an exception occurred in a stub, and we're ignoring it + // because we're doing an RPC and want to use what the server + // returns instead, log it so the developer knows + // (unless they explicitly ask to see the error). + // + // Tests can set the '_expectedByTest' flag on an exception so it won't + // go to log. + if (exception) { + if (options.throwStubExceptions) { + throw exception; + } else if (!exception._expectedByTest) { + Meteor._debug( + "Exception while simulating the effect of invoking '" + name + "'", + exception + ); + } + } + + // At this point we're definitely doing an RPC, and we're going to + // return the value of the RPC to the caller. + + // If the caller didn't give a callback, decide what to do. + let future; + if (!callback) { + if (Meteor.isClient) { + // On the client, we don't have fibers, so we can't block. The + // only thing we can do is to return undefined and discard the + // result of the RPC. If an error occurred then print the error + // to the console. + callback = err => { + err && Meteor._debug("Error invoking Method '" + name + "'", err); + }; + } else { + // On the server, make the function synchronous. Throw on + // errors, return on success. + future = new Future(); + callback = future.resolver(); + } + } + + // Send the randomSeed only if we used it + if (randomSeed.value !== null) { + message.randomSeed = randomSeed.value; + } + + const methodInvoker = new MethodInvoker({ + methodId, + callback: callback, + connection: self, + onResultReceived: options.onResultReceived, + wait: !!options.wait, + message: message, + noRetry: !!options.noRetry + }); + + if (options.wait) { + // It's a wait method! Wait methods go in their own block. + self._outstandingMethodBlocks.push({ + wait: true, + methods: [methodInvoker] + }); + } else { + // Not a wait method. Start a new block if the previous block was a wait + // block, and add it to the last block of methods. + if (isEmpty(self._outstandingMethodBlocks) || + last(self._outstandingMethodBlocks).wait) { + self._outstandingMethodBlocks.push({ + wait: false, + methods: [], + }); + } + + last(self._outstandingMethodBlocks).methods.push(methodInvoker); + } + + // If we added it to the first block, send it out now. + if (self._outstandingMethodBlocks.length === 1) methodInvoker.sendMessage(); + + // If we're using the default callback on the server, + // block waiting for the result. + if (future) { + return future.wait(); + } + return options.returnStubValue ? stubReturnValue : undefined; + } + + + _stubCall(name, args, options) { + // Run the stub, if we have one. The stub is supposed to make some + // temporary writes to the database to give the user a smooth experience + // until the actual result of executing the method comes back from the + // server (whereupon the temporary writes to the database will be reversed + // during the beginUpdate/endUpdate process.) + // + // Normally, we ignore the return value of the stub (even if it is an + // exception), in favor of the real return value from the server. The + // exception is if the *caller* is a stub. In that case, we're not going + // to do a RPC, so we use the return value of the stub as our return + // value. + const self = this; + const enclosing = DDP._CurrentMethodInvocation.get(); + const stub = self._methodHandlers[name]; + const alreadyInSimulation = enclosing?.isSimulation; + const isFromCallAsync = enclosing?._isFromCallAsync; + const randomSeed = { value: null}; + + const defaultReturn = { + alreadyInSimulation, randomSeed, isFromCallAsync + }; + if (!stub) { + return { ...defaultReturn, hasStub: false }; + } + + // Lazily generate a randomSeed, only if it is requested by the stub. + // The random streams only have utility if they're used on both the client + // and the server; if the client doesn't generate any 'random' values + // then we don't expect the server to generate any either. + // Less commonly, the server may perform different actions from the client, + // and may in fact generate values where the client did not, but we don't + // have any client-side values to match, so even here we may as well just + // use a random seed on the server. In that case, we don't pass the + // randomSeed to save bandwidth, and we don't even generate it to save a + // bit of CPU and to avoid consuming entropy. + + const randomSeedGenerator = () => { + if (randomSeed.value === null) { + randomSeed.value = DDPCommon.makeRpcSeed(enclosing, name); + } + return randomSeed.value; + }; + + const setUserId = userId => { + self.setUserId(userId); + }; + + const invocation = new DDPCommon.MethodInvocation({ + isSimulation: true, + userId: self.userId(), + isFromCallAsync: options?.isFromCallAsync, + setUserId: setUserId, + randomSeed() { + return randomSeedGenerator(); + } + }); + + // Note that unlike in the corresponding server code, we never audit + // that stubs check() their arguments. + const stubInvocation = () => { + if (Meteor.isServer) { + // Because saveOriginals and retrieveOriginals aren't reentrant, + // don't allow stubs to yield. + return Meteor._noYieldsAllowed(() => { + // re-clone, so that the stub can't affect our caller's values + return stub.apply(invocation, EJSON.clone(args)); + }); + } else { + return stub.apply(invocation, EJSON.clone(args)); + } + }; + return { ...defaultReturn, hasStub: true, stubInvocation, invocation }; + } + + // Before calling a method stub, prepare all stores to track changes and allow + // _retrieveAndStoreOriginals to get the original versions of changed + // documents. + _saveOriginals() { + if (! this._waitingForQuiescence()) { + this._flushBufferedWrites(); + } + + Object.values(this._stores).forEach((store) => { + store.saveOriginals(); + }); + } + + // Retrieves the original versions of all documents modified by the stub for + // method 'methodId' from all stores and saves them to _serverDocuments (keyed + // by document) and _documentsWrittenByStub (keyed by method ID). + _retrieveAndStoreOriginals(methodId) { + const self = this; + if (self._documentsWrittenByStub[methodId]) + throw new Error('Duplicate methodId in _retrieveAndStoreOriginals'); + + const docsWritten = []; + + Object.entries(self._stores).forEach(([collection, store]) => { + const originals = store.retrieveOriginals(); + // not all stores define retrieveOriginals + if (! originals) return; + originals.forEach((doc, id) => { + docsWritten.push({ collection, id }); + if (! hasOwn.call(self._serverDocuments, collection)) { + self._serverDocuments[collection] = new MongoIDMap(); + } + const serverDoc = self._serverDocuments[collection].setDefault( + id, + Object.create(null) + ); + if (serverDoc.writtenByStubs) { + // We're not the first stub to write this doc. Just add our method ID + // to the record. + serverDoc.writtenByStubs[methodId] = true; + } else { + // First stub! Save the original value and our method ID. + serverDoc.document = doc; + serverDoc.flushCallbacks = []; + serverDoc.writtenByStubs = Object.create(null); + serverDoc.writtenByStubs[methodId] = true; + } + }); + }); + if (! isEmpty(docsWritten)) { + self._documentsWrittenByStub[methodId] = docsWritten; + } + } + + // This is very much a private function we use to make the tests + // take up fewer server resources after they complete. + _unsubscribeAll() { + Object.values(this._subscriptions).forEach((sub) => { + // Avoid killing the autoupdate subscription so that developers + // still get hot code pushes when writing tests. + // + // XXX it's a hack to encode knowledge about autoupdate here, + // but it doesn't seem worth it yet to have a special API for + // subscriptions to preserve after unit tests. + if (sub.name !== 'meteor_autoupdate_clientVersions') { + sub.stop(); + } + }); + } + + // Sends the DDP stringification of the given message object + _send(obj) { + this._stream.send(DDPCommon.stringifyDDP(obj)); + } + + // We detected via DDP-level heartbeats that we've lost the + // connection. Unlike `disconnect` or `close`, a lost connection + // will be automatically retried. + _lostConnection(error) { + this._stream._lostConnection(error); + } + + /** + * @memberOf Meteor + * @importFromPackage meteor + * @alias Meteor.status + * @summary Get the current connection status. A reactive data source. + * @locus Client + */ + status(...args) { + return this._stream.status(...args); + } + + /** + * @summary Force an immediate reconnection attempt if the client is not connected to the server. + + This method does nothing if the client is already connected. + * @memberOf Meteor + * @importFromPackage meteor + * @alias Meteor.reconnect + * @locus Client + */ + reconnect(...args) { + return this._stream.reconnect(...args); + } + + /** + * @memberOf Meteor + * @importFromPackage meteor + * @alias Meteor.disconnect + * @summary Disconnect the client from the server. + * @locus Client + */ + disconnect(...args) { + return this._stream.disconnect(...args); + } + + close() { + return this._stream.disconnect({ _permanent: true }); + } + + /// + /// Reactive user system + /// + userId() { + if (this._userIdDeps) this._userIdDeps.depend(); + return this._userId; + } + + setUserId(userId) { + // Avoid invalidating dependents if setUserId is called with current value. + if (this._userId === userId) return; + this._userId = userId; + if (this._userIdDeps) this._userIdDeps.changed(); + } + + // Returns true if we are in a state after reconnect of waiting for subs to be + // revived or early methods to finish their data, or we are waiting for a + // "wait" method to finish. + _waitingForQuiescence() { + return ( + ! isEmpty(this._subsBeingRevived) || + ! isEmpty(this._methodsBlockingQuiescence) + ); + } + + // Returns true if any method whose message has been sent to the server has + // not yet invoked its user callback. + _anyMethodsAreOutstanding() { + const invokers = this._methodInvokers; + return Object.values(invokers).some((invoker) => !!invoker.sentMessage); + } + + _livedata_connected(msg) { + const self = this; + + if (self._version !== 'pre1' && self._heartbeatInterval !== 0) { + self._heartbeat = new DDPCommon.Heartbeat({ + heartbeatInterval: self._heartbeatInterval, + heartbeatTimeout: self._heartbeatTimeout, + onTimeout() { + self._lostConnection( + new DDP.ConnectionError('DDP heartbeat timed out') + ); + }, + sendPing() { + self._send({ msg: 'ping' }); + } + }); + self._heartbeat.start(); + } + + // If this is a reconnect, we'll have to reset all stores. + if (self._lastSessionId) self._resetStores = true; + + let reconnectedToPreviousSession; + if (typeof msg.session === 'string') { + reconnectedToPreviousSession = self._lastSessionId === msg.session; + self._lastSessionId = msg.session; + } + + if (reconnectedToPreviousSession) { + // Successful reconnection -- pick up where we left off. Note that right + // now, this never happens: the server never connects us to a previous + // session, because DDP doesn't provide enough data for the server to know + // what messages the client has processed. We need to improve DDP to make + // this possible, at which point we'll probably need more code here. + return; + } + + // Server doesn't have our data any more. Re-sync a new session. + + // Forget about messages we were buffering for unknown collections. They'll + // be resent if still relevant. + self._updatesForUnknownStores = Object.create(null); + + if (self._resetStores) { + // Forget about the effects of stubs. We'll be resetting all collections + // anyway. + self._documentsWrittenByStub = Object.create(null); + self._serverDocuments = Object.create(null); + } + + // Clear _afterUpdateCallbacks. + self._afterUpdateCallbacks = []; + + // Mark all named subscriptions which are ready (ie, we already called the + // ready callback) as needing to be revived. + // XXX We should also block reconnect quiescence until unnamed subscriptions + // (eg, autopublish) are done re-publishing to avoid flicker! + self._subsBeingRevived = Object.create(null); + Object.entries(self._subscriptions).forEach(([id, sub]) => { + if (sub.ready) { + self._subsBeingRevived[id] = true; + } + }); + + // Arrange for "half-finished" methods to have their callbacks run, and + // track methods that were sent on this connection so that we don't + // quiesce until they are all done. + // + // Start by clearing _methodsBlockingQuiescence: methods sent before + // reconnect don't matter, and any "wait" methods sent on the new connection + // that we drop here will be restored by the loop below. + self._methodsBlockingQuiescence = Object.create(null); + if (self._resetStores) { + const invokers = self._methodInvokers; + keys(invokers).forEach(id => { + const invoker = invokers[id]; + if (invoker.gotResult()) { + // This method already got its result, but it didn't call its callback + // because its data didn't become visible. We did not resend the + // method RPC. We'll call its callback when we get a full quiesce, + // since that's as close as we'll get to "data must be visible". + self._afterUpdateCallbacks.push( + (...args) => invoker.dataVisible(...args) + ); + } else if (invoker.sentMessage) { + // This method has been sent on this connection (maybe as a resend + // from the last connection, maybe from onReconnect, maybe just very + // quickly before processing the connected message). + // + // We don't need to do anything special to ensure its callbacks get + // called, but we'll count it as a method which is preventing + // reconnect quiescence. (eg, it might be a login method that was run + // from onReconnect, and we don't want to see flicker by seeing a + // logged-out state.) + self._methodsBlockingQuiescence[invoker.methodId] = true; + } + }); + } + + self._messagesBufferedUntilQuiescence = []; + + // If we're not waiting on any methods or subs, we can reset the stores and + // call the callbacks immediately. + if (! self._waitingForQuiescence()) { + if (self._resetStores) { + Object.values(self._stores).forEach((store) => { + store.beginUpdate(0, true); + store.endUpdate(); + }); + self._resetStores = false; + } + self._runAfterUpdateCallbacks(); + } + } + + _processOneDataMessage(msg, updates) { + const messageType = msg.msg; + + // msg is one of ['added', 'changed', 'removed', 'ready', 'updated'] + if (messageType === 'added') { + this._process_added(msg, updates); + } else if (messageType === 'changed') { + this._process_changed(msg, updates); + } else if (messageType === 'removed') { + this._process_removed(msg, updates); + } else if (messageType === 'ready') { + this._process_ready(msg, updates); + } else if (messageType === 'updated') { + this._process_updated(msg, updates); + } else if (messageType === 'nosub') { + // ignore this + } else { + Meteor._debug('discarding unknown livedata data message type', msg); + } + } + + _livedata_data(msg) { + const self = this; + + if (self._waitingForQuiescence()) { + self._messagesBufferedUntilQuiescence.push(msg); + + if (msg.msg === 'nosub') { + delete self._subsBeingRevived[msg.id]; + } + + if (msg.subs) { + msg.subs.forEach(subId => { + delete self._subsBeingRevived[subId]; + }); + } + + if (msg.methods) { + msg.methods.forEach(methodId => { + delete self._methodsBlockingQuiescence[methodId]; + }); + } + + if (self._waitingForQuiescence()) { + return; + } + + // No methods or subs are blocking quiescence! + // We'll now process and all of our buffered messages, reset all stores, + // and apply them all at once. + + const bufferedMessages = self._messagesBufferedUntilQuiescence; + Object.values(bufferedMessages).forEach(bufferedMessage => { + self._processOneDataMessage( + bufferedMessage, + self._bufferedWrites + ); + }); + + self._messagesBufferedUntilQuiescence = []; + + } else { + self._processOneDataMessage(msg, self._bufferedWrites); + } + + // Immediately flush writes when: + // 1. Buffering is disabled. Or; + // 2. any non-(added/changed/removed) message arrives. + const standardWrite = + msg.msg === "added" || + msg.msg === "changed" || + msg.msg === "removed"; + + if (self._bufferedWritesInterval === 0 || ! standardWrite) { + self._flushBufferedWrites(); + return; + } + + if (self._bufferedWritesFlushAt === null) { + self._bufferedWritesFlushAt = + new Date().valueOf() + self._bufferedWritesMaxAge; + } else if (self._bufferedWritesFlushAt < new Date().valueOf()) { + self._flushBufferedWrites(); + return; + } + + if (self._bufferedWritesFlushHandle) { + clearTimeout(self._bufferedWritesFlushHandle); + } + self._bufferedWritesFlushHandle = setTimeout( + self.__flushBufferedWrites, + self._bufferedWritesInterval + ); + } + + _flushBufferedWrites() { + const self = this; + if (self._bufferedWritesFlushHandle) { + clearTimeout(self._bufferedWritesFlushHandle); + self._bufferedWritesFlushHandle = null; + } + + self._bufferedWritesFlushAt = null; + // We need to clear the buffer before passing it to + // performWrites. As there's no guarantee that it + // will exit cleanly. + const writes = self._bufferedWrites; + self._bufferedWrites = Object.create(null); + self._performWrites(writes); + } + + _performWrites(updates) { + const self = this; + + if (self._resetStores || ! isEmpty(updates)) { + // Begin a transactional update of each store. + + Object.entries(self._stores).forEach(([storeName, store]) => { + store.beginUpdate( + hasOwn.call(updates, storeName) + ? updates[storeName].length + : 0, + self._resetStores + ); + }); + + self._resetStores = false; + + Object.entries(updates).forEach(([storeName, updateMessages]) => { + const store = self._stores[storeName]; + if (store) { + updateMessages.forEach(updateMessage => { + store.update(updateMessage); + }); + } else { + // Nobody's listening for this data. Queue it up until + // someone wants it. + // XXX memory use will grow without bound if you forget to + // create a collection or just don't care about it... going + // to have to do something about that. + const updates = self._updatesForUnknownStores; + + if (! hasOwn.call(updates, storeName)) { + updates[storeName] = []; + } + + updates[storeName].push(...updateMessages); + } + }); + + // End update transaction. + Object.values(self._stores).forEach((store) => { + store.endUpdate(); + }); + } + + self._runAfterUpdateCallbacks(); + } + + // Call any callbacks deferred with _runWhenAllServerDocsAreFlushed whose + // relevant docs have been flushed, as well as dataVisible callbacks at + // reconnect-quiescence time. + _runAfterUpdateCallbacks() { + const self = this; + const callbacks = self._afterUpdateCallbacks; + self._afterUpdateCallbacks = []; + callbacks.forEach((c) => { + c(); + }); + } + + _pushUpdate(updates, collection, msg) { + if (! hasOwn.call(updates, collection)) { + updates[collection] = []; + } + updates[collection].push(msg); + } + + _getServerDoc(collection, id) { + const self = this; + if (! hasOwn.call(self._serverDocuments, collection)) { + return null; + } + const serverDocsForCollection = self._serverDocuments[collection]; + return serverDocsForCollection.get(id) || null; + } + + _process_added(msg, updates) { + const self = this; + const id = MongoID.idParse(msg.id); + const serverDoc = self._getServerDoc(msg.collection, id); + if (serverDoc) { + // Some outstanding stub wrote here. + const isExisting = serverDoc.document !== undefined; + + serverDoc.document = msg.fields || Object.create(null); + serverDoc.document._id = id; + + if (self._resetStores) { + // During reconnect the server is sending adds for existing ids. + // Always push an update so that document stays in the store after + // reset. Use current version of the document for this update, so + // that stub-written values are preserved. + const currentDoc = self._stores[msg.collection].getDoc(msg.id); + if (currentDoc !== undefined) msg.fields = currentDoc; + + self._pushUpdate(updates, msg.collection, msg); + } else if (isExisting) { + throw new Error('Server sent add for existing id: ' + msg.id); + } + } else { + self._pushUpdate(updates, msg.collection, msg); + } + } + + _process_changed(msg, updates) { + const self = this; + const serverDoc = self._getServerDoc(msg.collection, MongoID.idParse(msg.id)); + if (serverDoc) { + if (serverDoc.document === undefined) + throw new Error('Server sent changed for nonexisting id: ' + msg.id); + DiffSequence.applyChanges(serverDoc.document, msg.fields); + } else { + self._pushUpdate(updates, msg.collection, msg); + } + } + + _process_removed(msg, updates) { + const self = this; + const serverDoc = self._getServerDoc(msg.collection, MongoID.idParse(msg.id)); + if (serverDoc) { + // Some outstanding stub wrote here. + if (serverDoc.document === undefined) + throw new Error('Server sent removed for nonexisting id:' + msg.id); + serverDoc.document = undefined; + } else { + self._pushUpdate(updates, msg.collection, { + msg: 'removed', + collection: msg.collection, + id: msg.id + }); + } + } + + _process_updated(msg, updates) { + const self = this; + // Process "method done" messages. + + msg.methods.forEach((methodId) => { + const docs = self._documentsWrittenByStub[methodId] || {}; + Object.values(docs).forEach((written) => { + const serverDoc = self._getServerDoc(written.collection, written.id); + if (! serverDoc) { + throw new Error('Lost serverDoc for ' + JSON.stringify(written)); + } + if (! serverDoc.writtenByStubs[methodId]) { + throw new Error( + 'Doc ' + + JSON.stringify(written) + + ' not written by method ' + + methodId + ); + } + delete serverDoc.writtenByStubs[methodId]; + if (isEmpty(serverDoc.writtenByStubs)) { + // All methods whose stubs wrote this method have completed! We can + // now copy the saved document to the database (reverting the stub's + // change if the server did not write to this object, or applying the + // server's writes if it did). + + // This is a fake ddp 'replace' message. It's just for talking + // between livedata connections and minimongo. (We have to stringify + // the ID because it's supposed to look like a wire message.) + self._pushUpdate(updates, written.collection, { + msg: 'replace', + id: MongoID.idStringify(written.id), + replace: serverDoc.document + }); + // Call all flush callbacks. + + serverDoc.flushCallbacks.forEach((c) => { + c(); + }); + + // Delete this completed serverDocument. Don't bother to GC empty + // IdMaps inside self._serverDocuments, since there probably aren't + // many collections and they'll be written repeatedly. + self._serverDocuments[written.collection].remove(written.id); + } + }); + delete self._documentsWrittenByStub[methodId]; + + // We want to call the data-written callback, but we can't do so until all + // currently buffered messages are flushed. + const callbackInvoker = self._methodInvokers[methodId]; + if (! callbackInvoker) { + throw new Error('No callback invoker for method ' + methodId); + } + + self._runWhenAllServerDocsAreFlushed( + (...args) => callbackInvoker.dataVisible(...args) + ); + }); + } + + _process_ready(msg, updates) { + const self = this; + // Process "sub ready" messages. "sub ready" messages don't take effect + // until all current server documents have been flushed to the local + // database. We can use a write fence to implement this. + + msg.subs.forEach((subId) => { + self._runWhenAllServerDocsAreFlushed(() => { + const subRecord = self._subscriptions[subId]; + // Did we already unsubscribe? + if (!subRecord) return; + // Did we already receive a ready message? (Oops!) + if (subRecord.ready) return; + subRecord.ready = true; + subRecord.readyCallback && subRecord.readyCallback(); + subRecord.readyDeps.changed(); + }); + }); + } + + // Ensures that "f" will be called after all documents currently in + // _serverDocuments have been written to the local cache. f will not be called + // if the connection is lost before then! + _runWhenAllServerDocsAreFlushed(f) { + const self = this; + const runFAfterUpdates = () => { + self._afterUpdateCallbacks.push(f); + }; + let unflushedServerDocCount = 0; + const onServerDocFlush = () => { + --unflushedServerDocCount; + if (unflushedServerDocCount === 0) { + // This was the last doc to flush! Arrange to run f after the updates + // have been applied. + runFAfterUpdates(); + } + }; + + Object.values(self._serverDocuments).forEach((serverDocuments) => { + serverDocuments.forEach((serverDoc) => { + const writtenByStubForAMethodWithSentMessage = + keys(serverDoc.writtenByStubs).some(methodId => { + const invoker = self._methodInvokers[methodId]; + return invoker && invoker.sentMessage; + }); + + if (writtenByStubForAMethodWithSentMessage) { + ++unflushedServerDocCount; + serverDoc.flushCallbacks.push(onServerDocFlush); + } + }); + }); + if (unflushedServerDocCount === 0) { + // There aren't any buffered docs --- we can call f as soon as the current + // round of updates is applied! + runFAfterUpdates(); + } + } + + _livedata_nosub(msg) { + const self = this; + + // First pass it through _livedata_data, which only uses it to help get + // towards quiescence. + self._livedata_data(msg); + + // Do the rest of our processing immediately, with no + // buffering-until-quiescence. + + // we weren't subbed anyway, or we initiated the unsub. + if (! hasOwn.call(self._subscriptions, msg.id)) { + return; + } + + // XXX COMPAT WITH 1.0.3.1 #errorCallback + const errorCallback = self._subscriptions[msg.id].errorCallback; + const stopCallback = self._subscriptions[msg.id].stopCallback; + + self._subscriptions[msg.id].remove(); + + const meteorErrorFromMsg = msgArg => { + return ( + msgArg && + msgArg.error && + new Meteor.Error( + msgArg.error.error, + msgArg.error.reason, + msgArg.error.details + ) + ); + }; + + // XXX COMPAT WITH 1.0.3.1 #errorCallback + if (errorCallback && msg.error) { + errorCallback(meteorErrorFromMsg(msg)); + } + + if (stopCallback) { + stopCallback(meteorErrorFromMsg(msg)); + } + } + + _livedata_result(msg) { + // id, result or error. error has error (code), reason, details + + const self = this; + + // Lets make sure there are no buffered writes before returning result. + if (! isEmpty(self._bufferedWrites)) { + self._flushBufferedWrites(); + } + + // find the outstanding request + // should be O(1) in nearly all realistic use cases + if (isEmpty(self._outstandingMethodBlocks)) { + Meteor._debug('Received method result but no methods outstanding'); + return; + } + const currentMethodBlock = self._outstandingMethodBlocks[0].methods; + let i; + const m = currentMethodBlock.find((method, idx) => { + const found = method.methodId === msg.id; + if (found) i = idx; + return found; + }); + if (!m) { + Meteor._debug("Can't match method response to original method call", msg); + return; + } + + // Remove from current method block. This may leave the block empty, but we + // don't move on to the next block until the callback has been delivered, in + // _outstandingMethodFinished. + currentMethodBlock.splice(i, 1); + + if (hasOwn.call(msg, 'error')) { + m.receiveResult( + new Meteor.Error(msg.error.error, msg.error.reason, msg.error.details) + ); + } else { + // msg.result may be undefined if the method didn't return a + // value + m.receiveResult(undefined, msg.result); + } + } + + // Called by MethodInvoker after a method's callback is invoked. If this was + // the last outstanding method in the current block, runs the next block. If + // there are no more methods, consider accepting a hot code push. + _outstandingMethodFinished() { + const self = this; + if (self._anyMethodsAreOutstanding()) return; + + // No methods are outstanding. This should mean that the first block of + // methods is empty. (Or it might not exist, if this was a method that + // half-finished before disconnect/reconnect.) + if (! isEmpty(self._outstandingMethodBlocks)) { + const firstBlock = self._outstandingMethodBlocks.shift(); + if (! isEmpty(firstBlock.methods)) + throw new Error( + 'No methods outstanding but nonempty block: ' + + JSON.stringify(firstBlock) + ); + + // Send the outstanding methods now in the first block. + if (! isEmpty(self._outstandingMethodBlocks)) + self._sendOutstandingMethods(); + } + + // Maybe accept a hot code push. + self._maybeMigrate(); + } + + // Sends messages for all the methods in the first block in + // _outstandingMethodBlocks. + _sendOutstandingMethods() { + const self = this; + + if (isEmpty(self._outstandingMethodBlocks)) { + return; + } + + self._outstandingMethodBlocks[0].methods.forEach(m => { + m.sendMessage(); + }); + } + + _livedata_error(msg) { + Meteor._debug('Received error from server: ', msg.reason); + if (msg.offendingMessage) Meteor._debug('For: ', msg.offendingMessage); + } + + _callOnReconnectAndSendAppropriateOutstandingMethods() { + const self = this; + const oldOutstandingMethodBlocks = self._outstandingMethodBlocks; + self._outstandingMethodBlocks = []; + + self.onReconnect && self.onReconnect(); + DDP._reconnectHook.each(callback => { + callback(self); + return true; + }); + + if (isEmpty(oldOutstandingMethodBlocks)) return; + + // We have at least one block worth of old outstanding methods to try + // again. First: did onReconnect actually send anything? If not, we just + // restore all outstanding methods and run the first block. + if (isEmpty(self._outstandingMethodBlocks)) { + self._outstandingMethodBlocks = oldOutstandingMethodBlocks; + self._sendOutstandingMethods(); + return; + } + + // OK, there are blocks on both sides. Special case: merge the last block of + // the reconnect methods with the first block of the original methods, if + // neither of them are "wait" blocks. + if (! last(self._outstandingMethodBlocks).wait && + ! oldOutstandingMethodBlocks[0].wait) { + oldOutstandingMethodBlocks[0].methods.forEach(m => { + last(self._outstandingMethodBlocks).methods.push(m); + + // If this "last block" is also the first block, send the message. + if (self._outstandingMethodBlocks.length === 1) { + m.sendMessage(); + } + }); + + oldOutstandingMethodBlocks.shift(); + } + + // Now add the rest of the original blocks on. + self._outstandingMethodBlocks.push(...oldOutstandingMethodBlocks); + } + + // We can accept a hot code push if there are no methods in flight. + _readyToMigrate() { + return isEmpty(this._methodInvokers); + } + + // If we were blocking a migration, see if it's now possible to continue. + // Call whenever the set of outstanding/blocked methods shrinks. + _maybeMigrate() { + const self = this; + if (self._retryMigrate && self._readyToMigrate()) { + self._retryMigrate(); + self._retryMigrate = null; + } + } + + onMessage(raw_msg) { + let msg; + try { + msg = DDPCommon.parseDDP(raw_msg); + } catch (e) { + Meteor._debug('Exception while parsing DDP', e); + return; + } + + // Any message counts as receiving a pong, as it demonstrates that + // the server is still alive. + if (this._heartbeat) { + this._heartbeat.messageReceived(); + } + + if (msg === null || !msg.msg) { + if(!msg || !msg.testMessageOnConnect) { + if (Object.keys(msg).length === 1 && msg.server_id) return; + Meteor._debug('discarding invalid livedata message', msg); + } + return; + } + + if (msg.msg === 'connected') { + this._version = this._versionSuggestion; + this._livedata_connected(msg); + this.options.onConnected(); + } else if (msg.msg === 'failed') { + if (this._supportedDDPVersions.indexOf(msg.version) >= 0) { + this._versionSuggestion = msg.version; + this._stream.reconnect({ _force: true }); + } else { + const description = + 'DDP version negotiation failed; server requested version ' + + msg.version; + this._stream.disconnect({ _permanent: true, _error: description }); + this.options.onDDPVersionNegotiationFailure(description); + } + } else if (msg.msg === 'ping' && this.options.respondToPings) { + this._send({ msg: 'pong', id: msg.id }); + } else if (msg.msg === 'pong') { + // noop, as we assume everything's a pong + } else if ( + ['added', 'changed', 'removed', 'ready', 'updated'].includes(msg.msg) + ) { + this._livedata_data(msg); + } else if (msg.msg === 'nosub') { + this._livedata_nosub(msg); + } else if (msg.msg === 'result') { + this._livedata_result(msg); + } else if (msg.msg === 'error') { + this._livedata_error(msg); + } else { + Meteor._debug('discarding unknown livedata message type', msg); + } + } + + onReset() { + // Send a connect message at the beginning of the stream. + // NOTE: reset is called even on the first connection, so this is + // the only place we send this message. + const msg = { msg: 'connect' }; + if (this._lastSessionId) msg.session = this._lastSessionId; + msg.version = this._versionSuggestion || this._supportedDDPVersions[0]; + this._versionSuggestion = msg.version; + msg.support = this._supportedDDPVersions; + this._send(msg); + + // Mark non-retry calls as failed. This has to be done early as getting these methods out of the + // current block is pretty important to making sure that quiescence is properly calculated, as + // well as possibly moving on to another useful block. + + // Only bother testing if there is an outstandingMethodBlock (there might not be, especially if + // we are connecting for the first time. + if (this._outstandingMethodBlocks.length > 0) { + // If there is an outstanding method block, we only care about the first one as that is the + // one that could have already sent messages with no response, that are not allowed to retry. + const currentMethodBlock = this._outstandingMethodBlocks[0].methods; + this._outstandingMethodBlocks[0].methods = currentMethodBlock.filter( + methodInvoker => { + // Methods with 'noRetry' option set are not allowed to re-send after + // recovering dropped connection. + if (methodInvoker.sentMessage && methodInvoker.noRetry) { + // Make sure that the method is told that it failed. + methodInvoker.receiveResult( + new Meteor.Error( + 'invocation-failed', + 'Method invocation might have failed due to dropped connection. ' + + 'Failing because `noRetry` option was passed to Meteor.apply.' + ) + ); + } + + // Only keep a method if it wasn't sent or it's allowed to retry. + // This may leave the block empty, but we don't move on to the next + // block until the callback has been delivered, in _outstandingMethodFinished. + return !(methodInvoker.sentMessage && methodInvoker.noRetry); + } + ); + } + + // Now, to minimize setup latency, go ahead and blast out all of + // our pending methods ands subscriptions before we've even taken + // the necessary RTT to know if we successfully reconnected. (1) + // They're supposed to be idempotent, and where they are not, + // they can block retry in apply; (2) even if we did reconnect, + // we're not sure what messages might have gotten lost + // (in either direction) since we were disconnected (TCP being + // sloppy about that.) + + // If the current block of methods all got their results (but didn't all get + // their data visible), discard the empty block now. + if ( + this._outstandingMethodBlocks.length > 0 && + this._outstandingMethodBlocks[0].methods.length === 0 + ) { + this._outstandingMethodBlocks.shift(); + } + + // Mark all messages as unsent, they have not yet been sent on this + // connection. + keys(this._methodInvokers).forEach(id => { + this._methodInvokers[id].sentMessage = false; + }); + + // If an `onReconnect` handler is set, call it first. Go through + // some hoops to ensure that methods that are called from within + // `onReconnect` get executed _before_ ones that were originally + // outstanding (since `onReconnect` is used to re-establish auth + // certificates) + this._callOnReconnectAndSendAppropriateOutstandingMethods(); + + // add new subscriptions at the end. this way they take effect after + // the handlers and we don't see flicker. + Object.entries(this._subscriptions).forEach(([id, sub]) => { + this._send({ + msg: 'sub', + id: id, + name: sub.name, + params: sub.params + }); + }); + } +} diff --git a/packages/ddp-client-async/common/namespace.js b/packages/ddp-client-async/common/namespace.js new file mode 100644 index 0000000000..117ea27d6b --- /dev/null +++ b/packages/ddp-client-async/common/namespace.js @@ -0,0 +1,91 @@ +import { DDPCommon } from 'meteor/ddp-common'; +import { Meteor } from 'meteor/meteor'; + +import { Connection } from './livedata_connection.js'; + +// This array allows the `_allSubscriptionsReady` method below, which +// is used by the `spiderable` package, to keep track of whether all +// data is ready. +const allConnections = []; + +/** + * @namespace DDP + * @summary Namespace for DDP-related methods/classes. + */ +export const DDP = {}; + +// This is private but it's used in a few places. accounts-base uses +// it to get the current user. Meteor.setTimeout and friends clear +// it. We can probably find a better way to factor this. +DDP._CurrentMethodInvocation = new Meteor.EnvironmentVariable(); +DDP._CurrentPublicationInvocation = new Meteor.EnvironmentVariable(); + +// XXX: Keep DDP._CurrentInvocation for backwards-compatibility. +DDP._CurrentInvocation = DDP._CurrentMethodInvocation; + +// This is passed into a weird `makeErrorType` function that expects its thing +// to be a constructor +function connectionErrorConstructor(message) { + this.message = message; +} + +DDP.ConnectionError = Meteor.makeErrorType( + 'DDP.ConnectionError', + connectionErrorConstructor +); + +DDP.ForcedReconnectError = Meteor.makeErrorType( + 'DDP.ForcedReconnectError', + () => {} +); + +// Returns the named sequence of pseudo-random values. +// The scope will be DDP._CurrentMethodInvocation.get(), so the stream will produce +// consistent values for method calls on the client and server. +DDP.randomStream = name => { + const scope = DDP._CurrentMethodInvocation.get(); + return DDPCommon.RandomStream.get(scope, name); +}; + +// @param url {String} URL to Meteor app, +// e.g.: +// "subdomain.meteor.com", +// "http://subdomain.meteor.com", +// "/", +// "ddp+sockjs://ddp--****-foo.meteor.com/sockjs" + +/** + * @summary Connect to the server of a different Meteor application to subscribe to its document sets and invoke its remote methods. + * @locus Anywhere + * @param {String} url The URL of another Meteor application. + * @param {Object} [options] + * @param {Boolean} options.reloadWithOutstanding is it OK to reload if there are outstanding methods? + * @param {Object} options.headers extra headers to send on the websockets connection, for server-to-server DDP only + * @param {Object} options._sockjsOptions Specifies options to pass through to the sockjs client + * @param {Function} options.onDDPNegotiationVersionFailure callback when version negotiation fails. + */ +DDP.connect = (url, options) => { + const ret = new Connection(url, options); + allConnections.push(ret); // hack. see below. + return ret; +}; + +DDP._reconnectHook = new Hook({ bindEnvironment: false }); + +/** + * @summary Register a function to call as the first step of + * reconnecting. This function can call methods which will be executed before + * any other outstanding methods. For example, this can be used to re-establish + * the appropriate authentication context on the connection. + * @locus Anywhere + * @param {Function} callback The function to call. It will be called with a + * single argument, the [connection object](#ddp_connect) that is reconnecting. + */ +DDP.onReconnect = callback => DDP._reconnectHook.register(callback); + +// Hack for `spiderable` package: a way to see if the page is done +// loading all the data it needs. +// +DDP._allSubscriptionsReady = () => allConnections.every( + conn => Object.values(conn._subscriptions).every(sub => sub.ready) +); diff --git a/packages/ddp-client-async/package.js b/packages/ddp-client-async/package.js new file mode 100644 index 0000000000..0cdc77a953 --- /dev/null +++ b/packages/ddp-client-async/package.js @@ -0,0 +1,63 @@ +Package.describe({ + summary: "Meteor's latency-compensated distributed data client", + version: '2.6.0', + documentation: null +}); + +Npm.depends({ + '@sinonjs/fake-timers': '7.0.5' +}); + +Package.onUse((api) => { + api.use([ + 'check', + 'random', + 'ejson', + 'tracker', + 'retry', + 'id-map', + 'ecmascript', + 'callback-hook', + 'ddp-common', + 'reload', + 'socket-stream-client', + + // we depend on _diffObjects, _applyChanges, + 'diff-sequence', + + // _idParse, _idStringify. + 'mongo-id' + ], ['client', 'server']); + + api.use('reload', 'client', { weak: true }); + + // For backcompat where things use Package.ddp.DDP, etc + api.export('DDP'); + api.mainModule('client/client.js', 'client'); + api.mainModule('server/server.js', 'server'); +}); + +Package.onTest((api) => { + api.use([ + 'livedata', + 'mongo', + 'test-helpers', + 'ecmascript', + 'underscore', + 'tinytest', + 'random', + 'tracker', + 'reactive-var', + 'mongo-id', + 'diff-sequence', + 'ejson', + 'ddp-common', + 'check' + ]); + + api.addFiles('test/stub_stream.js'); + api.addFiles('test/livedata_connection_tests.js'); + api.addFiles('test/livedata_tests.js'); + api.addFiles('test/livedata_test_service.js'); + api.addFiles('test/random_stream_tests.js'); +}); diff --git a/packages/ddp-client-async/server/server.js b/packages/ddp-client-async/server/server.js new file mode 100644 index 0000000000..8566aba9c2 --- /dev/null +++ b/packages/ddp-client-async/server/server.js @@ -0,0 +1 @@ +export { DDP } from '../common/namespace.js'; diff --git a/packages/ddp-client-async/test/livedata_connection_tests.js b/packages/ddp-client-async/test/livedata_connection_tests.js new file mode 100644 index 0000000000..32e014ccbf --- /dev/null +++ b/packages/ddp-client-async/test/livedata_connection_tests.js @@ -0,0 +1,2491 @@ +import FakeTimers from '@sinonjs/fake-timers'; +import { DDP } from '../common/namespace.js'; +import { Connection } from '../common/livedata_connection.js'; + +const newConnection = function(stream, options) { + // Some of these tests leave outstanding methods with no result yet + // returned. This should not block us from re-running tests when sources + // change. + return new Connection( + stream, + _.extend( + { + reloadWithOutstanding: true, + bufferedWritesInterval: 0 + }, + options + ) + ); +}; + +const makeConnectMessage = function(session) { + const msg = { + msg: 'connect', + version: DDPCommon.SUPPORTED_DDP_VERSIONS[0], + support: DDPCommon.SUPPORTED_DDP_VERSIONS + }; + + if (session) msg.session = session; + return msg; +}; + +// Tests that stream got a message that matches expected. +// Expected is normally an object, and allows a wildcard value of '*', +// which will then match any value. +// Returns the message (parsed as a JSON object if expected is an object); +// which is particularly handy if you want to extract a value that was +// matched as a wildcard. +const testGotMessage = function(test, stream, expected) { + if (stream.sent.length === 0) { + test.fail({ error: 'no message received', expected: expected }); + return undefined; + } + + let got = stream.sent.shift(); + + if (typeof got === 'string' && typeof expected === 'object') + got = JSON.parse(got); + + // An expected value of '*' matches any value, and the matching value (or + // array of matching values, if there are multiple) is returned from this + // function. + if (typeof expected === 'object') { + const keysWithStarValues = []; + _.each(expected, function(v, k) { + if (v === '*') keysWithStarValues.push(k); + }); + _.each(keysWithStarValues, function(k) { + expected[k] = got[k]; + }); + } + + test.equal(got, expected); + return got; +}; + +const startAndConnect = function(test, stream) { + stream.reset(); // initial connection start. + + testGotMessage(test, stream, makeConnectMessage()); + test.length(stream.sent, 0); + + stream.receive({ msg: 'connected', session: SESSION_ID }); + test.length(stream.sent, 0); +}; + +const SESSION_ID = '17'; + +Tinytest.add('livedata stub - receive data', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + + startAndConnect(test, stream); + + // data comes in for unknown collection. + const coll_name = Random.id(); + stream.receive({ + msg: 'added', + collection: coll_name, + id: '1234', + fields: { a: 1 } + }); + // break throught the black box and test internal state + test.length(conn._updatesForUnknownStores[coll_name], 1); + + // XXX: Test that the old signature of passing manager directly instead of in + // options works. + const coll = new Mongo.Collection(coll_name, conn); + + // queue has been emptied and doc is in db. + test.isUndefined(conn._updatesForUnknownStores[coll_name]); + test.equal(coll.find({}).fetch(), [{ _id: '1234', a: 1 }]); + + // second message. applied directly to the db. + stream.receive({ + msg: 'changed', + collection: coll_name, + id: '1234', + fields: { a: 2 } + }); + test.equal(coll.find({}).fetch(), [{ _id: '1234', a: 2 }]); + test.isUndefined(conn._updatesForUnknownStores[coll_name]); +}); + +Tinytest.add('livedata stub - buffering data', function(test) { + // Install special setTimeout that allows tick-by-tick control in tests using sinonjs 'lolex' + // This needs to be before the connection is instantiated. + const clock = FakeTimers.install(); + const tick = timeout => clock.tick(timeout); + + const stream = new StubStream(); + const conn = newConnection(stream, { + bufferedWritesInterval: 10, + bufferedWritesMaxAge: 40 + }); + + startAndConnect(test, stream); + + const coll_name = Random.id(); + const coll = new Mongo.Collection(coll_name, conn); + + const testDocCount = count => test.equal(coll.find({}).count(), count); + + const addDoc = () => { + stream.receive({ + msg: 'added', + collection: coll_name, + id: Random.id(), + fields: {} + }); + }; + + // Starting at 0 ticks. At this point we haven't advanced the fake clock at all. + + addDoc(); // 1st Doc + testDocCount(0); // No doc been recognized yet because it's buffered, waiting for more. + tick(6); // 6 total ticks + testDocCount(0); // Ensure that the doc still hasn't shown up, despite the clock moving forward. + tick(4); // 10 total ticks, 1st buffer interval + testDocCount(1); // No other docs have arrived, so we 'see' the 1st doc. + + addDoc(); // 2nd doc + tick(1); // 11 total ticks (1 since last flush) + testDocCount(1); // Again, second doc hasn't arrived because we're waiting for more... + tick(9); // 20 total ticks (10 ticks since last flush & the 2nd 10-tick interval) + testDocCount(2); // Now we're here and got the second document. + + // Add several docs, frequently enough that we buffer multiple times before the next flush. + addDoc(); // 3 docs + tick(6); // 26 ticks (6 since last flush) + addDoc(); // 4 docs + tick(6); // 32 ticks (12 since last flush) + addDoc(); // 5 docs + tick(6); // 38 ticks (18 since last flush) + addDoc(); // 6 docs + tick(6); // 44 ticks (24 since last flush) + addDoc(); // 7 docs + tick(9); // 53 ticks (33 since last flush) + addDoc(); // 8 docs + tick(9); // 62 ticks! (42 ticks since last flush, over max-age - next interval triggers flush) + testDocCount(2); // Still at 2 from before! (Just making sure) + tick(1); // Ok, 63 ticks (10 since last doc, so this should cause the flush of all the docs) + testDocCount(8); // See all the docs. + + // Put things back how they were. + clock.uninstall(); +}); + +Tinytest.add('livedata stub - subscribe', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + + startAndConnect(test, stream); + + // subscribe + let callback_fired = false; + const sub = conn.subscribe('my_data', function() { + callback_fired = true; + }); + test.isFalse(callback_fired); + + test.length(stream.sent, 1); + let message = JSON.parse(stream.sent.shift()); + const id = message.id; + delete message.id; + test.equal(message, { msg: 'sub', name: 'my_data', params: [] }); + + let reactivelyReady = false; + const autorunHandle = Tracker.autorun(function() { + reactivelyReady = sub.ready(); + }); + test.isFalse(reactivelyReady); + + // get the sub satisfied. callback fires. + stream.receive({ msg: 'ready', subs: [id] }); + test.isTrue(callback_fired); + Tracker.flush(); + test.isTrue(reactivelyReady); + + // Unsubscribe. + sub.stop(); + test.length(stream.sent, 1); + message = JSON.parse(stream.sent.shift()); + test.equal(message, { msg: 'unsub', id: id }); + Tracker.flush(); + test.isFalse(reactivelyReady); + + // Resubscribe. + conn.subscribe('my_data'); + test.length(stream.sent, 1); + message = JSON.parse(stream.sent.shift()); + const id2 = message.id; + test.notEqual(id, id2); + delete message.id; + test.equal(message, { msg: 'sub', name: 'my_data', params: [] }); +}); + +Tinytest.add('livedata stub - reactive subscribe', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + + startAndConnect(test, stream); + + const rFoo = new ReactiveVar('foo1'); + const rBar = new ReactiveVar('bar1'); + + const onReadyCount = {}; + const onReady = function(tag) { + return function() { + if (_.has(onReadyCount, tag)) ++onReadyCount[tag]; + else onReadyCount[tag] = 1; + }; + }; + + // Subscribe to some subs. + let stopperHandle, completerHandle; + const autorunHandle = Tracker.autorun(function() { + conn.subscribe('foo', rFoo.get(), onReady(rFoo.get())); + conn.subscribe('bar', rBar.get(), onReady(rBar.get())); + completerHandle = conn.subscribe('completer', onReady('completer')); + stopperHandle = conn.subscribe('stopper', onReady('stopper')); + }); + + let completerReady; + const readyAutorunHandle = Tracker.autorun(function() { + completerReady = completerHandle.ready(); + }); + + // Check sub messages. (Assume they are sent in the order executed.) + test.length(stream.sent, 4); + let message = JSON.parse(stream.sent.shift()); + const idFoo1 = message.id; + delete message.id; + test.equal(message, { msg: 'sub', name: 'foo', params: ['foo1'] }); + + message = JSON.parse(stream.sent.shift()); + const idBar1 = message.id; + delete message.id; + test.equal(message, { msg: 'sub', name: 'bar', params: ['bar1'] }); + + message = JSON.parse(stream.sent.shift()); + const idCompleter = message.id; + delete message.id; + test.equal(message, { msg: 'sub', name: 'completer', params: [] }); + + message = JSON.parse(stream.sent.shift()); + const idStopper = message.id; + delete message.id; + test.equal(message, { msg: 'sub', name: 'stopper', params: [] }); + + // Haven't hit onReady yet. + test.equal(onReadyCount, {}); + Tracker.flush(); + test.isFalse(completerReady); + + // "completer" gets ready now. its callback should fire. + stream.receive({ msg: 'ready', subs: [idCompleter] }); + test.equal(onReadyCount, { completer: 1 }); + test.length(stream.sent, 0); + Tracker.flush(); + test.isTrue(completerReady); + + // Stop 'stopper'. + stopperHandle.stop(); + test.length(stream.sent, 1); + message = JSON.parse(stream.sent.shift()); + test.equal(message, { msg: 'unsub', id: idStopper }); + + test.equal(onReadyCount, { completer: 1 }); + Tracker.flush(); + test.isTrue(completerReady); + + // Change the foo subscription and flush. We should sub to the new foo + // subscription, re-sub to the stopper subscription, and then unsub from the old + // foo subscription. The bar subscription should be unaffected. The completer + // subscription should call its new onReady callback, because we always + // call onReady for a given reactively-saved subscription. + // The completerHandle should have been reestablished to the ready handle. + rFoo.set('foo2'); + Tracker.flush(); + test.length(stream.sent, 3); + + message = JSON.parse(stream.sent.shift()); + const idFoo2 = message.id; + delete message.id; + test.equal(message, { msg: 'sub', name: 'foo', params: ['foo2'] }); + + message = JSON.parse(stream.sent.shift()); + const idStopperAgain = message.id; + delete message.id; + test.equal(message, { msg: 'sub', name: 'stopper', params: [] }); + + message = JSON.parse(stream.sent.shift()); + test.equal(message, { msg: 'unsub', id: idFoo1 }); + + test.equal(onReadyCount, { completer: 2 }); + test.isTrue(completerReady); + + // Ready the stopper and bar subs. Completing stopper should call only the + // onReady from the new subscription because they were separate subscriptions + // started at different times and the first one was explicitly torn down by + // the client; completing bar should call the onReady from the new + // subscription because we always call onReady for a given reactively-saved + // subscription. + stream.receive({ msg: 'ready', subs: [idStopperAgain, idBar1] }); + test.equal(onReadyCount, { completer: 2, bar1: 1, stopper: 1 }); + + // Shut down the autorun. This should unsub us from all current subs at flush + // time. + autorunHandle.stop(); + Tracker.flush(); + test.isFalse(completerReady); + readyAutorunHandle.stop(); + + test.length(stream.sent, 4); + // The order of unsubs here is not important. + const unsubMessages = _.map(stream.sent, JSON.parse); + stream.sent.length = 0; + test.equal(_.unique(_.pluck(unsubMessages, 'msg')), ['unsub']); + const actualIds = _.pluck(unsubMessages, 'id'); + const expectedIds = [idFoo2, idBar1, idCompleter, idStopperAgain]; + actualIds.sort(); + expectedIds.sort(); + test.equal(actualIds, expectedIds); +}); + +Tinytest.add('livedata stub - reactive subscribe handle correct', function( + test +) { + const stream = new StubStream(); + const conn = newConnection(stream); + + startAndConnect(test, stream); + + const rFoo = new ReactiveVar('foo1'); + + // Subscribe to some subs. + let fooHandle, fooReady; + const autorunHandle = Tracker.autorun(function() { + fooHandle = conn.subscribe('foo', rFoo.get()); + Tracker.autorun(function() { + fooReady = fooHandle.ready(); + }); + }); + + let message = JSON.parse(stream.sent.shift()); + const idFoo1 = message.id; + delete message.id; + test.equal(message, { msg: 'sub', name: 'foo', params: ['foo1'] }); + + // Not ready yet + Tracker.flush(); + test.isFalse(fooHandle.ready()); + test.isFalse(fooReady); + + // change the argument to foo. This will make a new handle, which isn't ready + // the ready autorun should invalidate, reading the new false value, and + // setting up a new dep which goes true soon + rFoo.set('foo2'); + Tracker.flush(); + test.length(stream.sent, 2); + + message = JSON.parse(stream.sent.shift()); + const idFoo2 = message.id; + delete message.id; + test.equal(message, { msg: 'sub', name: 'foo', params: ['foo2'] }); + + message = JSON.parse(stream.sent.shift()); + test.equal(message, { msg: 'unsub', id: idFoo1 }); + + Tracker.flush(); + test.isFalse(fooHandle.ready()); + test.isFalse(fooReady); + + // "foo" gets ready now. The handle should be ready and the autorun rerun + stream.receive({ msg: 'ready', subs: [idFoo2] }); + test.length(stream.sent, 0); + Tracker.flush(); + test.isTrue(fooHandle.ready()); + test.isTrue(fooReady); + + // change the argument to foo. This will make a new handle, which isn't ready + // the ready autorun should invalidate, making fooReady false too + rFoo.set('foo3'); + Tracker.flush(); + test.length(stream.sent, 2); + + message = JSON.parse(stream.sent.shift()); + const idFoo3 = message.id; + delete message.id; + test.equal(message, { msg: 'sub', name: 'foo', params: ['foo3'] }); + + message = JSON.parse(stream.sent.shift()); + test.equal(message, { msg: 'unsub', id: idFoo2 }); + + Tracker.flush(); + test.isFalse(fooHandle.ready()); + test.isFalse(fooReady); + + // "foo" gets ready again + stream.receive({ msg: 'ready', subs: [idFoo3] }); + test.length(stream.sent, 0); + Tracker.flush(); + test.isTrue(fooHandle.ready()); + test.isTrue(fooReady); + + autorunHandle.stop(); +}); + +Tinytest.add('livedata stub - this', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + + startAndConnect(test, stream); + conn.methods({ + test_this: function() { + test.isTrue(this.isSimulation); + this.unblock(); // should be a no-op + } + }); + + // should throw no exceptions + conn.call('test_this', _.identity); + // satisfy method, quiesce connection + let message = JSON.parse(stream.sent.shift()); + test.isUndefined(message.randomSeed); + test.equal(message, { + msg: 'method', + method: 'test_this', + params: [], + id: message.id + }); + test.length(stream.sent, 0); + + stream.receive({ msg: 'result', id: message.id, result: null }); + stream.receive({ msg: 'updated', methods: [message.id] }); +}); + +if (Meteor.isClient) { + Tinytest.add('livedata stub - methods', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + + startAndConnect(test, stream); + + const collName = Random.id(); + const coll = new Mongo.Collection(collName, { connection: conn }); + + // setup method + conn.methods({ + do_something: function(x) { + coll.insert({ value: x }); + } + }); + + // setup observers + const counts = { added: 0, removed: 0, changed: 0, moved: 0 }; + const handle = coll.find({}).observe({ + addedAt: function() { + counts.added += 1; + }, + removedAt: function() { + counts.removed += 1; + }, + changedAt: function() { + counts.changed += 1; + }, + movedTo: function() { + counts.moved += 1; + } + }); + + // call method with results callback + let callback1Fired = false; + conn.call('do_something', 'friday!', function(err, res) { + test.isUndefined(err); + test.equal(res, '1234'); + callback1Fired = true; + }); + test.isFalse(callback1Fired); + + // observers saw the method run. + test.equal(counts, { added: 1, removed: 0, changed: 0, moved: 0 }); + + // get response from server + const message = testGotMessage(test, stream, { + msg: 'method', + method: 'do_something', + params: ['friday!'], + id: '*', + randomSeed: '*' + }); + + test.equal(coll.find({}).count(), 1); + test.equal(coll.find({ value: 'friday!' }).count(), 1); + const docId = coll.findOne({ value: 'friday!' })._id; + + // results does not yet result in callback, because data is not + // ready. + stream.receive({ msg: 'result', id: message.id, result: '1234' }); + test.isFalse(callback1Fired); + + // result message doesn't affect data + test.equal(coll.find({}).count(), 1); + test.equal(coll.find({ value: 'friday!' }).count(), 1); + test.equal(counts, { added: 1, removed: 0, changed: 0, moved: 0 }); + + // data methods do not show up (not quiescent yet) + stream.receive({ + msg: 'added', + collection: collName, + id: MongoID.idStringify(docId), + fields: { value: 'tuesday' } + }); + test.equal(coll.find({}).count(), 1); + test.equal(coll.find({ value: 'friday!' }).count(), 1); + test.equal(counts, { added: 1, removed: 0, changed: 0, moved: 0 }); + + // send another methods (unknown on client) + let callback2Fired = false; + conn.call('do_something_else', 'monday', function(err, res) { + callback2Fired = true; + }); + test.isFalse(callback1Fired); + test.isFalse(callback2Fired); + + // test we still send a method request to server + const message2 = JSON.parse(stream.sent.shift()); + test.isUndefined(message2.randomSeed); + test.equal(message2, { + msg: 'method', + method: 'do_something_else', + params: ['monday'], + id: message2.id + }); + + // get the first data satisfied message. changes are applied to database even + // though another method is outstanding, because the other method didn't have + // a stub. and its callback is called. + stream.receive({ msg: 'updated', methods: [message.id] }); + test.isTrue(callback1Fired); + test.isFalse(callback2Fired); + + test.equal(coll.find({}).count(), 1); + test.equal(coll.find({ value: 'tuesday' }).count(), 1); + test.equal(counts, { added: 1, removed: 0, changed: 1, moved: 0 }); + + // second result + stream.receive({ msg: 'result', id: message2.id, result: 'bupkis' }); + test.isFalse(callback2Fired); + + // get second satisfied; no new changes are applied. + stream.receive({ msg: 'updated', methods: [message2.id] }); + test.isTrue(callback2Fired); + + test.equal(coll.find({}).count(), 1); + test.equal(coll.find({ value: 'tuesday', _id: docId }).count(), 1); + test.equal(counts, { added: 1, removed: 0, changed: 1, moved: 0 }); + + handle.stop(); + }); +} + +Tinytest.add('livedata stub - mutating method args', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + + startAndConnect(test, stream); + + conn.methods({ + mutateArgs: function(arg) { + arg.foo = 42; + } + }); + + conn.call('mutateArgs', { foo: 50 }, _.identity); + + // Method should be called with original arg, not mutated arg. + let message = JSON.parse(stream.sent.shift()); + test.isUndefined(message.randomSeed); + test.equal(message, { + msg: 'method', + method: 'mutateArgs', + params: [{ foo: 50 }], + id: message.id + }); + test.length(stream.sent, 0); +}); + +const observeCursor = function(test, cursor) { + const counts = { added: 0, removed: 0, changed: 0, moved: 0 }; + const expectedCounts = _.clone(counts); + const handle = cursor.observe({ + addedAt: function() { + counts.added += 1; + }, + removedAt: function() { + counts.removed += 1; + }, + changedAt: function() { + counts.changed += 1; + }, + movedTo: function() { + counts.moved += 1; + } + }); + return { + stop: _.bind(handle.stop, handle), + expectCallbacks: function(delta) { + _.each(delta, function(mod, field) { + expectedCounts[field] += mod; + }); + test.equal(counts, expectedCounts); + } + }; +}; + +// method calls another method in simulation. see not sent. +if (Meteor.isClient) { + Tinytest.add('livedata stub - methods calling methods', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + + startAndConnect(test, stream); + + const coll_name = Random.id(); + const coll = new Mongo.Collection(coll_name, { connection: conn }); + + // setup methods + conn.methods({ + do_something: function() { + conn.call('do_something_else'); + }, + do_something_else: function() { + coll.insert({ a: 1 }); + } + }); + + const o = observeCursor(test, coll.find()); + + // call method. + conn.call('do_something', _.identity); + + // see we only send message for outer methods + const message = testGotMessage(test, stream, { + msg: 'method', + method: 'do_something', + params: [], + id: '*', + randomSeed: '*' + }); + test.length(stream.sent, 0); + + // but inner method runs locally. + o.expectCallbacks({ added: 1 }); + test.equal(coll.find().count(), 1); + const docId = coll.findOne()._id; + test.equal(coll.findOne(), { _id: docId, a: 1 }); + + // we get the results + stream.receive({ msg: 'result', id: message.id, result: '1234' }); + + // get data from the method. data from this doc does not show up yet, but data + // from another doc does. + stream.receive({ + msg: 'added', + collection: coll_name, + id: MongoID.idStringify(docId), + fields: { value: 'tuesday' } + }); + o.expectCallbacks(); + test.equal(coll.findOne(docId), { _id: docId, a: 1 }); + stream.receive({ + msg: 'added', + collection: coll_name, + id: 'monkey', + fields: { value: 'bla' } + }); + o.expectCallbacks({ added: 1 }); + test.equal(coll.findOne(docId), { _id: docId, a: 1 }); + const newDoc = coll.findOne({ value: 'bla' }); + test.isTrue(newDoc); + test.equal(newDoc, { _id: newDoc._id, value: 'bla' }); + + // get method satisfied. all data shows up. the 'a' field is reverted and + // 'value' field is set. + stream.receive({ msg: 'updated', methods: [message.id] }); + o.expectCallbacks({ changed: 1 }); + test.equal(coll.findOne(docId), { _id: docId, value: 'tuesday' }); + test.equal(coll.findOne(newDoc._id), { _id: newDoc._id, value: 'bla' }); + + o.stop(); + }); +} +Tinytest.add('livedata stub - method call before connect', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + + const callbackOutput = []; + conn.call('someMethod', function(err, result) { + callbackOutput.push(result); + }); + test.equal(callbackOutput, []); + + // the real stream drops all output pre-connection + stream.sent.length = 0; + + // Now connect. + stream.reset(); + + testGotMessage(test, stream, makeConnectMessage()); + testGotMessage(test, stream, { + msg: 'method', + method: 'someMethod', + params: [], + id: '*' + }); +}); + +Tinytest.add('livedata stub - reconnect', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + + startAndConnect(test, stream); + + const collName = Random.id(); + const coll = new Mongo.Collection(collName, { connection: conn }); + + const o = observeCursor(test, coll.find()); + + // subscribe + let subCallbackFired = false; + const sub = conn.subscribe('my_data', function() { + subCallbackFired = true; + }); + test.isFalse(subCallbackFired); + + let subMessage = JSON.parse(stream.sent.shift()); + test.equal(subMessage, { + msg: 'sub', + name: 'my_data', + params: [], + id: subMessage.id + }); + + // get some data. it shows up. + stream.receive({ + msg: 'added', + collection: collName, + id: '1234', + fields: { a: 1 } + }); + + test.equal(coll.find({}).count(), 1); + o.expectCallbacks({ added: 1 }); + test.isFalse(subCallbackFired); + + stream.receive({ + msg: 'changed', + collection: collName, + id: '1234', + fields: { b: 2 } + }); + stream.receive({ + msg: 'ready', + subs: [subMessage.id] // satisfy sub + }); + test.isTrue(subCallbackFired); + subCallbackFired = false; // re-arm for test that it doesn't fire again. + + test.equal(coll.find({ a: 1, b: 2 }).count(), 1); + o.expectCallbacks({ changed: 1 }); + + // call method. + let methodCallbackFired = false; + conn.call('do_something', function() { + methodCallbackFired = true; + }); + + conn.apply('do_something_else', [], { wait: true }, _.identity); + conn.apply('do_something_later', [], _.identity); + + test.isFalse(methodCallbackFired); + + // The non-wait method should send, but not the wait method. + let methodMessage = JSON.parse(stream.sent.shift()); + test.isUndefined(methodMessage.randomSeed); + test.equal(methodMessage, { + msg: 'method', + method: 'do_something', + params: [], + id: methodMessage.id + }); + test.equal(stream.sent.length, 0); + + // more data. shows up immediately because there was no relevant method stub. + stream.receive({ + msg: 'changed', + collection: collName, + id: '1234', + fields: { c: 3 } + }); + test.equal(coll.findOne('1234'), { _id: '1234', a: 1, b: 2, c: 3 }); + o.expectCallbacks({ changed: 1 }); + + // stream reset. reconnect! we send a connect, our pending method, and our + // sub. The wait method still is blocked. + stream.reset(); + + testGotMessage(test, stream, makeConnectMessage(SESSION_ID)); + testGotMessage(test, stream, methodMessage); + testGotMessage(test, stream, subMessage); + + // reconnect with different session id + stream.receive({ msg: 'connected', session: SESSION_ID + 1 }); + + // resend data. doesn't show up: we're in reconnect quiescence. + stream.receive({ + msg: 'added', + collection: collName, + id: '1234', + fields: { a: 1, b: 2, c: 3, d: 4 } + }); + stream.receive({ + msg: 'added', + collection: collName, + id: '2345', + fields: { e: 5 } + }); + test.equal(coll.findOne('1234'), { _id: '1234', a: 1, b: 2, c: 3 }); + test.isFalse(coll.findOne('2345')); + o.expectCallbacks(); + + // satisfy and return the method + stream.receive({ + msg: 'updated', + methods: [methodMessage.id] + }); + test.isFalse(methodCallbackFired); + stream.receive({ msg: 'result', id: methodMessage.id, result: 'bupkis' }); + // The callback still doesn't fire (and we don't send the wait method): we're + // still in global quiescence + test.isFalse(methodCallbackFired); + test.equal(stream.sent.length, 0); + + // still no update. + test.equal(coll.findOne('1234'), { _id: '1234', a: 1, b: 2, c: 3 }); + test.isFalse(coll.findOne('2345')); + o.expectCallbacks(); + + // re-satisfy sub + stream.receive({ msg: 'ready', subs: [subMessage.id] }); + + // now the doc changes and method callback is called, and the wait method is + // sent. the sub callback isn't re-called. + test.isTrue(methodCallbackFired); + test.isFalse(subCallbackFired); + test.equal(coll.findOne('1234'), { _id: '1234', a: 1, b: 2, c: 3, d: 4 }); + test.equal(coll.findOne('2345'), { _id: '2345', e: 5 }); + o.expectCallbacks({ added: 1, changed: 1 }); + + let waitMethodMessage = JSON.parse(stream.sent.shift()); + test.isUndefined(waitMethodMessage.randomSeed); + test.equal(waitMethodMessage, { + msg: 'method', + method: 'do_something_else', + params: [], + id: waitMethodMessage.id + }); + test.equal(stream.sent.length, 0); + stream.receive({ msg: 'result', id: waitMethodMessage.id, result: 'bupkis' }); + test.equal(stream.sent.length, 0); + stream.receive({ msg: 'updated', methods: [waitMethodMessage.id] }); + + // wait method done means we can send the third method + test.equal(stream.sent.length, 1); + let laterMethodMessage = JSON.parse(stream.sent.shift()); + test.isUndefined(laterMethodMessage.randomSeed); + test.equal(laterMethodMessage, { + msg: 'method', + method: 'do_something_later', + params: [], + id: laterMethodMessage.id + }); + + o.stop(); +}); + +if (Meteor.isClient) { + Tinytest.add('livedata stub - reconnect non-idempotent method', function( + test + ) { + // This test is for https://github.com/meteor/meteor/issues/6108 + const stream = new StubStream(); + const conn = newConnection(stream); + + startAndConnect(test, stream); + + let firstMethodCallbackFired = false; + let firstMethodCallbackErrored = false; + let secondMethodCallbackFired = false; + let secondMethodCallbackErrored = false; + + // call with noRetry true so that the method should fail to retry on reconnect. + conn.apply('do_something', [], { noRetry: true }, function(error) { + firstMethodCallbackFired = true; + // failure on reconnect should trigger an error. + if (error && error.error === 'invocation-failed') { + firstMethodCallbackErrored = true; + } + }); + conn.apply('do_something_else', [], { noRetry: true }, function(error) { + secondMethodCallbackFired = true; + // failure on reconnect should trigger an error. + if (error && error.error === 'invocation-failed') { + secondMethodCallbackErrored = true; + } + }); + + // The method has not succeeded yet + test.isFalse(firstMethodCallbackFired); + test.isFalse(secondMethodCallbackFired); + + // send the methods + stream.sent.shift(); + stream.sent.shift(); + // reconnect + stream.reset(); + + // verify that a reconnect message was sent. + testGotMessage(test, stream, makeConnectMessage(SESSION_ID)); + // Make sure that the stream triggers connection. + stream.receive({ msg: 'connected', session: SESSION_ID + 1 }); + + //The method callback should fire even though the stream has not sent a response. + //the callback should have been fired with an error. + test.isTrue(firstMethodCallbackFired); + test.isTrue(firstMethodCallbackErrored); + test.isTrue(secondMethodCallbackFired); + test.isTrue(secondMethodCallbackErrored); + + // verify that the method message was not sent. + test.isUndefined(stream.sent.shift()); + }); +} + +function addReconnectTests(name, testFunc) { + Tinytest.add(name + ' (deprecated)', function(test) { + function deprecatedSetOnReconnect(conn, handler) { + conn.onReconnect = handler; + } + testFunc.call(this, test, deprecatedSetOnReconnect); + }); + + Tinytest.add(name, function(test) { + let stopper; + function setOnReconnect(conn, handler) { + stopper && stopper.stop(); + stopper = DDP.onReconnect(function(reconnectingConn) { + if (reconnectingConn === conn) { + handler(); + } + }); + } + testFunc.call(this, test, setOnReconnect); + stopper && stopper.stop(); + }); +} + +if (Meteor.isClient) { + addReconnectTests( + 'livedata stub - reconnect method which only got result', + function(test, setOnReconnect) { + const stream = new StubStream(); + const conn = newConnection(stream); + startAndConnect(test, stream); + + const collName = Random.id(); + const coll = new Mongo.Collection(collName, { connection: conn }); + const o = observeCursor(test, coll.find()); + + conn.methods({ + writeSomething: function() { + // stub write + coll.insert({ foo: 'bar' }); + } + }); + + test.equal(coll.find({ foo: 'bar' }).count(), 0); + + // Call a method. We'll get the result but not data-done before reconnect. + const callbackOutput = []; + const onResultReceivedOutput = []; + conn.apply( + 'writeSomething', + [], + { + onResultReceived: function(err, result) { + onResultReceivedOutput.push(result); + } + }, + function(err, result) { + callbackOutput.push(result); + } + ); + // Stub write is visible. + test.equal(coll.find({ foo: 'bar' }).count(), 1); + const stubWrittenId = coll.findOne({ foo: 'bar' })._id; + o.expectCallbacks({ added: 1 }); + // Callback not called. + test.equal(callbackOutput, []); + test.equal(onResultReceivedOutput, []); + // Method sent. + const methodId = testGotMessage(test, stream, { + msg: 'method', + method: 'writeSomething', + params: [], + id: '*', + randomSeed: '*' + }).id; + test.equal(stream.sent.length, 0); + + // Get some data. + stream.receive({ + msg: 'added', + collection: collName, + id: MongoID.idStringify(stubWrittenId), + fields: { baz: 42 } + }); + // It doesn't show up yet. + test.equal(coll.find().count(), 1); + test.equal(coll.findOne(stubWrittenId), { + _id: stubWrittenId, + foo: 'bar' + }); + o.expectCallbacks(); + + // Get the result. + stream.receive({ msg: 'result', id: methodId, result: 'bla' }); + // Data unaffected. + test.equal(coll.find().count(), 1); + test.equal(coll.findOne(stubWrittenId), { + _id: stubWrittenId, + foo: 'bar' + }); + o.expectCallbacks(); + // Callback not called, but onResultReceived is. + test.equal(callbackOutput, []); + test.equal(onResultReceivedOutput, ['bla']); + + // Reset stream. Method does NOT get resent, because its result is already + // in. Reconnect quiescence happens as soon as 'connected' is received because + // there are no pending methods or subs in need of revival. + stream.reset(); + testGotMessage(test, stream, makeConnectMessage(SESSION_ID)); + // Still holding out hope for session resumption, so nothing updated yet. + test.equal(coll.find().count(), 1); + test.equal(coll.findOne(stubWrittenId), { + _id: stubWrittenId, + foo: 'bar' + }); + o.expectCallbacks(); + test.equal(callbackOutput, []); + + // Receive 'connected': time for reconnect quiescence! Data gets updated + // locally (ie, data is reset) and callback gets called. + stream.receive({ msg: 'connected', session: SESSION_ID + 1 }); + test.equal(coll.find().count(), 0); + o.expectCallbacks({ removed: 1 }); + test.equal(callbackOutput, ['bla']); + test.equal(onResultReceivedOutput, ['bla']); + stream.receive({ + msg: 'added', + collection: collName, + id: MongoID.idStringify(stubWrittenId), + fields: { baz: 42 } + }); + test.equal(coll.findOne(stubWrittenId), { _id: stubWrittenId, baz: 42 }); + o.expectCallbacks({ added: 1 }); + + // Run method again. We're going to do the same thing this time, except we're + // also going to use an onReconnect to insert another method at reconnect + // time, which will delay reconnect quiescence. + conn.apply( + 'writeSomething', + [], + { + onResultReceived: function(err, result) { + onResultReceivedOutput.push(result); + } + }, + function(err, result) { + callbackOutput.push(result); + } + ); + // Stub write is visible. + test.equal(coll.find({ foo: 'bar' }).count(), 1); + const stubWrittenId2 = coll.findOne({ foo: 'bar' })._id; + o.expectCallbacks({ added: 1 }); + // Callback not called. + test.equal(callbackOutput, ['bla']); + test.equal(onResultReceivedOutput, ['bla']); + // Method sent. + const methodId2 = testGotMessage(test, stream, { + msg: 'method', + method: 'writeSomething', + params: [], + id: '*', + randomSeed: '*' + }).id; + test.equal(stream.sent.length, 0); + + // Get some data. + stream.receive({ + msg: 'added', + collection: collName, + id: MongoID.idStringify(stubWrittenId2), + fields: { baz: 42 } + }); + // It doesn't show up yet. + test.equal(coll.find().count(), 2); + test.equal(coll.findOne(stubWrittenId2), { + _id: stubWrittenId2, + foo: 'bar' + }); + o.expectCallbacks(); + + // Get the result. + stream.receive({ msg: 'result', id: methodId2, result: 'blab' }); + // Data unaffected. + test.equal(coll.find().count(), 2); + test.equal(coll.findOne(stubWrittenId2), { + _id: stubWrittenId2, + foo: 'bar' + }); + o.expectCallbacks(); + // Callback not called, but onResultReceived is. + test.equal(callbackOutput, ['bla']); + test.equal(onResultReceivedOutput, ['bla', 'blab']); + setOnReconnect(conn, function() { + conn.call('slowMethod', function(err, result) { + callbackOutput.push(result); + }); + }); + + // Reset stream. Method does NOT get resent, because its result is already in, + // but slowMethod gets called via onReconnect. Reconnect quiescence is now + // blocking on slowMethod. + stream.reset(); + testGotMessage(test, stream, makeConnectMessage(SESSION_ID + 1)); + const slowMethodId = testGotMessage(test, stream, { + msg: 'method', + method: 'slowMethod', + params: [], + id: '*' + }).id; + // Still holding out hope for session resumption, so nothing updated yet. + test.equal(coll.find().count(), 2); + test.equal(coll.findOne(stubWrittenId2), { + _id: stubWrittenId2, + foo: 'bar' + }); + o.expectCallbacks(); + test.equal(callbackOutput, ['bla']); + + // Receive 'connected'... but no reconnect quiescence yet due to slowMethod. + stream.receive({ msg: 'connected', session: SESSION_ID + 2 }); + test.equal(coll.find().count(), 2); + test.equal(coll.findOne(stubWrittenId2), { + _id: stubWrittenId2, + foo: 'bar' + }); + o.expectCallbacks(); + test.equal(callbackOutput, ['bla']); + + // Receive data matching our stub. It doesn't take effect yet. + stream.receive({ + msg: 'added', + collection: collName, + id: MongoID.idStringify(stubWrittenId2), + fields: { foo: 'bar' } + }); + o.expectCallbacks(); + + // slowMethod is done writing, so we get full reconnect quiescence (but no + // slowMethod callback)... ie, a reset followed by applying the data we just + // got, as well as calling the callback from the method that half-finished + // before reset. The net effect is deleting doc 'stubWrittenId'. + stream.receive({ msg: 'updated', methods: [slowMethodId] }); + test.equal(coll.find().count(), 1); + test.equal(coll.findOne(stubWrittenId2), { + _id: stubWrittenId2, + foo: 'bar' + }); + o.expectCallbacks({ removed: 1 }); + test.equal(callbackOutput, ['bla', 'blab']); + + // slowMethod returns a value now. + stream.receive({ msg: 'result', id: slowMethodId, result: 'slow' }); + o.expectCallbacks(); + test.equal(callbackOutput, ['bla', 'blab', 'slow']); + + o.stop(); + } + ); +} +Tinytest.add('livedata stub - reconnect method which only got data', function( + test +) { + const stream = new StubStream(); + const conn = newConnection(stream); + startAndConnect(test, stream); + + const collName = Random.id(); + const coll = new Mongo.Collection(collName, { connection: conn }); + const o = observeCursor(test, coll.find()); + + // Call a method. We'll get the data-done message but not the result before + // reconnect. + const callbackOutput = []; + const onResultReceivedOutput = []; + conn.apply( + 'doLittle', + [], + { + onResultReceived: function(err, result) { + onResultReceivedOutput.push(result); + } + }, + function(err, result) { + callbackOutput.push(result); + } + ); + // Callbacks not called. + test.equal(callbackOutput, []); + test.equal(onResultReceivedOutput, []); + // Method sent. + const methodId = testGotMessage(test, stream, { + msg: 'method', + method: 'doLittle', + params: [], + id: '*' + }).id; + test.equal(stream.sent.length, 0); + + // Get some data. + stream.receive({ + msg: 'added', + collection: collName, + id: 'photo', + fields: { baz: 42 } + }); + // It shows up instantly because the stub didn't write anything. + test.equal(coll.find().count(), 1); + test.equal(coll.findOne('photo'), { _id: 'photo', baz: 42 }); + o.expectCallbacks({ added: 1 }); + + // Get the data-done message. + stream.receive({ msg: 'updated', methods: [methodId] }); + // Data still here. + test.equal(coll.find().count(), 1); + test.equal(coll.findOne('photo'), { _id: 'photo', baz: 42 }); + o.expectCallbacks(); + // Method callback not called yet (no result yet). + test.equal(callbackOutput, []); + test.equal(onResultReceivedOutput, []); + + // Reset stream. Method gets resent (with same ID), and blocks reconnect + // quiescence. + stream.reset(); + testGotMessage(test, stream, makeConnectMessage(SESSION_ID)); + testGotMessage(test, stream, { + msg: 'method', + method: 'doLittle', + params: [], + id: methodId + }); + // Still holding out hope for session resumption, so nothing updated yet. + test.equal(coll.find().count(), 1); + test.equal(coll.findOne('photo'), { _id: 'photo', baz: 42 }); + o.expectCallbacks(); + test.equal(callbackOutput, []); + test.equal(onResultReceivedOutput, []); + + // Receive 'connected'. Still blocking on reconnect quiescence. + stream.receive({ msg: 'connected', session: SESSION_ID + 1 }); + test.equal(coll.find().count(), 1); + test.equal(coll.findOne('photo'), { _id: 'photo', baz: 42 }); + o.expectCallbacks(); + test.equal(callbackOutput, []); + test.equal(onResultReceivedOutput, []); + + // Receive method result. onResultReceived is called but the main callback + // isn't (ie, we don't get confused by the fact that we got data-done the + // *FIRST* time through). + stream.receive({ msg: 'result', id: methodId, result: 'res' }); + test.equal(callbackOutput, []); + test.equal(onResultReceivedOutput, ['res']); + + // Now we get data-done. Collection is reset and callback is called. + stream.receive({ msg: 'updated', methods: [methodId] }); + test.equal(coll.find().count(), 0); + o.expectCallbacks({ removed: 1 }); + test.equal(callbackOutput, ['res']); + test.equal(onResultReceivedOutput, ['res']); + + o.stop(); +}); +if (Meteor.isClient) { + Tinytest.add('livedata stub - multiple stubs same doc', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + startAndConnect(test, stream); + + const collName = Random.id(); + const coll = new Mongo.Collection(collName, { connection: conn }); + const o = observeCursor(test, coll.find()); + + conn.methods({ + insertSomething: function() { + // stub write + coll.insert({ foo: 'bar' }); + }, + updateIt: function(id) { + coll.update(id, { $set: { baz: 42 } }); + } + }); + + test.equal(coll.find().count(), 0); + + // Call the insert method. + conn.call('insertSomething', _.identity); + // Stub write is visible. + test.equal(coll.find({ foo: 'bar' }).count(), 1); + const stubWrittenId = coll.findOne({ foo: 'bar' })._id; + o.expectCallbacks({ added: 1 }); + // Method sent. + const insertMethodId = testGotMessage(test, stream, { + msg: 'method', + method: 'insertSomething', + params: [], + id: '*', + randomSeed: '*' + }).id; + test.equal(stream.sent.length, 0); + + // Call update method. + conn.call('updateIt', stubWrittenId, _.identity); + // This stub write is visible too. + test.equal(coll.find().count(), 1); + test.equal(coll.findOne(stubWrittenId), { + _id: stubWrittenId, + foo: 'bar', + baz: 42 + }); + o.expectCallbacks({ changed: 1 }); + // Method sent. + const updateMethodId = testGotMessage(test, stream, { + msg: 'method', + method: 'updateIt', + params: [stubWrittenId], + id: '*' + }).id; + test.equal(stream.sent.length, 0); + + // Get some data... slightly different than what we wrote. + stream.receive({ + msg: 'added', + collection: collName, + id: MongoID.idStringify(stubWrittenId), + fields: { + foo: 'barb', + other: 'field', + other2: 'bla' + } + }); + // It doesn't show up yet. + test.equal(coll.find().count(), 1); + test.equal(coll.findOne(stubWrittenId), { + _id: stubWrittenId, + foo: 'bar', + baz: 42 + }); + o.expectCallbacks(); + + // And get the first method-done. Still no updates to minimongo: we can't + // quiesce the doc until the second method is done. + stream.receive({ msg: 'updated', methods: [insertMethodId] }); + test.equal(coll.find().count(), 1); + test.equal(coll.findOne(stubWrittenId), { + _id: stubWrittenId, + foo: 'bar', + baz: 42 + }); + o.expectCallbacks(); + + // More data. Not quite what we wrote. Also ignored for now. + stream.receive({ + msg: 'changed', + collection: collName, + id: MongoID.idStringify(stubWrittenId), + fields: { baz: 43 }, + cleared: ['other'] + }); + test.equal(coll.find().count(), 1); + test.equal(coll.findOne(stubWrittenId), { + _id: stubWrittenId, + foo: 'bar', + baz: 42 + }); + o.expectCallbacks(); + + // Second data-ready. Now everything takes effect! + stream.receive({ msg: 'updated', methods: [updateMethodId] }); + test.equal(coll.find().count(), 1); + test.equal(coll.findOne(stubWrittenId), { + _id: stubWrittenId, + foo: 'barb', + other2: 'bla', + baz: 43 + }); + o.expectCallbacks({ changed: 1 }); + + o.stop(); + }); +} + +if (Meteor.isClient) { + Tinytest.add( + "livedata stub - unsent methods don't block quiescence", + function(test) { + // This test is for https://github.com/meteor/meteor/issues/555 + + const stream = new StubStream(); + const conn = newConnection(stream); + startAndConnect(test, stream); + + const collName = Random.id(); + const coll = new Mongo.Collection(collName, { connection: conn }); + + conn.methods({ + insertSomething: function() { + // stub write + coll.insert({ foo: 'bar' }); + } + }); + + test.equal(coll.find().count(), 0); + + // Call a random method (no-op) + conn.call('no-op', _.identity); + // Call a wait method + conn.apply('no-op', [], { wait: true }, _.identity); + // Call a method with a stub that writes. + conn.call('insertSomething', _.identity); + + // Stub write is visible. + test.equal(coll.find({ foo: 'bar' }).count(), 1); + const stubWrittenId = coll.findOne({ foo: 'bar' })._id; + + // first method sent + const firstMethodId = testGotMessage(test, stream, { + msg: 'method', + method: 'no-op', + params: [], + id: '*' + }).id; + test.equal(stream.sent.length, 0); + + // ack the first method + stream.receive({ msg: 'updated', methods: [firstMethodId] }); + stream.receive({ msg: 'result', id: firstMethodId }); + + // Wait method sent. + const waitMethodId = testGotMessage(test, stream, { + msg: 'method', + method: 'no-op', + params: [], + id: '*' + }).id; + test.equal(stream.sent.length, 0); + + // ack the wait method + stream.receive({ msg: 'updated', methods: [waitMethodId] }); + stream.receive({ msg: 'result', id: waitMethodId }); + + // insert method sent. + const insertMethodId = testGotMessage(test, stream, { + msg: 'method', + method: 'insertSomething', + params: [], + id: '*', + randomSeed: '*' + }).id; + test.equal(stream.sent.length, 0); + + // ack the insert method + stream.receive({ msg: 'updated', methods: [insertMethodId] }); + stream.receive({ msg: 'result', id: insertMethodId }); + + // simulation reverted. + test.equal(coll.find({ foo: 'bar' }).count(), 0); + } + ); +} +Tinytest.add('livedata stub - reactive resub', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + + startAndConnect(test, stream); + + const readiedSubs = {}; + const markAllReady = function() { + // synthesize a "ready" message in response to any "sub" + // message with an id we haven't seen before + _.each(stream.sent, function(msg) { + msg = JSON.parse(msg); + if (msg.msg === 'sub' && !_.has(readiedSubs, msg.id)) { + stream.receive({ msg: 'ready', subs: [msg.id] }); + readiedSubs[msg.id] = true; + } + }); + }; + + const fooArg = new ReactiveVar('A'); + let fooReady = 0; + + let inner; + const outer = Tracker.autorun(function() { + inner = Tracker.autorun(function() { + conn.subscribe('foo-sub', fooArg.get(), function() { + fooReady++; + }); + }); + }); + + markAllReady(); + let message = JSON.parse(stream.sent.shift()); + delete message.id; + test.equal(message, { msg: 'sub', name: 'foo-sub', params: ['A'] }); + test.equal(fooReady, 1); + + // Rerun the inner autorun with different subscription + // arguments. + fooArg.set('B'); + test.isTrue(inner.invalidated); + Tracker.flush(); + test.isFalse(inner.invalidated); + markAllReady(); + message = JSON.parse(stream.sent.shift()); + delete message.id; + test.equal(message, { msg: 'sub', name: 'foo-sub', params: ['B'] }); + message = JSON.parse(stream.sent.shift()); + delete message.id; + test.equal(message, { msg: 'unsub' }); + test.equal(fooReady, 2); + + // Rerun inner again with same args; should be no re-sub. + inner.invalidate(); + test.isTrue(inner.invalidated); + Tracker.flush(); + test.isFalse(inner.invalidated); + markAllReady(); + test.isUndefined(stream.sent.shift()); + test.isUndefined(stream.sent.shift()); + test.equal(fooReady, 3); + + // Rerun outer! Should still be no re-sub even though + // the inner computation is stopped and a new one is + // started. + outer.invalidate(); + test.isTrue(inner.invalidated); + Tracker.flush(); + test.isFalse(inner.invalidated); + markAllReady(); + test.isUndefined(stream.sent.shift()); + test.equal(fooReady, 4); + + // Change the subscription. Now we should get an onReady. + fooArg.set('C'); + Tracker.flush(); + markAllReady(); + message = JSON.parse(stream.sent.shift()); + delete message.id; + test.equal(message, { msg: 'sub', name: 'foo-sub', params: ['C'] }); + message = JSON.parse(stream.sent.shift()); + delete message.id; + test.equal(message, { msg: 'unsub' }); + test.equal(fooReady, 5); +}); + +Tinytest.add('livedata connection - reactive userId', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + + test.equal(conn.userId(), null); + conn.setUserId(1337); + test.equal(conn.userId(), 1337); +}); + +Tinytest.add('livedata connection - two wait methods', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + startAndConnect(test, stream); + + const collName = Random.id(); + const coll = new Mongo.Collection(collName, { connection: conn }); + + // setup method + conn.methods({ do_something: function(x) {} }); + + const responses = []; + conn.apply('do_something', ['one!'], function() { + responses.push('one'); + }); + let one_message = JSON.parse(stream.sent.shift()); + test.equal(one_message.params, ['one!']); + + conn.apply('do_something', ['two!'], { wait: true }, function() { + responses.push('two'); + }); + // 'two!' isn't sent yet, because it's a wait method. + test.equal(stream.sent.length, 0); + + conn.apply('do_something', ['three!'], function() { + responses.push('three'); + }); + conn.apply('do_something', ['four!'], function() { + responses.push('four'); + }); + + conn.apply('do_something', ['five!'], { wait: true }, function() { + responses.push('five'); + }); + + conn.apply('do_something', ['six!'], function() { + responses.push('six'); + }); + + // Verify that we did not send any more methods since we are still waiting on + // 'one!'. + test.equal(stream.sent.length, 0); + + // Receive some data. "one" is not a wait method and there are no stubs, so it + // gets applied immediately. + test.equal(coll.find().count(), 0); + stream.receive({ + msg: 'added', + collection: collName, + id: 'foo', + fields: { x: 1 } + }); + test.equal(coll.find().count(), 1); + test.equal(coll.findOne('foo'), { _id: 'foo', x: 1 }); + + // Let "one!" finish. Both messages are required to fire the callback. + stream.receive({ msg: 'result', id: one_message.id }); + test.equal(responses, []); + stream.receive({ msg: 'updated', methods: [one_message.id] }); + test.equal(responses, ['one']); + + // Now we've send out "two!". + let two_message = JSON.parse(stream.sent.shift()); + test.equal(two_message.params, ['two!']); + + // But still haven't sent "three!". + test.equal(stream.sent.length, 0); + + // Receive more data. "two" is a wait method, so the data doesn't get applied + // yet. + stream.receive({ + msg: 'changed', + collection: collName, + id: 'foo', + fields: { y: 3 } + }); + test.equal(coll.find().count(), 1); + test.equal(coll.findOne('foo'), { _id: 'foo', x: 1 }); + + // Let "two!" finish, with its end messages in the opposite order to "one!". + stream.receive({ msg: 'updated', methods: [two_message.id] }); + test.equal(responses, ['one']); + test.equal(stream.sent.length, 0); + // data-done message is enough to allow data to be written. + test.equal(coll.find().count(), 1); + test.equal(coll.findOne('foo'), { _id: 'foo', x: 1, y: 3 }); + stream.receive({ msg: 'result', id: two_message.id }); + test.equal(responses, ['one', 'two']); + + // Verify that we just sent "three!" and "four!" now that we got + // responses for "one!" and "two!" + test.equal(stream.sent.length, 2); + let three_message = JSON.parse(stream.sent.shift()); + test.equal(three_message.params, ['three!']); + let four_message = JSON.parse(stream.sent.shift()); + test.equal(four_message.params, ['four!']); + + // Out of order response is OK for non-wait methods. + stream.receive({ msg: 'result', id: three_message.id }); + stream.receive({ msg: 'result', id: four_message.id }); + stream.receive({ msg: 'updated', methods: [four_message.id] }); + test.equal(responses, ['one', 'two', 'four']); + test.equal(stream.sent.length, 0); + + // Let three finish too. + stream.receive({ msg: 'updated', methods: [three_message.id] }); + test.equal(responses, ['one', 'two', 'four', 'three']); + + // Verify that we just sent "five!" (the next wait method). + test.equal(stream.sent.length, 1); + let five_message = JSON.parse(stream.sent.shift()); + test.equal(five_message.params, ['five!']); + test.equal(responses, ['one', 'two', 'four', 'three']); + + // Let five finish. + stream.receive({ msg: 'result', id: five_message.id }); + stream.receive({ msg: 'updated', methods: [five_message.id] }); + test.equal(responses, ['one', 'two', 'four', 'three', 'five']); + + let six_message = JSON.parse(stream.sent.shift()); + test.equal(six_message.params, ['six!']); +}); + +addReconnectTests( + 'livedata connection - onReconnect prepends messages correctly with a wait method', + function(test, setOnReconnect) { + const stream = new StubStream(); + const conn = newConnection(stream); + startAndConnect(test, stream); + + // setup method + conn.methods({ do_something: function(x) {} }); + + setOnReconnect(conn, function() { + conn.apply('do_something', ['reconnect zero'], _.identity); + conn.apply('do_something', ['reconnect one'], _.identity); + conn.apply('do_something', ['reconnect two'], { wait: true }, _.identity); + conn.apply('do_something', ['reconnect three'], _.identity); + }); + + conn.apply('do_something', ['one'], _.identity); + conn.apply('do_something', ['two'], { wait: true }, _.identity); + conn.apply('do_something', ['three'], _.identity); + + // reconnect + stream.sent = []; + stream.reset(); + testGotMessage(test, stream, makeConnectMessage(conn._lastSessionId)); + + // Test that we sent what we expect to send, and we're blocked on + // what we expect to be blocked. The subsequent logic to correctly + // read the wait flag is tested separately. + test.equal( + _.map(stream.sent, function(msg) { + return JSON.parse(msg).params[0]; + }), + ['reconnect zero', 'reconnect one'] + ); + + // white-box test: + test.equal( + _.map(conn._outstandingMethodBlocks, function(block) { + return [ + block.wait, + _.map(block.methods, function(method) { + return method._message.params[0]; + }) + ]; + }), + [ + [false, ['reconnect zero', 'reconnect one']], + [true, ['reconnect two']], + [false, ['reconnect three', 'one']], + [true, ['two']], + [false, ['three']] + ] + ); + } +); + +Tinytest.add('livedata connection - ping without id', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + startAndConnect(test, stream); + + stream.receive({ msg: 'ping' }); + testGotMessage(test, stream, { msg: 'pong' }); +}); + +Tinytest.add('livedata connection - ping with id', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + startAndConnect(test, stream); + + const id = Random.id(); + stream.receive({ msg: 'ping', id: id }); + testGotMessage(test, stream, { msg: 'pong', id: id }); +}); + +_.each(DDPCommon.SUPPORTED_DDP_VERSIONS, function(version) { + Tinytest.addAsync('livedata connection - ping from ' + version, function( + test, + onComplete + ) { + const connection = new Connection(getSelfConnectionUrl(), { + reloadWithOutstanding: true, + supportedDDPVersions: [version], + onDDPVersionNegotiationFailure: function() { + test.fail(); + onComplete(); + }, + onConnected: function() { + test.equal(connection._version, version); + // It's a little naughty to access _stream and _send, but it works... + connection._stream.on('message', function(json) { + let msg = JSON.parse(json); + let done = false; + if (msg.msg === 'pong') { + test.notEqual(version, 'pre1'); + done = true; + } else if (msg.msg === 'error') { + // Version pre1 does not play ping-pong + test.equal(version, 'pre1'); + done = true; + } else { + Meteor._debug('Got unexpected message: ' + json); + } + if (done) { + connection._stream.disconnect({ _permanent: true }); + onComplete(); + } + }); + connection._send({ msg: 'ping' }); + } + }); + }); +}); + +const getSelfConnectionUrl = function() { + if (Meteor.isClient) { + let ddpUrl = Meteor._relativeToSiteRootUrl('/'); + if (typeof __meteor_runtime_config__ !== 'undefined') { + if (__meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL) + ddpUrl = __meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL; + } + return ddpUrl; + } else { + return Meteor.absoluteUrl(); + } +}; + +if (Meteor.isServer) { + Meteor.methods({ + reverse: function(arg) { + // Return something notably different from reverse.meteor.com. + return ( + arg + .split('') + .reverse() + .join('') + ' LOCAL' + ); + } + }); +} + +testAsyncMulti('livedata connection - reconnect to a different server', [ + function(test, expect) { + const self = this; + self.conn = DDP.connect('reverse.meteor.com'); + pollUntil( + expect, + function() { + return self.conn.status().connected; + }, + 5000, + 100, + false + ); + }, + function(test, expect) { + const self = this; + self.doTest = self.conn.status().connected; + if (self.doTest) { + self.conn.call( + 'reverse', + 'foo', + expect(function(err, res) { + test.equal(res, 'oof'); + }) + ); + } + }, + function(test, expect) { + const self = this; + if (self.doTest) { + self.conn.reconnect({ url: getSelfConnectionUrl() }); + self.conn.call( + 'reverse', + 'bar', + expect(function(err, res) { + test.equal(res, 'rab LOCAL'); + }) + ); + } + } +]); + +Tinytest.addAsync( + 'livedata connection - version negotiation requires renegotiating', + function(test, onComplete) { + const connection = new Connection(getSelfConnectionUrl(), { + reloadWithOutstanding: true, + supportedDDPVersions: ['garbled', DDPCommon.SUPPORTED_DDP_VERSIONS[0]], + onDDPVersionNegotiationFailure: function() { + test.fail(); + onComplete(); + }, + onConnected: function() { + test.equal(connection._version, DDPCommon.SUPPORTED_DDP_VERSIONS[0]); + connection._stream.disconnect({ _permanent: true }); + onComplete(); + } + }); + } +); + +Tinytest.addAsync('livedata connection - version negotiation error', function( + test, + onComplete +) { + const connection = new Connection(getSelfConnectionUrl(), { + reloadWithOutstanding: true, + supportedDDPVersions: ['garbled', 'more garbled'], + onDDPVersionNegotiationFailure: function() { + test.equal(connection.status().status, 'failed'); + test.matches( + connection.status().reason, + /DDP version negotiation failed/ + ); + test.isFalse(connection.status().connected); + onComplete(); + }, + onConnected: function() { + test.fail(); + onComplete(); + } + }); +}); + +addReconnectTests( + 'livedata connection - onReconnect prepends messages correctly without a wait method', + function(test, setOnReconnect) { + const stream = new StubStream(); + const conn = newConnection(stream); + startAndConnect(test, stream); + + // setup method + conn.methods({ do_something: function(x) {} }); + + setOnReconnect(conn, function() { + conn.apply('do_something', ['reconnect one'], _.identity); + conn.apply('do_something', ['reconnect two'], _.identity); + conn.apply('do_something', ['reconnect three'], _.identity); + }); + + conn.apply('do_something', ['one'], _.identity); + conn.apply('do_something', ['two'], { wait: true }, _.identity); + conn.apply('do_something', ['three'], { wait: true }, _.identity); + conn.apply('do_something', ['four'], _.identity); + + // reconnect + stream.sent = []; + stream.reset(); + testGotMessage(test, stream, makeConnectMessage(conn._lastSessionId)); + + // Test that we sent what we expect to send, and we're blocked on + // what we expect to be blocked. The subsequent logic to correctly + // read the wait flag is tested separately. + test.equal( + _.map(stream.sent, function(msg) { + return JSON.parse(msg).params[0]; + }), + ['reconnect one', 'reconnect two', 'reconnect three', 'one'] + ); + + // white-box test: + test.equal( + _.map(conn._outstandingMethodBlocks, function(block) { + return [ + block.wait, + _.map(block.methods, function(method) { + return method._message.params[0]; + }) + ]; + }), + [ + [false, ['reconnect one', 'reconnect two', 'reconnect three', 'one']], + [true, ['two']], + [true, ['three']], + [false, ['four']] + ] + ); + } +); + +addReconnectTests( + 'livedata connection - onReconnect with sent messages', + function(test, setOnReconnect) { + const stream = new StubStream(); + const conn = newConnection(stream); + startAndConnect(test, stream); + + // setup method + conn.methods({ do_something: function(x) {} }); + + setOnReconnect(conn, function() { + conn.apply('do_something', ['login'], { wait: true }, _.identity); + }); + + conn.apply('do_something', ['one'], _.identity); + + // initial connect + stream.sent = []; + stream.reset(); + testGotMessage(test, stream, makeConnectMessage(conn._lastSessionId)); + + // Test that we sent just the login message. + const loginId = testGotMessage(test, stream, { + msg: 'method', + method: 'do_something', + params: ['login'], + id: '*' + }).id; + + // we connect. + stream.receive({ msg: 'connected', session: Random.id() }); + test.length(stream.sent, 0); + + // login got result (but not yet data) + stream.receive({ msg: 'result', id: loginId, result: 'foo' }); + test.length(stream.sent, 0); + + // login got data. now we send next method. + stream.receive({ msg: 'updated', methods: [loginId] }); + + testGotMessage(test, stream, { + msg: 'method', + method: 'do_something', + params: ['one'], + id: '*' + }).id; + } +); + +addReconnectTests('livedata stub - reconnect double wait method', function( + test, + setOnReconnect +) { + const stream = new StubStream(); + const conn = newConnection(stream); + startAndConnect(test, stream); + + const output = []; + setOnReconnect(conn, function() { + conn.apply('reconnectMethod', [], { wait: true }, function(err, result) { + output.push('reconnect'); + }); + }); + + conn.apply('halfwayMethod', [], { wait: true }, function(err, result) { + output.push('halfway'); + }); + + test.equal(output, []); + // Method sent. + const halfwayId = testGotMessage(test, stream, { + msg: 'method', + method: 'halfwayMethod', + params: [], + id: '*' + }).id; + test.equal(stream.sent.length, 0); + + // Get the result. This means it will not be resent. + stream.receive({ msg: 'result', id: halfwayId, result: 'bla' }); + // Callback not called. + test.equal(output, []); + + // Reset stream. halfwayMethod does NOT get resent, but reconnectMethod does! + // Reconnect quiescence happens when reconnectMethod is done. + stream.reset(); + testGotMessage(test, stream, makeConnectMessage(SESSION_ID)); + const reconnectId = testGotMessage(test, stream, { + msg: 'method', + method: 'reconnectMethod', + params: [], + id: '*' + }).id; + test.length(stream.sent, 0); + // Still holding out hope for session resumption, so no callbacks yet. + test.equal(output, []); + + // Receive 'connected', but reconnect quiescence is blocking on + // reconnectMethod. + stream.receive({ msg: 'connected', session: SESSION_ID + 1 }); + test.equal(output, []); + + // Data-done for reconnectMethod. This gets us to reconnect quiescence, so + // halfwayMethod's callback fires. reconnectMethod's is still waiting on its + // result. + stream.receive({ msg: 'updated', methods: [reconnectId] }); + test.equal(output.shift(), 'halfway'); + test.equal(output, []); + + // Get result of reconnectMethod. Its callback fires. + stream.receive({ msg: 'result', id: reconnectId, result: 'foo' }); + test.equal(output.shift(), 'reconnect'); + test.equal(output, []); + + // Call another method. It should be delivered immediately. This is a + // regression test for a case where it never got delivered because there was + // an empty block in _outstandingMethodBlocks blocking it from being sent. + conn.call('lastMethod', _.identity); + testGotMessage(test, stream, { + msg: 'method', + method: 'lastMethod', + params: [], + id: '*' + }); +}); + +Tinytest.add('livedata stub - subscribe errors', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + + startAndConnect(test, stream); + + // subscribe + let onReadyFired = false; + let subErrorInStopped = null; + let subErrorInError = null; + + conn.subscribe('unknownSub', { + onReady: function() { + onReadyFired = true; + }, + + // We now have two ways to get the error from a subscription: + // 1. onStop, which is called no matter what when the subscription is + // stopped (a lifecycle callback) + // 2. onError, which is deprecated and is called only if there is an + // error + onStop: function(error) { + subErrorInStopped = error; + }, + onError: function(error) { + subErrorInError = error; + } + }); + + test.isFalse(onReadyFired); + test.equal(subErrorInStopped, null); + + // XXX COMPAT WITH 1.0.3.1 #errorCallback + test.equal(subErrorInError, null); + + let subMessage = JSON.parse(stream.sent.shift()); + test.equal(subMessage, { + msg: 'sub', + name: 'unknownSub', + params: [], + id: subMessage.id + }); + + // Reject the sub. + stream.receive({ + msg: 'nosub', + id: subMessage.id, + error: new Meteor.Error(404, 'Subscription not found') + }); + test.isFalse(onReadyFired); + + // Check the error passed to the stopped callback was correct + test.instanceOf(subErrorInStopped, Meteor.Error); + test.equal(subErrorInStopped.error, 404); + test.equal(subErrorInStopped.reason, 'Subscription not found'); + + // Check the error passed to the error callback was correct + // XXX COMPAT WITH 1.0.3.1 #errorCallback + test.instanceOf(subErrorInError, Meteor.Error); + test.equal(subErrorInError.error, 404); + test.equal(subErrorInError.reason, 'Subscription not found'); + + // stream reset: reconnect! + stream.reset(); + // We send a connect. + testGotMessage(test, stream, makeConnectMessage(SESSION_ID)); + // We should NOT re-sub to the sub, because we processed the error. + test.length(stream.sent, 0); + test.isFalse(onReadyFired); +}); + +Tinytest.add('livedata stub - subscribe stop', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + + startAndConnect(test, stream); + + // subscribe + let onReadyFired = false; + let onStopFired = false; + let subErrorInStopped = null; + + const sub = conn.subscribe('my_data', { + onStop: function(error) { + onStopFired = true; + subErrorInStopped = error; + } + }); + + test.equal(subErrorInStopped, null); + + sub.stop(); + + test.isTrue(onStopFired); + test.equal(subErrorInStopped, undefined); +}); + +if (Meteor.isClient) { + Tinytest.add('livedata stub - stubs before connected', function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + + const collName = Random.id(); + const coll = new Mongo.Collection(collName, { connection: conn }); + + // Start and send "connect", but DON'T get 'connected' quite yet. + stream.reset(); // initial connection start. + + testGotMessage(test, stream, makeConnectMessage()); + test.length(stream.sent, 0); + + // Insert a document. The stub updates "conn" directly. + coll.insert({ _id: 'foo', bar: 42 }, _.identity); + test.equal(coll.find().count(), 1); + test.equal(coll.findOne(), { _id: 'foo', bar: 42 }); + // It also sends the method message. + let methodMessage = JSON.parse(stream.sent.shift()); + test.isUndefined(methodMessage.randomSeed); + test.equal(methodMessage, { + msg: 'method', + method: '/' + collName + '/insert', + params: [{ _id: 'foo', bar: 42 }], + id: methodMessage.id + }); + test.length(stream.sent, 0); + + // Now receive a connected message. This should not clear the + // _documentsWrittenByStub state! + stream.receive({ msg: 'connected', session: SESSION_ID }); + test.length(stream.sent, 0); + test.equal(coll.find().count(), 1); + + // Now receive the "updated" message for the method. This should revert the + // insert. + stream.receive({ msg: 'updated', methods: [methodMessage.id] }); + test.length(stream.sent, 0); + test.equal(coll.find().count(), 0); + }); +} + +if (Meteor.isClient) { + Tinytest.add( + 'livedata stub - method call between reset and quiescence', + function(test) { + const stream = new StubStream(); + const conn = newConnection(stream); + + startAndConnect(test, stream); + + const collName = Random.id(); + const coll = new Mongo.Collection(collName, { connection: conn }); + + conn.methods({ + update_value: function() { + coll.update('aaa', { value: 222 }); + } + }); + + // Set up test subscription. + const sub = conn.subscribe('test_data'); + let subMessage = JSON.parse(stream.sent.shift()); + test.equal(subMessage, { + msg: 'sub', + name: 'test_data', + params: [], + id: subMessage.id + }); + test.length(stream.sent, 0); + + const subDocMessage = { + msg: 'added', + collection: collName, + id: 'aaa', + fields: { value: 111 } + }; + + const subReadyMessage = { msg: 'ready', subs: [subMessage.id] }; + + stream.receive(subDocMessage); + stream.receive(subReadyMessage); + test.isTrue(coll.findOne('aaa').value == 111); + + // Initiate reconnect. + stream.reset(); + testGotMessage(test, stream, makeConnectMessage(SESSION_ID)); + testGotMessage(test, stream, subMessage); + stream.receive({ msg: 'connected', session: SESSION_ID + 1 }); + + // Now in reconnect, can still see the document. + test.isTrue(coll.findOne('aaa').value == 111); + + conn.call('update_value'); + + // Observe the stub-written value. + test.isTrue(coll.findOne('aaa').value == 222); + + let methodMessage = JSON.parse(stream.sent.shift()); + test.equal(methodMessage, { + msg: 'method', + method: 'update_value', + params: [], + id: methodMessage.id + }); + test.length(stream.sent, 0); + + stream.receive(subDocMessage); + stream.receive(subReadyMessage); + + // By this point quiescence is reached and stores have been reset. + + // The stub-written value is still there. + test.isTrue(coll.findOne('aaa').value == 222); + + stream.receive({ + msg: 'changed', + collection: collName, + id: 'aaa', + fields: { value: 333 } + }); + stream.receive({ msg: 'updated', methods: [methodMessage.id] }); + stream.receive({ msg: 'result', id: methodMessage.id, result: null }); + + // Server wrote a different value, make sure it's visible now. + test.isTrue(coll.findOne('aaa').value == 333); + } + ); + + Tinytest.add('livedata stub - buffering and methods interaction', function( + test + ) { + const stream = new StubStream(); + const conn = newConnection(stream, { + // A very high values so that all messages are effectively buffered. + bufferedWritesInterval: 10000, + bufferedWritesMaxAge: 10000 + }); + + startAndConnect(test, stream); + + const collName = Random.id(); + const coll = new Mongo.Collection(collName, { connection: conn }); + + conn.methods({ + update_value: function() { + const value = coll.findOne('aaa').subscription; + // Method should have access to the latest value of the collection. + coll.update('aaa', { $set: { method: value + 110 } }); + } + }); + + // Set up test subscription. + const sub = conn.subscribe('test_data'); + let subMessage = JSON.parse(stream.sent.shift()); + test.equal(subMessage, { + msg: 'sub', + name: 'test_data', + params: [], + id: subMessage.id + }); + test.length(stream.sent, 0); + + const subDocMessage = { + msg: 'added', + collection: collName, + id: 'aaa', + fields: { subscription: 111 } + }; + + const subReadyMessage = { msg: 'ready', subs: [subMessage.id] }; + + stream.receive(subDocMessage); + stream.receive(subReadyMessage); + test.equal(coll.findOne('aaa').subscription, 111); + + const subDocChangeMessage = { + msg: 'changed', + collection: collName, + id: 'aaa', + fields: { subscription: 112 } + }; + + stream.receive(subDocChangeMessage); + // Still 111 because buffer has not been flushed. + test.equal(coll.findOne('aaa').subscription, 111); + + // Call updates the stub. + conn.call('update_value'); + + // Observe the stub-written value. + test.equal(coll.findOne('aaa').method, 222); + // subscription field is updated to the latest value + // because of the method call. + test.equal(coll.findOne('aaa').subscription, 112); + + let methodMessage = JSON.parse(stream.sent.shift()); + test.equal(methodMessage, { + msg: 'method', + method: 'update_value', + params: [], + id: methodMessage.id + }); + test.length(stream.sent, 0); + + // "Server-side" change from the method arrives and method returns. + // With potentially fixed value for method field, if stub didn't + // use 112 as the subscription field value. + stream.receive({ + msg: 'changed', + collection: collName, + id: 'aaa', + fields: { method: 222 } + }); + stream.receive({ msg: 'updated', methods: [methodMessage.id] }); + stream.receive({ msg: 'result', id: methodMessage.id, result: null }); + + test.equal(coll.findOne('aaa').method, 222); + test.equal(coll.findOne('aaa').subscription, 112); + + // Buffer should already be flushed because of a non-update message. + // And after a flush we really want subscription field to be 112. + conn._flushBufferedWrites(); + test.equal(coll.findOne('aaa').method, 222); + test.equal(coll.findOne('aaa').subscription, 112); + }); +} + +// XXX also test: +// - reconnect, with session resume. +// - restart on update flag +// - on_update event +// - reloading when the app changes, including session migration diff --git a/packages/ddp-client-async/test/livedata_test_service.js b/packages/ddp-client-async/test/livedata_test_service.js new file mode 100644 index 0000000000..db32cf3cc4 --- /dev/null +++ b/packages/ddp-client-async/test/livedata_test_service.js @@ -0,0 +1,376 @@ +Meteor.methods({ + nothing: function() { + // No need to check if there are no arguments. + }, + echo: function(/* arguments */) { + check(arguments, [Match.Any]); + return _.toArray(arguments); + }, + echoOne: function(/*arguments*/) { + check(arguments, [Match.Any]); + return arguments[0]; + }, + exception: function(where, options) { + check(where, String); + check( + options, + Match.Optional({ + intended: Match.Optional(Boolean), + throwThroughFuture: Match.Optional(Boolean) + }) + ); + options = options || Object.create(null); + const shouldThrow = + (Meteor.isServer && where === 'server') || + (Meteor.isClient && where === 'client') || + where === 'both'; + + if (shouldThrow) { + let e; + if (options.intended) + e = new Meteor.Error(999, 'Client-visible test exception'); + else e = new Error('Test method throwing an exception'); + e._expectedByTest = true; + + // We used to improperly serialize errors that were thrown through a + // future first. + if (Meteor.isServer && options.throwThroughFuture) { + const Future = Npm.require('fibers/future'); + const f = new Future(); + f['throw'](e); + e = f.wait(); + } + throw e; + } + }, + setUserId: function(userId) { + check(userId, Match.OneOf(String, null)); + this.setUserId(userId); + } +}); + +// Methods to help test applying methods with `wait: true`: delayedTrue returns +// true 1s after being run unless makeDelayedTrueImmediatelyReturnFalse was run +// in the meanwhile. Increasing the timeout makes the "wait: true" test slower; +// decreasing the timeout makes the "wait: false" test flakier (ie, the timeout +// could fire before processing the second method). +if (Meteor.isServer) { + // Keys are random tokens, used to isolate multiple test invocations from each + // other. + const waiters = Object.create(null); + + const Future = Npm.require('fibers/future'); + + const returnThroughFuture = function(token, returnValue) { + // Make sure that when we call return, the fields are already cleared. + const record = waiters[token]; + if (!record) return; + delete waiters[token]; + record.future['return'](returnValue); + }; + + Meteor.methods({ + delayedTrue: function(token) { + check(token, String); + const record = (waiters[token] = { + future: new Future(), + timer: Meteor.setTimeout(function() { + returnThroughFuture(token, true); + }, 1000) + }); + + this.unblock(); + return record.future.wait(); + }, + makeDelayedTrueImmediatelyReturnFalse: function(token) { + check(token, String); + const record = waiters[token]; + if (!record) return; // since delayedTrue's timeout had already run + clearTimeout(record.timer); + returnThroughFuture(token, false); + } + }); +} + +/*****/ + +Ledger = new Mongo.Collection('ledger'); +Ledger.allow({ + insert: function() { + return true; + }, + update: function() { + return true; + }, + remove: function() { + return true; + }, + fetch: [] +}); + +Meteor.startup(function() { + if (Meteor.isServer) Ledger.remove({}); // XXX can this please be Ledger.remove()? +}); + +if (Meteor.isServer) + Meteor.publish('ledger', function(world) { + check(world, String); + return Ledger.find({ world: world }); + }); + +Meteor.methods({ + 'ledger/transfer': function(world, from_name, to_name, amount, cheat) { + check(world, String); + check(from_name, String); + check(to_name, String); + check(amount, Number); + check(cheat, Match.Optional(Boolean)); + const from = Ledger.findOne({ name: from_name, world: world }); + const to = Ledger.findOne({ name: to_name, world: world }); + + if (Meteor.isServer) cheat = false; + + if (!from) + throw new Meteor.Error( + 404, + 'No such account ' + from_name + ' in ' + world + ); + + if (!to) + throw new Meteor.Error( + 404, + 'No such account ' + to_name + ' in ' + world + ); + + if (from.balance < amount && !cheat) + throw new Meteor.Error(409, 'Insufficient funds'); + + Ledger.update(from._id, { $inc: { balance: -amount } }); + Ledger.update(to._id, { $inc: { balance: amount } }); + } +}); + +/*****/ + +/// Helpers for "livedata - changing userid reruns subscriptions..." + +objectsWithUsers = new Mongo.Collection('objectsWithUsers'); + +if (Meteor.isServer) { + objectsWithUsers.remove({}); + objectsWithUsers.insert({ name: 'owned by none', ownerUserIds: [null] }); + objectsWithUsers.insert({ name: 'owned by one - a', ownerUserIds: ['1'] }); + objectsWithUsers.insert({ + name: 'owned by one/two - a', + ownerUserIds: ['1', '2'] + }); + objectsWithUsers.insert({ + name: 'owned by one/two - b', + ownerUserIds: ['1', '2'] + }); + objectsWithUsers.insert({ name: 'owned by two - a', ownerUserIds: ['2'] }); + objectsWithUsers.insert({ name: 'owned by two - b', ownerUserIds: ['2'] }); + + Meteor.publish('objectsWithUsers', function() { + return objectsWithUsers.find( + { ownerUserIds: this.userId }, + { fields: { ownerUserIds: 0 } } + ); + }); + + (function() { + const userIdWhenStopped = Object.create(null); + Meteor.publish('recordUserIdOnStop', function(key) { + check(key, String); + const self = this; + self.onStop(function() { + userIdWhenStopped[key] = self.userId; + }); + }); + + Meteor.methods({ + userIdWhenStopped: function(key) { + check(key, String); + return userIdWhenStopped[key]; + } + }); + })(); +} + +/*****/ + +/// Helper for "livedata - setUserId fails when called on server" + +if (Meteor.isServer) { + Meteor.startup(function() { + errorThrownWhenCallingSetUserIdDirectlyOnServer = null; + try { + Meteor.call('setUserId', '1000'); + } catch (e) { + errorThrownWhenCallingSetUserIdDirectlyOnServer = e; + } + }); +} + +/// Helper for "livedata - no setUserId after unblock" + +if (Meteor.isServer) { + Meteor.methods({ + setUserIdAfterUnblock: function() { + this.unblock(); + let threw = false; + const originalUserId = this.userId; + try { + // Calling setUserId after unblock should throw an error (and not mutate + // userId). + this.setUserId(originalUserId + 'bla'); + } catch (e) { + threw = true; + } + return threw && this.userId === originalUserId; + } + }); +} + +/*****/ + +/// Helper for "livedata - overlapping universal subs" + +if (Meteor.isServer) { + (function() { + const collName = 'overlappingUniversalSubs'; + const universalSubscribers = [[], []]; + + _.each([0, 1], function(index) { + Meteor.publish(null, function() { + const sub = this; + universalSubscribers[index].push(sub); + sub.onStop(function() { + universalSubscribers[index] = _.without( + universalSubscribers[index], + sub + ); + }); + }); + }); + + Meteor.methods({ + testOverlappingSubs: function(token) { + check(token, String); + _.each(universalSubscribers[0], function(sub) { + sub.added(collName, token, {}); + }); + _.each(universalSubscribers[1], function(sub) { + sub.added(collName, token, {}); + }); + _.each(universalSubscribers[0], function(sub) { + sub.removed(collName, token); + }); + } + }); + })(); +} + +/// Helper for "livedata - runtime universal sub creation" + +if (Meteor.isServer) { + Meteor.methods({ + runtimeUniversalSubCreation: function(token) { + check(token, String); + Meteor.publish(null, function() { + this.added('runtimeSubCreation', token, {}); + }); + } + }); +} + +/// Helper for "livedata - publisher errors" + +if (Meteor.isServer) { + Meteor.publish('publisherErrors', function(collName, options) { + check(collName, String); + // See below to see what options are accepted. + check(options, Object); + const sub = this; + + // First add a random item, which should be cleaned up. We use ready/onReady + // to make sure that the second test block is only called after the added is + // processed, so that there's any chance of the coll.find().count() failing. + sub.added(collName, Random.id(), { foo: 42 }); + sub.ready(); + + if (options.stopInHandler) { + sub.stop(); + return; + } + + let error; + if (options.internalError) { + error = new Error('Egads!'); + error._expectedByTest = true; // don't log + } else { + error = new Meteor.Error(412, 'Explicit error'); + } + if (options.throwInHandler) { + throw error; + } else if (options.errorInHandler) { + sub.error(error); + } else if (options.throwWhenUserIdSet) { + if (sub.userId) throw error; + } else if (options.errorLater) { + Meteor.defer(function() { + sub.error(error); + }); + } + }); +} + +/*****/ + +/// Helpers for "livedata - publish multiple cursors" +One = new Mongo.Collection('collectionOne'); +Two = new Mongo.Collection('collectionTwo'); + +if (Meteor.isServer) { + One.remove({}); + One.insert({ name: 'value1' }); + One.insert({ name: 'value2' }); + + Two.remove({}); + Two.insert({ name: 'value3' }); + Two.insert({ name: 'value4' }); + Two.insert({ name: 'value5' }); + + Meteor.publish('multiPublish', function(options) { + // See below to see what options are accepted. + check(options, Object); + if (options.normal) { + return [One.find(), Two.find()]; + } else if (options.dup) { + // Suppress the log of the expected internal error. + Meteor._suppress_log(1); + return [ + One.find(), + One.find({ name: 'value2' }), // multiple cursors for one collection - error + Two.find() + ]; + } else if (options.notCursor) { + // Suppress the log of the expected internal error. + Meteor._suppress_log(1); + return [One.find(), 'not a cursor', Two.find()]; + } else throw 'unexpected options'; + }); +} + +/// Helper for "livedata - result by value" +const resultByValueArrays = Object.create(null); +Meteor.methods({ + getArray: function(testId) { + if (!_.has(resultByValueArrays, testId)) resultByValueArrays[testId] = []; + return resultByValueArrays[testId]; + }, + pushToArray: function(testId, value) { + if (!_.has(resultByValueArrays, testId)) resultByValueArrays[testId] = []; + resultByValueArrays[testId].push(value); + } +}); diff --git a/packages/ddp-client-async/test/livedata_tests.js b/packages/ddp-client-async/test/livedata_tests.js new file mode 100644 index 0000000000..a5fae51937 --- /dev/null +++ b/packages/ddp-client-async/test/livedata_tests.js @@ -0,0 +1,1103 @@ +import { DDP } from '../common/namespace.js'; +import { Connection } from '../common/livedata_connection.js'; + +// XXX should check error codes +const failure = function(test, code, reason) { + return function(error, result) { + test.equal(result, undefined); + test.isTrue(error && typeof error === 'object'); + if (error && typeof error === 'object') { + if (typeof code === 'number') { + test.instanceOf(error, Meteor.Error); + code && test.equal(error.error, code); + reason && test.equal(error.reason, reason); + // XXX should check that other keys aren't present.. should + // probably use something like the Matcher we used to have + } else { + // for normal Javascript errors + test.instanceOf(error, Error); + test.equal(error.message, code); + } + } + }; +}; + +const failureOnStopped = function(test, code, reason) { + const f = failure(test, code, reason); + + return function(error) { + if (error) { + f(error); + } + }; +}; + +Tinytest.add('livedata - Meteor.Error', function(test) { + const error = new Meteor.Error(123, 'kittens', 'puppies'); + test.instanceOf(error, Meteor.Error); + test.instanceOf(error, Error); + test.equal(error.error, 123); + test.equal(error.reason, 'kittens'); + test.equal(error.details, 'puppies'); +}); + +if (Meteor.isServer) { + Tinytest.add('livedata - version negotiation', function(test) { + const versionCheck = function(clientVersions, serverVersions, expected) { + test.equal( + DDPServer._calculateVersion(clientVersions, serverVersions), + expected + ); + }; + + versionCheck(['A', 'B', 'C'], ['A', 'B', 'C'], 'A'); + versionCheck(['B', 'C'], ['A', 'B', 'C'], 'B'); + versionCheck(['A', 'B', 'C'], ['B', 'C'], 'B'); + versionCheck(['foo', 'bar', 'baz'], ['A', 'B', 'C'], 'A'); + }); +} + +Tinytest.add('livedata - methods with colliding names', function(test) { + const x = Random.id(); + const m = {}; + m[x] = function() {}; + Meteor.methods(m); + + test.throws(function() { + Meteor.methods(m); + }); +}); + +Tinytest.add('livedata - non-function method', function(test) { + const x = Random.id(); + const m = {}; + m[x] = 'kitten'; + + test.throws(function() { + Meteor.methods(m); + }); +}); + +const echoTest = function(item) { + return function(test, expect) { + if (Meteor.isServer) { + test.equal(Meteor.call('echo', item), [item]); + test.equal(Meteor.call('echoOne', item), item); + } + if (Meteor.isClient) test.equal(Meteor.call('echo', item), undefined); + + test.equal(Meteor.call('echo', item, expect(undefined, [item])), undefined); + test.equal( + Meteor.call('echoOne', item, expect(undefined, item)), + undefined + ); + }; +}; + +testAsyncMulti('livedata - basic method invocation', [ + // Unknown methods + function(test, expect) { + if (Meteor.isServer) { + // On server, with no callback, throws exception + let ret; + let threw; + try { + ret = Meteor.call('unknown method'); + } catch (e) { + test.equal(e.error, 404); + threw = true; + } + test.isTrue(threw); + test.equal(ret, undefined); + } + + if (Meteor.isClient) { + // On client, with no callback, just returns undefined + const ret = Meteor.call('unknown method'); + test.equal(ret, undefined); + } + + // On either, with a callback, calls the callback and does not throw + const ret = Meteor.call( + 'unknown method', + expect(failure(test, 404, "Method 'unknown method' not found")) + ); + test.equal(ret, undefined); + }, + + function(test, expect) { + // make sure 'undefined' is preserved as such, instead of turning + // into null (JSON does not have 'undefined' so there is special + // code for this) + if (Meteor.isServer) test.equal(Meteor.call('nothing'), undefined); + if (Meteor.isClient) test.equal(Meteor.call('nothing'), undefined); + + test.equal(Meteor.call('nothing', expect(undefined, undefined)), undefined); + }, + + function(test, expect) { + if (Meteor.isServer) test.equal(Meteor.call('echo'), []); + if (Meteor.isClient) test.equal(Meteor.call('echo'), undefined); + + test.equal(Meteor.call('echo', expect(undefined, [])), undefined); + }, + + echoTest(new Date()), + echoTest({ d: new Date(), s: 'foobarbaz' }), + echoTest([new Date(), 'foobarbaz']), + echoTest(new Mongo.ObjectID()), + echoTest({ o: new Mongo.ObjectID() }), + echoTest({ $date: 30 }), // literal + echoTest({ $literal: { $date: 30 } }), + echoTest(12), + echoTest(Infinity), + echoTest(-Infinity), + + function(test, expect) { + if (Meteor.isServer) + test.equal(Meteor.call('echo', 12, { x: 13 }), [12, { x: 13 }]); + if (Meteor.isClient) + test.equal(Meteor.call('echo', 12, { x: 13 }), undefined); + + test.equal( + Meteor.call('echo', 12, { x: 13 }, expect(undefined, [12, { x: 13 }])), + undefined + ); + }, + + // test that `wait: false` is respected + function(test, expect) { + if (Meteor.isClient) { + // For test isolation + const token = Random.id(); + Meteor.apply( + 'delayedTrue', + [token], + { wait: false }, + expect(function(err, res) { + test.equal(res, false); + }) + ); + Meteor.apply('makeDelayedTrueImmediatelyReturnFalse', [token]); + } + }, + + // test that `wait: true` is respected + function(test, expect) { + if (Meteor.isClient) { + const token = Random.id(); + Meteor.apply( + 'delayedTrue', + [token], + { wait: true }, + expect(function(err, res) { + test.equal(res, true); + }) + ); + Meteor.apply('makeDelayedTrueImmediatelyReturnFalse', [token]); + } + }, + + function(test, expect) { + // No callback + + if (Meteor.isServer) { + test.throws(function() { + Meteor.call('exception', 'both'); + }); + test.throws(function() { + Meteor.call('exception', 'server'); + }); + // No exception, because no code will run on the client + test.equal(Meteor.call('exception', 'client'), undefined); + } + + if (Meteor.isClient) { + // The client exception is thrown away because it's in the + // stub. The server exception is throw away because we didn't + // give a callback. + test.equal(Meteor.call('exception', 'both'), undefined); + test.equal(Meteor.call('exception', 'server'), undefined); + test.equal(Meteor.call('exception', 'client'), undefined); + + // If we pass throwStubExceptions then we *should* see thrown exceptions + // on the client + test.throws(function() { + Meteor.apply('exception', ['both'], { throwStubExceptions: true }); + }); + test.equal( + Meteor.apply('exception', ['server'], { throwStubExceptions: true }), + undefined + ); + test.throws(function() { + Meteor.apply('exception', ['client'], { throwStubExceptions: true }); + }); + } + + // With callback + + if (Meteor.isClient) { + test.equal( + Meteor.call( + 'exception', + 'both', + expect(failure(test, 500, 'Internal server error')) + ), + undefined + ); + test.equal( + Meteor.call( + 'exception', + 'server', + expect(failure(test, 500, 'Internal server error')) + ), + undefined + ); + test.equal(Meteor.call('exception', 'client'), undefined); + } + + if (Meteor.isServer) { + test.equal( + Meteor.call( + 'exception', + 'both', + expect(failure(test, 'Test method throwing an exception')) + ), + undefined + ); + test.equal( + Meteor.call( + 'exception', + 'server', + expect(failure(test, 'Test method throwing an exception')) + ), + undefined + ); + test.equal(Meteor.call('exception', 'client'), undefined); + } + }, + + function(test, expect) { + if (Meteor.isServer) { + let threw = false; + try { + Meteor.call('exception', 'both', { intended: true }); + } catch (e) { + threw = true; + test.equal(e.error, 999); + test.equal(e.reason, 'Client-visible test exception'); + } + test.isTrue(threw); + threw = false; + try { + Meteor.call('exception', 'both', { + intended: true, + throwThroughFuture: true + }); + } catch (e) { + threw = true; + test.equal(e.error, 999); + test.equal(e.reason, 'Client-visible test exception'); + } + test.isTrue(threw); + } + + if (Meteor.isClient) { + test.equal( + Meteor.call( + 'exception', + 'both', + { intended: true }, + expect(failure(test, 999, 'Client-visible test exception')) + ), + undefined + ); + test.equal( + Meteor.call( + 'exception', + 'server', + { intended: true }, + expect(failure(test, 999, 'Client-visible test exception')) + ), + undefined + ); + test.equal( + Meteor.call( + 'exception', + 'server', + { + intended: true, + throwThroughFuture: true + }, + expect(failure(test, 999, 'Client-visible test exception')) + ), + undefined + ); + } + } +]); + +const checkBalances = function(test, a, b) { + const alice = Ledger.findOne({ name: 'alice', world: test.runId() }); + const bob = Ledger.findOne({ name: 'bob', world: test.runId() }); + test.equal(alice.balance, a); + test.equal(bob.balance, b); +}; + +// would be nice to have a database-aware test harness of some kind -- +// this is a big hack (and XXX pollutes the global test namespace) +testAsyncMulti('livedata - compound methods', [ + function(test, expect) { + if (Meteor.isClient) Meteor.subscribe('ledger', test.runId(), expect()); + + Ledger.insert( + { name: 'alice', balance: 100, world: test.runId() }, + expect(function() {}) + ); + Ledger.insert( + { name: 'bob', balance: 50, world: test.runId() }, + expect(function() {}) + ); + }, + function(test, expect) { + Meteor.call( + 'ledger/transfer', + test.runId(), + 'alice', + 'bob', + 10, + expect(function(err, result) { + test.equal(err, undefined); + test.equal(result, undefined); + checkBalances(test, 90, 60); + }) + ); + checkBalances(test, 90, 60); + }, + function(test, expect) { + Meteor.call( + 'ledger/transfer', + test.runId(), + 'alice', + 'bob', + 100, + true, + expect(function(err, result) { + failure(test, 409)(err, result); + // Balances are reverted back to pre-stub values. + checkBalances(test, 90, 60); + }) + ); + + if (Meteor.isClient) + // client can fool itself by cheating, but only until the sync + // finishes + checkBalances(test, -10, 160); + else checkBalances(test, 90, 60); + } +]); + +// Replaces the Connection's `_livedata_data` method to push incoming +// messages on a given collection to an array. This can be used to +// verify that the right data is sent on the wire +// +// @param messages {Array} The array to which to append the messages +// @return {Function} A function to call to undo the eavesdropping +const eavesdropOnCollection = function( + livedata_connection, + collection_name, + messages +) { + const old_livedata_data = _.bind( + livedata_connection._livedata_data, + livedata_connection + ); + + // Kind of gross since all tests past this one will run with this + // hook set up. That's probably fine since we only check a specific + // collection but still... + // + // Should we consider having a separate connection per Tinytest or + // some similar scheme? + livedata_connection._livedata_data = function(msg) { + if (msg.collection && msg.collection === collection_name) { + messages.push(msg); + } + old_livedata_data(msg); + }; + + return function() { + livedata_connection._livedata_data = old_livedata_data; + }; +}; + +if (Meteor.isClient) { + testAsyncMulti( + 'livedata - changing userid reruns subscriptions without flapping data on the wire', + [ + function(test, expect) { + const messages = []; + const undoEavesdrop = eavesdropOnCollection( + Meteor.connection, + 'objectsWithUsers', + messages + ); + + // A helper for testing incoming set and unset messages + // XXX should this be extracted as a general helper together with + // eavesdropOnCollection? + const expectMessages = function( + expectedAddedMessageCount, + expectedRemovedMessageCount, + expectedNamesInCollection + ) { + let actualAddedMessageCount = 0; + let actualRemovedMessageCount = 0; + _.each(messages, function(msg) { + if (msg.msg === 'added') ++actualAddedMessageCount; + else if (msg.msg === 'removed') ++actualRemovedMessageCount; + else test.fail({ unexpected: JSON.stringify(msg) }); + }); + test.equal(actualAddedMessageCount, expectedAddedMessageCount); + test.equal(actualRemovedMessageCount, expectedRemovedMessageCount); + expectedNamesInCollection.sort(); + test.equal( + _.pluck( + objectsWithUsers.find({}, { sort: ['name'] }).fetch(), + 'name' + ), + expectedNamesInCollection + ); + messages.length = 0; // clear messages without creating a new object + }; + + // make sure we're not already logged in. can happen if accounts + // tests fail oddly. + Meteor.apply( + 'setUserId', + [null], + { wait: true }, + expect(function() {}) + ); + + let afterFirstSetUserId; + let afterSecondSetUserId; + let afterThirdSetUserId; + + Meteor.subscribe( + 'objectsWithUsers', + expect(function() { + expectMessages(1, 0, ['owned by none']); + Meteor.apply( + 'setUserId', + ['1'], + { wait: true }, + afterFirstSetUserId + ); + }) + ); + + afterFirstSetUserId = expect(function() { + expectMessages(3, 1, [ + 'owned by one - a', + 'owned by one/two - a', + 'owned by one/two - b' + ]); + Meteor.apply( + 'setUserId', + ['2'], + { wait: true }, + afterSecondSetUserId + ); + }); + + afterSecondSetUserId = expect(function() { + expectMessages(2, 1, [ + 'owned by one/two - a', + 'owned by one/two - b', + 'owned by two - a', + 'owned by two - b' + ]); + Meteor.apply('setUserId', ['2'], { wait: true }, afterThirdSetUserId); + }); + + afterThirdSetUserId = expect(function() { + // Nothing should have been sent since the results of the + // query are the same ("don't flap data on the wire") + expectMessages(0, 0, [ + 'owned by one/two - a', + 'owned by one/two - b', + 'owned by two - a', + 'owned by two - b' + ]); + undoEavesdrop(); + }); + }, + function(test, expect) { + const key = Random.id(); + Meteor.subscribe('recordUserIdOnStop', key); + Meteor.apply( + 'setUserId', + ['100'], + { wait: true }, + expect(function() {}) + ); + Meteor.apply( + 'setUserId', + ['101'], + { wait: true }, + expect(function() {}) + ); + Meteor.call( + 'userIdWhenStopped', + key, + expect(function(err, result) { + test.isFalse(err); + test.equal(result, '100'); + }) + ); + // clean up + Meteor.apply( + 'setUserId', + [null], + { wait: true }, + expect(function() {}) + ); + } + ] + ); +} + +Tinytest.add('livedata - setUserId error when called from server', function( + test +) { + if (Meteor.isServer) { + test.equal( + errorThrownWhenCallingSetUserIdDirectlyOnServer.message, + "Can't call setUserId on a server initiated method call" + ); + } +}); + +let pubHandles; +if (Meteor.isServer) { + pubHandles = {}; +} + +Meteor.methods({ + 'livedata/setup': function(id) { + check(id, String); + if (Meteor.isServer) { + pubHandles[id] = {}; + Meteor.publish('pub1' + id, function() { + pubHandles[id].pub1 = this; + this.ready(); + }); + Meteor.publish('pub2' + id, function() { + pubHandles[id].pub2 = this; + this.ready(); + }); + } + }, + 'livedata/pub1go': function(id) { + check(id, String); + if (Meteor.isServer) { + pubHandles[id].pub1.added('MultiPubCollection' + id, 'foo', { a: 'aa' }); + return 1; + } + return 0; + }, + 'livedata/pub2go': function(id) { + check(id, String); + if (Meteor.isServer) { + pubHandles[id].pub2.added('MultiPubCollection' + id, 'foo', { b: 'bb' }); + return 2; + } + return 0; + } +}); + +if (Meteor.isClient) { + (function() { + let MultiPub; + const id = Random.id(); + testAsyncMulti('livedata - added from two different subs', [ + function(test, expect) { + Meteor.call('livedata/setup', id, expect(function() {})); + }, + function(test, expect) { + MultiPub = new Mongo.Collection('MultiPubCollection' + id); + const sub1 = Meteor.subscribe('pub1' + id, expect(function() {})); + const sub2 = Meteor.subscribe('pub2' + id, expect(function() {})); + }, + function(test, expect) { + Meteor.call( + 'livedata/pub1go', + id, + expect(function(err, res) { + test.equal(res, 1); + }) + ); + }, + function(test, expect) { + test.equal(MultiPub.findOne('foo'), { _id: 'foo', a: 'aa' }); + }, + function(test, expect) { + Meteor.call( + 'livedata/pub2go', + id, + expect(function(err, res) { + test.equal(res, 2); + }) + ); + }, + function(test, expect) { + test.equal(MultiPub.findOne('foo'), { _id: 'foo', a: 'aa', b: 'bb' }); + } + ]); + })(); +} + +if (Meteor.isClient) { + testAsyncMulti('livedata - overlapping universal subs', [ + function(test, expect) { + const coll = new Mongo.Collection('overlappingUniversalSubs'); + const token = Random.id(); + test.isFalse(coll.findOne(token)); + Meteor.call( + 'testOverlappingSubs', + token, + expect(function(err) { + test.isFalse(err); + test.isTrue(coll.findOne(token)); + }) + ); + } + ]); + + testAsyncMulti('livedata - runtime universal sub creation', [ + function(test, expect) { + const coll = new Mongo.Collection('runtimeSubCreation'); + const token = Random.id(); + test.isFalse(coll.findOne(token)); + Meteor.call( + 'runtimeUniversalSubCreation', + token, + expect(function(err) { + test.isFalse(err); + test.isTrue(coll.findOne(token)); + }) + ); + } + ]); + + testAsyncMulti('livedata - no setUserId after unblock', [ + function(test, expect) { + Meteor.call( + 'setUserIdAfterUnblock', + expect(function(err, result) { + test.isFalse(err); + test.isTrue(result); + }) + ); + } + ]); + + testAsyncMulti( + 'livedata - publisher errors with onError callback', + (function() { + let conn, collName, coll; + let errorFromRerun; + let gotErrorFromStopper = false; + return [ + function(test, expect) { + // Use a separate connection so that we can safely check to see if + // conn._subscriptions is empty. + conn = new Connection('/', { + reloadWithOutstanding: true + }); + collName = Random.id(); + coll = new Mongo.Collection(collName, { connection: conn }); + + const testSubError = function(options) { + conn.subscribe('publisherErrors', collName, options, { + onReady: expect(), + onError: expect( + failure( + test, + options.internalError ? 500 : 412, + options.internalError + ? 'Internal server error' + : 'Explicit error' + ) + ) + }); + }; + testSubError({ throwInHandler: true }); + testSubError({ throwInHandler: true, internalError: true }); + testSubError({ errorInHandler: true }); + testSubError({ errorInHandler: true, internalError: true }); + testSubError({ errorLater: true }); + testSubError({ errorLater: true, internalError: true }); + }, + function(test, expect) { + test.equal(coll.find().count(), 0); + test.equal(_.size(conn._subscriptions), 0); // white-box test + + conn.subscribe( + 'publisherErrors', + collName, + { throwWhenUserIdSet: true }, + { + onReady: expect(), + onError: function(error) { + errorFromRerun = error; + } + } + ); + }, + function(test, expect) { + // Because the last subscription is ready, we should have a document. + test.equal(coll.find().count(), 1); + test.isFalse(errorFromRerun); + test.equal(_.size(conn._subscriptions), 1); // white-box test + conn.call('setUserId', 'bla', expect(function() {})); + }, + function(test, expect) { + // Now that we've re-run, we should have stopped the subscription, + // gotten a error, and lost the document. + test.equal(coll.find().count(), 0); + test.isTrue(errorFromRerun); + test.instanceOf(errorFromRerun, Meteor.Error); + test.equal(errorFromRerun.error, 412); + test.equal(errorFromRerun.reason, 'Explicit error'); + test.equal(_.size(conn._subscriptions), 0); // white-box test + + conn.subscribe( + 'publisherErrors', + collName, + { stopInHandler: true }, + { + onError: function() { + gotErrorFromStopper = true; + } + } + ); + // Call a method. This method won't be processed until the publisher's + // function returns, so blocking on it being done ensures that we've + // gotten the removed/nosub/etc. + conn.call('nothing', expect(function() {})); + }, + function(test, expect) { + test.equal(coll.find().count(), 0); + // sub.stop does NOT call onError. + test.isFalse(gotErrorFromStopper); + test.equal(_.size(conn._subscriptions), 0); // white-box test + conn._stream.disconnect({ _permanent: true }); + } + ]; + })() + ); + + testAsyncMulti( + 'livedata - publisher errors with onStop callback', + (function() { + let conn, collName, coll; + let errorFromRerun; + let gotErrorFromStopper = false; + return [ + function(test, expect) { + // Use a separate connection so that we can safely check to see if + // conn._subscriptions is empty. + conn = new Connection('/', { + reloadWithOutstanding: true + }); + collName = Random.id(); + coll = new Mongo.Collection(collName, { connection: conn }); + + const testSubError = function(options) { + conn.subscribe('publisherErrors', collName, options, { + onReady: expect(), + onStop: expect( + failureOnStopped( + test, + options.internalError ? 500 : 412, + options.internalError + ? 'Internal server error' + : 'Explicit error' + ) + ) + }); + }; + testSubError({ throwInHandler: true }); + testSubError({ throwInHandler: true, internalError: true }); + testSubError({ errorInHandler: true }); + testSubError({ errorInHandler: true, internalError: true }); + testSubError({ errorLater: true }); + testSubError({ errorLater: true, internalError: true }); + }, + function(test, expect) { + test.equal(coll.find().count(), 0); + test.equal(_.size(conn._subscriptions), 0); // white-box test + + conn.subscribe( + 'publisherErrors', + collName, + { throwWhenUserIdSet: true }, + { + onReady: expect(), + onStop: function(error) { + errorFromRerun = error; + } + } + ); + }, + function(test, expect) { + // Because the last subscription is ready, we should have a document. + test.equal(coll.find().count(), 1); + test.isFalse(errorFromRerun); + test.equal(_.size(conn._subscriptions), 1); // white-box test + conn.call('setUserId', 'bla', expect(function() {})); + }, + function(test, expect) { + // Now that we've re-run, we should have stopped the subscription, + // gotten a error, and lost the document. + test.equal(coll.find().count(), 0); + test.isTrue(errorFromRerun); + test.instanceOf(errorFromRerun, Meteor.Error); + test.equal(errorFromRerun.error, 412); + test.equal(errorFromRerun.reason, 'Explicit error'); + test.equal(_.size(conn._subscriptions), 0); // white-box test + + conn.subscribe( + 'publisherErrors', + collName, + { stopInHandler: true }, + { + onStop: function(error) { + if (error) { + gotErrorFromStopper = true; + } + } + } + ); + // Call a method. This method won't be processed until the publisher's + // function returns, so blocking on it being done ensures that we've + // gotten the removed/nosub/etc. + conn.call('nothing', expect(function() {})); + }, + function(test, expect) { + test.equal(coll.find().count(), 0); + // sub.stop does NOT call onError. + test.isFalse(gotErrorFromStopper); + test.equal(_.size(conn._subscriptions), 0); // white-box test + conn._stream.disconnect({ _permanent: true }); + } + ]; + })() + ); + + testAsyncMulti('livedata - publish multiple cursors', [ + function(test, expect) { + const sub = Meteor.subscribe( + 'multiPublish', + { normal: 1 }, + { + onReady: expect(function() { + test.isTrue(sub.ready()); + test.equal(One.find().count(), 2); + test.equal(Two.find().count(), 3); + }), + onError: failure() + } + ); + }, + function(test, expect) { + Meteor.subscribe( + 'multiPublish', + { dup: 1 }, + { + onReady: failure(), + onError: expect(failure(test, 500, 'Internal server error')) + } + ); + }, + function(test, expect) { + Meteor.subscribe( + 'multiPublish', + { notCursor: 1 }, + { + onReady: failure(), + onError: expect(failure(test, 500, 'Internal server error')) + } + ); + } + ]); +} + +const selfUrl = Meteor.isServer + ? Meteor.absoluteUrl() + : Meteor._relativeToSiteRootUrl('/'); + +if (Meteor.isServer) { + Meteor.methods({ + s2s: function(arg) { + check(arg, String); + return 's2s ' + arg; + } + }); +} +(function() { + testAsyncMulti('livedata - connect works from both client and server', [ + function(test, expect) { + const self = this; + self.conn = DDP.connect(selfUrl); + pollUntil( + expect, + function() { + return self.conn.status().connected; + }, + 10000 + ); + }, + + function(test, expect) { + const self = this; + if (self.conn.status().connected) { + self.conn.call( + 's2s', + 'foo', + expect(function(err, res) { + if (err) throw err; + test.equal(res, 's2s foo'); + }) + ); + } + } + ]); +})(); + +if (Meteor.isServer) { + (function() { + testAsyncMulti('livedata - method call on server blocks in a fiber way', [ + function(test, expect) { + const self = this; + self.conn = DDP.connect(selfUrl); + pollUntil( + expect, + function() { + return self.conn.status().connected; + }, + 10000 + ); + }, + + function(test, expect) { + const self = this; + if (self.conn.status().connected) { + test.equal(self.conn.call('s2s', 'foo'), 's2s foo'); + } + } + ]); + })(); +} + +(function() { + testAsyncMulti('livedata - connect fails to unknown place', [ + function(test, expect) { + const self = this; + self.conn = DDP.connect('example.com', { _dontPrintErrors: true }); + Meteor.setTimeout( + expect(function() { + test.isFalse(self.conn.status().connected, 'Not connected'); + self.conn.close(); + }), + 500 + ); + } + ]); +})(); + +if (Meteor.isServer) { + Meteor.publish('publisherCloning', function() { + const self = this; + const fields = { x: { y: 42 } }; + self.added('publisherCloning', 'a', fields); + fields.x.y = 43; + self.changed('publisherCloning', 'a', fields); + self.ready(); + }); +} else { + const PublisherCloningCollection = new Mongo.Collection('publisherCloning'); + testAsyncMulti('livedata - publish callbacks clone', [ + function(test, expect) { + Meteor.subscribe( + 'publisherCloning', + { normal: 1 }, + { + onReady: expect(function() { + test.equal(PublisherCloningCollection.findOne(), { + _id: 'a', + x: { y: 43 } + }); + }), + onError: failure() + } + ); + } + ]); +} + +testAsyncMulti('livedata - result by value', [ + function(test, expect) { + const self = this; + self.testId = Random.id(); + Meteor.call( + 'getArray', + self.testId, + expect(function(error, firstResult) { + test.isFalse(error); + test.isTrue(firstResult); + self.firstResult = firstResult; + }) + ); + }, + function(test, expect) { + const self = this; + Meteor.call( + 'pushToArray', + self.testId, + 'xxx', + expect(function(error) { + test.isFalse(error); + }) + ); + }, + function(test, expect) { + const self = this; + Meteor.call( + 'getArray', + self.testId, + expect(function(error, secondResult) { + test.isFalse(error); + test.equal(self.firstResult.length + 1, secondResult.length); + }) + ); + } +]); + +// XXX some things to test in greater detail: +// staying in simulation mode +// time warp +// serialization / beginAsync(true) / beginAsync(false) +// malformed messages (need raw wire access) +// method completion/satisfaction +// subscriptions (multiple APIs, including autorun?) +// subscription completion +// subscription attribute shadowing +// server method calling methods on other server (eg, should simulate) +// subscriptions and methods being idempotent +// reconnection +// reconnection not resulting in method re-execution +// reconnection tolerating all kinds of lost messages (including data) +// [probably lots more] diff --git a/packages/ddp-client-async/test/random_stream_tests.js b/packages/ddp-client-async/test/random_stream_tests.js new file mode 100644 index 0000000000..03b1ce05ec --- /dev/null +++ b/packages/ddp-client-async/test/random_stream_tests.js @@ -0,0 +1,44 @@ +Tinytest.add('livedata - DDP.randomStream', function(test) { + const randomSeed = Random.id(); + const context = { randomSeed: randomSeed }; + + let sequence = DDP._CurrentMethodInvocation.withValue(context, function() { + return DDP.randomStream('1'); + }); + + let seeds = sequence.alea.args; + + test.equal(seeds.length, 2); + test.equal(seeds[0], randomSeed); + test.equal(seeds[1], '1'); + + const id1 = sequence.id(); + + // Clone the sequence by building it the same way RandomStream.get does + const sequenceClone = Random.createWithSeeds.apply(null, seeds); + const id1Cloned = sequenceClone.id(); + const id2Cloned = sequenceClone.id(); + test.equal(id1, id1Cloned); + + // We should get the same sequence when we use the same key + sequence = DDP._CurrentMethodInvocation.withValue(context, function() { + return DDP.randomStream('1'); + }); + seeds = sequence.alea.args; + test.equal(seeds.length, 2); + test.equal(seeds[0], randomSeed); + test.equal(seeds[1], '1'); + + // But we should be at the 'next' position in the stream + const id2 = sequence.id(); + + // Technically these could be equal, but likely to be a bug if hit + // http://search.dilbert.com/comic/Random%20Number%20Generator + test.notEqual(id1, id2); + + test.equal(id2, id2Cloned); +}); + +Tinytest.add('livedata - DDP.randomStream with no-args', function(test) { + DDP.randomStream().id(); +}); diff --git a/packages/ddp-client-async/test/stub_stream.js b/packages/ddp-client-async/test/stub_stream.js new file mode 100644 index 0000000000..1b0186a348 --- /dev/null +++ b/packages/ddp-client-async/test/stub_stream.js @@ -0,0 +1,57 @@ +StubStream = function() { + const self = this; + + self.sent = []; + self.callbacks = Object.create(null); +}; + +_.extend(StubStream.prototype, { + // Methods from Stream + on: function(name, callback) { + const self = this; + + if (!self.callbacks[name]) self.callbacks[name] = [callback]; + else self.callbacks[name].push(callback); + }, + + send: function(data) { + const self = this; + self.sent.push(data); + }, + + status: function() { + return { status: 'connected', fake: true }; + }, + + reconnect: function() { + // no-op + }, + + _lostConnection: function() { + // no-op + }, + + // Methods for tests + receive: function(data) { + const self = this; + + if (typeof data === 'object') { + data = EJSON.stringify(data); + } + + _.each(self.callbacks['message'], function(cb) { + cb(data); + }); + }, + + reset: function() { + const self = this; + _.each(self.callbacks['reset'], function(cb) { + cb(); + }); + }, + + // Provide a tag to detect stub streams. + // We don't log heartbeat failures on stub streams, for example. + _isStub: true +}); diff --git a/packages/ddp-client/package.js b/packages/ddp-client/package.js index b60d545947..64cbf8d09e 100644 --- a/packages/ddp-client/package.js +++ b/packages/ddp-client/package.js @@ -9,6 +9,11 @@ Npm.depends({ }); Package.onUse((api) => { + if (process.env.DISABLE_FIBERS) { + api.use('ddp-client-async'); + api.export('DDP', 'server'); + return; + } api.use([ 'check', 'random', @@ -40,6 +45,7 @@ Package.onUse((api) => { Package.onTest((api) => { api.use([ 'livedata', + 'mongo', 'test-helpers', 'ecmascript', 'underscore', @@ -53,11 +59,6 @@ Package.onTest((api) => { 'ddp-common', 'check' ]); - if (!process.env.DISABLE_FIBERS) { - api.use('mongo', ['client', 'server']); - } else { - api.use('mongo-async', ['client', 'server']); - } api.addFiles('test/stub_stream.js'); api.addFiles('test/livedata_connection_tests.js'); diff --git a/packages/ddp-server-async/.npm/package/.gitignore b/packages/ddp-server-async/.npm/package/.gitignore new file mode 100644 index 0000000000..3c3629e647 --- /dev/null +++ b/packages/ddp-server-async/.npm/package/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/packages/ddp-server-async/.npm/package/README b/packages/ddp-server-async/.npm/package/README new file mode 100644 index 0000000000..3d492553a4 --- /dev/null +++ b/packages/ddp-server-async/.npm/package/README @@ -0,0 +1,7 @@ +This directory and the files immediately inside it are automatically generated +when you change this package's NPM dependencies. Commit the files in this +directory (npm-shrinkwrap.json, .gitignore, and this README) to source control +so that others run the same versions of sub-dependencies. + +You should NOT check in the node_modules directory that Meteor automatically +creates; if you are using git, the .gitignore file tells git to ignore it. diff --git a/packages/ddp-server-async/.npm/package/npm-shrinkwrap.json b/packages/ddp-server-async/.npm/package/npm-shrinkwrap.json new file mode 100644 index 0000000000..b9b024674c --- /dev/null +++ b/packages/ddp-server-async/.npm/package/npm-shrinkwrap.json @@ -0,0 +1,45 @@ +{ + "lockfileVersion": 1, + "dependencies": { + "faye-websocket": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.3.tgz", + "integrity": "sha512-D2y4bovYpzziGgbHYtGCMjlJM36vAl/y+xUyn1C+FVx8szd1E+86KwVw6XvYSzOP8iMpm1X0I4xJD+QtUb36OA==" + }, + "http-parser-js": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.3.tgz", + "integrity": "sha512-t7hjvef/5HEK7RWTdUzVUhl8zkEu+LlaE0IYzdMuvbSDipxBRpOn4Uhw8ZyECEa808iVT8XCjzo6xmYt4CiLZg==" + }, + "permessage-deflate": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/permessage-deflate/-/permessage-deflate-0.1.7.tgz", + "integrity": "sha512-EUNi/RIsyJ1P1u9QHFwMOUWMYetqlE22ZgGbad7YP856WF4BFF0B7DuNy6vEGsgNNud6c/SkdWzkne71hH8MjA==" + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "sockjs": { + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.21.tgz", + "integrity": "sha512-DhbPFGpxjc6Z3I+uX07Id5ZO2XwYsWOrYjaSeieES78cq+JaJvVe5q/m1uvjIQhXinhIeCFRH6JgXe+mvVMyXw==" + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + }, + "websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==" + }, + "websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==" + } + } +} diff --git a/packages/ddp-server-async/README.md b/packages/ddp-server-async/README.md new file mode 100644 index 0000000000..21d2ac4c84 --- /dev/null +++ b/packages/ddp-server-async/README.md @@ -0,0 +1,4 @@ +# ddp-server +[Source code of released version](https://github.com/meteor/meteor/tree/master/packages/ddp-server) | [Source code of development version](https://github.com/meteor/meteor/tree/devel/packages/ddp-server) +*** + diff --git a/packages/ddp-server-async/crossbar.js b/packages/ddp-server-async/crossbar.js new file mode 100644 index 0000000000..6672059f99 --- /dev/null +++ b/packages/ddp-server-async/crossbar.js @@ -0,0 +1,167 @@ +// A "crossbar" is a class that provides structured notification registration. +// See _match for the definition of how a notification matches a trigger. +// All notifications and triggers must have a string key named 'collection'. + +DDPServer._Crossbar = function (options) { + var self = this; + options = options || {}; + + self.nextId = 1; + // map from collection name (string) -> listener id -> object. each object has + // keys 'trigger', 'callback'. As a hack, the empty string means "no + // collection". + self.listenersByCollection = {}; + self.listenersByCollectionCount = {}; + self.factPackage = options.factPackage || "livedata"; + self.factName = options.factName || null; +}; + +_.extend(DDPServer._Crossbar.prototype, { + // msg is a trigger or a notification + _collectionForMessage: function (msg) { + var self = this; + if (! _.has(msg, 'collection')) { + return ''; + } else if (typeof(msg.collection) === 'string') { + if (msg.collection === '') + throw Error("Message has empty collection!"); + return msg.collection; + } else { + throw Error("Message has non-string collection!"); + } + }, + + // Listen for notification that match 'trigger'. A notification + // matches if it has the key-value pairs in trigger as a + // subset. When a notification matches, call 'callback', passing + // the actual notification. + // + // Returns a listen handle, which is an object with a method + // stop(). Call stop() to stop listening. + // + // XXX It should be legal to call fire() from inside a listen() + // callback? + listen: function (trigger, callback) { + var self = this; + var id = self.nextId++; + + var collection = self._collectionForMessage(trigger); + var record = {trigger: EJSON.clone(trigger), callback: callback}; + if (! _.has(self.listenersByCollection, collection)) { + self.listenersByCollection[collection] = {}; + self.listenersByCollectionCount[collection] = 0; + } + self.listenersByCollection[collection][id] = record; + self.listenersByCollectionCount[collection]++; + + if (self.factName && Package['facts-base']) { + Package['facts-base'].Facts.incrementServerFact( + self.factPackage, self.factName, 1); + } + + return { + stop: function () { + if (self.factName && Package['facts-base']) { + Package['facts-base'].Facts.incrementServerFact( + self.factPackage, self.factName, -1); + } + delete self.listenersByCollection[collection][id]; + self.listenersByCollectionCount[collection]--; + if (self.listenersByCollectionCount[collection] === 0) { + delete self.listenersByCollection[collection]; + delete self.listenersByCollectionCount[collection]; + } + } + }; + }, + + // Fire the provided 'notification' (an object whose attribute + // values are all JSON-compatibile) -- inform all matching listeners + // (registered with listen()). + // + // If fire() is called inside a write fence, then each of the + // listener callbacks will be called inside the write fence as well. + // + // The listeners may be invoked in parallel, rather than serially. + fire: function (notification) { + var self = this; + + var collection = self._collectionForMessage(notification); + + if (! _.has(self.listenersByCollection, collection)) { + return; + } + + var listenersForCollection = self.listenersByCollection[collection]; + var callbackIds = []; + _.each(listenersForCollection, function (l, id) { + if (self._matches(notification, l.trigger)) { + callbackIds.push(id); + } + }); + + // Listener callbacks can yield, so we need to first find all the ones that + // match in a single iteration over self.listenersByCollection (which can't + // be mutated during this iteration), and then invoke the matching + // callbacks, checking before each call to ensure they haven't stopped. + // Note that we don't have to check that + // self.listenersByCollection[collection] still === listenersForCollection, + // because the only way that stops being true is if listenersForCollection + // first gets reduced down to the empty object (and then never gets + // increased again). + _.each(callbackIds, function (id) { + if (_.has(listenersForCollection, id)) { + listenersForCollection[id].callback(notification); + } + }); + }, + + // A notification matches a trigger if all keys that exist in both are equal. + // + // Examples: + // N:{collection: "C"} matches T:{collection: "C"} + // (a non-targeted write to a collection matches a + // non-targeted query) + // N:{collection: "C", id: "X"} matches T:{collection: "C"} + // (a targeted write to a collection matches a non-targeted query) + // N:{collection: "C"} matches T:{collection: "C", id: "X"} + // (a non-targeted write to a collection matches a + // targeted query) + // N:{collection: "C", id: "X"} matches T:{collection: "C", id: "X"} + // (a targeted write to a collection matches a targeted query targeted + // at the same document) + // N:{collection: "C", id: "X"} does not match T:{collection: "C", id: "Y"} + // (a targeted write to a collection does not match a targeted query + // targeted at a different document) + _matches: function (notification, trigger) { + // Most notifications that use the crossbar have a string `collection` and + // maybe an `id` that is a string or ObjectID. We're already dividing up + // triggers by collection, but let's fast-track "nope, different ID" (and + // avoid the overly generic EJSON.equals). This makes a noticeable + // performance difference; see https://github.com/meteor/meteor/pull/3697 + if (typeof(notification.id) === 'string' && + typeof(trigger.id) === 'string' && + notification.id !== trigger.id) { + return false; + } + if (notification.id instanceof MongoID.ObjectID && + trigger.id instanceof MongoID.ObjectID && + ! notification.id.equals(trigger.id)) { + return false; + } + + return _.all(trigger, function (triggerValue, key) { + return !_.has(notification, key) || + EJSON.equals(triggerValue, notification[key]); + }); + } +}); + +// The "invalidation crossbar" is a specific instance used by the DDP server to +// implement write fence notifications. Listener callbacks on this crossbar +// should call beginWrite on the current write fence before they return, if they +// want to delay the write fence from firing (ie, the DDP method-data-updated +// message from being sent). +DDPServer._InvalidationCrossbar = new DDPServer._Crossbar({ + factName: "invalidation-crossbar-listeners" +}); diff --git a/packages/ddp-server-async/crossbar_tests.js b/packages/ddp-server-async/crossbar_tests.js new file mode 100644 index 0000000000..cf42351798 --- /dev/null +++ b/packages/ddp-server-async/crossbar_tests.js @@ -0,0 +1,49 @@ +// White box tests of invalidation crossbar matching function. +// Note: the current crossbar match function is designed specifically +// to ensure that a modification that targets a specific ID does not +// notify a query that is watching a different specific ID. (And to +// keep separate collections separate.) Other than that, there's no +// deep meaning to the matching function, and it could be changed later +// as long as it preserves that property. +Tinytest.add('livedata - crossbar', function (test) { + var crossbar = new DDPServer._Crossbar; + test.isTrue(crossbar._matches({collection: "C"}, + {collection: "C"})); + test.isTrue(crossbar._matches({collection: "C", id: "X"}, + {collection: "C"})); + test.isTrue(crossbar._matches({collection: "C"}, + {collection: "C", id: "X"})); + test.isTrue(crossbar._matches({collection: "C", id: "X"}, + {collection: "C"})); + + test.isFalse(crossbar._matches({collection: "C", id: "X"}, + {collection: "C", id: "Y"})); + + // Test that stopped listens definitely don't fire. + var calledFirst = false; + var calledSecond = false; + var trigger = {collection: "C"}; + var secondHandle; + crossbar.listen(trigger, function (notification) { + // This test assumes that listeners will be called in the order + // registered. It's not wrong for the crossbar to do something different, + // but the test won't be valid in that case, so make it fail so we notice. + calledFirst = true; + if (calledSecond) { + test.fail({ + type: "test_assumption_failed", + message: "test assumed that listeners would be called in the order registered" + }); + } else { + secondHandle.stop(); + } + }); + secondHandle = crossbar.listen(trigger, function (notification) { + // This should not get invoked, because it should be stopped by the other + // listener! + calledSecond = true; + }); + crossbar.fire(trigger); + test.isTrue(calledFirst); + test.isFalse(calledSecond); +}); diff --git a/packages/ddp-server-async/livedata_server.js b/packages/ddp-server-async/livedata_server.js new file mode 100644 index 0000000000..533e01b701 --- /dev/null +++ b/packages/ddp-server-async/livedata_server.js @@ -0,0 +1,1917 @@ +DDPServer = {}; + +// Publication strategies define how we handle data from published cursors at the collection level +// This allows someone to: +// - Choose a trade-off between client-server bandwidth and server memory usage +// - Implement special (non-mongo) collections like volatile message queues +const publicationStrategies = { + // SERVER_MERGE is the default strategy. + // When using this strategy, the server maintains a copy of all data a connection is subscribed to. + // This allows us to only send deltas over multiple publications. + SERVER_MERGE: { + useCollectionView: true, + doAccountingForCollection: true, + }, + // The NO_MERGE_NO_HISTORY strategy results in the server sending all publication data + // directly to the client. It does not remember what it has previously sent + // to it will not trigger removed messages when a subscription is stopped. + // This should only be chosen for special use cases like send-and-forget queues. + NO_MERGE_NO_HISTORY: { + useCollectionView: false, + doAccountingForCollection: false, + }, + // NO_MERGE is similar to NO_MERGE_NO_HISTORY but the server will remember the IDs it has + // sent to the client so it can remove them when a subscription is stopped. + // This strategy can be used when a collection is only used in a single publication. + NO_MERGE: { + useCollectionView: false, + doAccountingForCollection: true, + } +}; + +DDPServer.publicationStrategies = publicationStrategies; + +// This file contains classes: +// * Session - The server's connection to a single DDP client +// * Subscription - A single subscription for a single client +// * Server - An entire server that may talk to > 1 client. A DDP endpoint. +// +// Session and Subscription are file scope. For now, until we freeze +// the interface, Server is package scope (in the future it should be +// exported). + +// Represents a single document in a SessionCollectionView +var SessionDocumentView = function () { + var self = this; + self.existsIn = new Set(); // set of subscriptionHandle + self.dataByKey = new Map(); // key-> [ {subscriptionHandle, value} by precedence] +}; + +DDPServer._SessionDocumentView = SessionDocumentView; + + +_.extend(SessionDocumentView.prototype, { + + getFields: function () { + var self = this; + var ret = {}; + self.dataByKey.forEach(function (precedenceList, key) { + ret[key] = precedenceList[0].value; + }); + return ret; + }, + + clearField: function (subscriptionHandle, key, changeCollector) { + var self = this; + // Publish API ignores _id if present in fields + if (key === "_id") + return; + var precedenceList = self.dataByKey.get(key); + + // It's okay to clear fields that didn't exist. No need to throw + // an error. + if (!precedenceList) + return; + + var removedValue = undefined; + for (var i = 0; i < precedenceList.length; i++) { + var precedence = precedenceList[i]; + if (precedence.subscriptionHandle === subscriptionHandle) { + // The view's value can only change if this subscription is the one that + // used to have precedence. + if (i === 0) + removedValue = precedence.value; + precedenceList.splice(i, 1); + break; + } + } + if (precedenceList.length === 0) { + self.dataByKey.delete(key); + changeCollector[key] = undefined; + } else if (removedValue !== undefined && + !EJSON.equals(removedValue, precedenceList[0].value)) { + changeCollector[key] = precedenceList[0].value; + } + }, + + changeField: function (subscriptionHandle, key, value, + changeCollector, isAdd) { + var self = this; + // Publish API ignores _id if present in fields + if (key === "_id") + return; + + // Don't share state with the data passed in by the user. + value = EJSON.clone(value); + + if (!self.dataByKey.has(key)) { + self.dataByKey.set(key, [{subscriptionHandle: subscriptionHandle, + value: value}]); + changeCollector[key] = value; + return; + } + var precedenceList = self.dataByKey.get(key); + var elt; + if (!isAdd) { + elt = precedenceList.find(function (precedence) { + return precedence.subscriptionHandle === subscriptionHandle; + }); + } + + if (elt) { + if (elt === precedenceList[0] && !EJSON.equals(value, elt.value)) { + // this subscription is changing the value of this field. + changeCollector[key] = value; + } + elt.value = value; + } else { + // this subscription is newly caring about this field + precedenceList.push({subscriptionHandle: subscriptionHandle, value: value}); + } + + } +}); + +/** + * Represents a client's view of a single collection + * @param {String} collectionName Name of the collection it represents + * @param {Object.} sessionCallbacks The callbacks for added, changed, removed + * @class SessionCollectionView + */ +var SessionCollectionView = function (collectionName, sessionCallbacks) { + var self = this; + self.collectionName = collectionName; + self.documents = new Map(); + self.callbacks = sessionCallbacks; +}; + +DDPServer._SessionCollectionView = SessionCollectionView; + + +Object.assign(SessionCollectionView.prototype, { + + isEmpty: function () { + var self = this; + return self.documents.size === 0; + }, + + diff: function (previous) { + var self = this; + DiffSequence.diffMaps(previous.documents, self.documents, { + both: _.bind(self.diffDocument, self), + + rightOnly: function (id, nowDV) { + self.callbacks.added(self.collectionName, id, nowDV.getFields()); + }, + + leftOnly: function (id, prevDV) { + self.callbacks.removed(self.collectionName, id); + } + }); + }, + + diffDocument: function (id, prevDV, nowDV) { + var self = this; + var fields = {}; + DiffSequence.diffObjects(prevDV.getFields(), nowDV.getFields(), { + both: function (key, prev, now) { + if (!EJSON.equals(prev, now)) + fields[key] = now; + }, + rightOnly: function (key, now) { + fields[key] = now; + }, + leftOnly: function(key, prev) { + fields[key] = undefined; + } + }); + self.callbacks.changed(self.collectionName, id, fields); + }, + + added: function (subscriptionHandle, id, fields) { + var self = this; + var docView = self.documents.get(id); + var added = false; + if (!docView) { + added = true; + docView = new SessionDocumentView(); + self.documents.set(id, docView); + } + docView.existsIn.add(subscriptionHandle); + var changeCollector = {}; + _.each(fields, function (value, key) { + docView.changeField( + subscriptionHandle, key, value, changeCollector, true); + }); + if (added) + self.callbacks.added(self.collectionName, id, changeCollector); + else + self.callbacks.changed(self.collectionName, id, changeCollector); + }, + + changed: function (subscriptionHandle, id, changed) { + var self = this; + var changedResult = {}; + var docView = self.documents.get(id); + if (!docView) + throw new Error("Could not find element with id " + id + " to change"); + _.each(changed, function (value, key) { + if (value === undefined) + docView.clearField(subscriptionHandle, key, changedResult); + else + docView.changeField(subscriptionHandle, key, value, changedResult); + }); + self.callbacks.changed(self.collectionName, id, changedResult); + }, + + removed: function (subscriptionHandle, id) { + var self = this; + var docView = self.documents.get(id); + if (!docView) { + var err = new Error("Removed nonexistent document " + id); + throw err; + } + docView.existsIn.delete(subscriptionHandle); + if (docView.existsIn.size === 0) { + // it is gone from everyone + self.callbacks.removed(self.collectionName, id); + self.documents.delete(id); + } else { + var changed = {}; + // remove this subscription from every precedence list + // and record the changes + docView.dataByKey.forEach(function (precedenceList, key) { + docView.clearField(subscriptionHandle, key, changed); + }); + + self.callbacks.changed(self.collectionName, id, changed); + } + } +}); + +/******************************************************************************/ +/* Session */ +/******************************************************************************/ + +var Session = function (server, version, socket, options) { + var self = this; + self.id = Random.id(); + + self.server = server; + self.version = version; + + self.initialized = false; + self.socket = socket; + + // Set to null when the session is destroyed. Multiple places below + // use this to determine if the session is alive or not. + self.inQueue = new Meteor._DoubleEndedQueue(); + + self.blocked = false; + self.workerRunning = false; + + self.cachedUnblock = null; + + // Sub objects for active subscriptions + self._namedSubs = new Map(); + self._universalSubs = []; + + self.userId = null; + + self.collectionViews = new Map(); + + // Set this to false to not send messages when collectionViews are + // modified. This is done when rerunning subs in _setUserId and those messages + // are calculated via a diff instead. + self._isSending = true; + + // If this is true, don't start a newly-created universal publisher on this + // session. The session will take care of starting it when appropriate. + self._dontStartNewUniversalSubs = false; + + // When we are rerunning subscriptions, any ready messages + // we want to buffer up for when we are done rerunning subscriptions + self._pendingReady = []; + + // List of callbacks to call when this connection is closed. + self._closeCallbacks = []; + + + // XXX HACK: If a sockjs connection, save off the URL. This is + // temporary and will go away in the near future. + self._socketUrl = socket.url; + + // Allow tests to disable responding to pings. + self._respondToPings = options.respondToPings; + + // This object is the public interface to the session. In the public + // API, it is called the `connection` object. Internally we call it + // a `connectionHandle` to avoid ambiguity. + self.connectionHandle = { + id: self.id, + close: function () { + self.close(); + }, + onClose: function (fn) { + var cb = Meteor.bindEnvironment(fn, "connection onClose callback"); + if (self.inQueue) { + self._closeCallbacks.push(cb); + } else { + // if we're already closed, call the callback. + Meteor.defer(cb); + } + }, + clientAddress: self._clientAddress(), + httpHeaders: self.socket.headers + }; + + self.send({ msg: 'connected', session: self.id }); + + // On initial connect, spin up all the universal publishers. + Meteor._runAsync(function() { + self.startUniversalSubs(); + }); + + if (version !== 'pre1' && options.heartbeatInterval !== 0) { + // We no longer need the low level timeout because we have heartbeats. + socket.setWebsocketTimeout(0); + + self.heartbeat = new DDPCommon.Heartbeat({ + heartbeatInterval: options.heartbeatInterval, + heartbeatTimeout: options.heartbeatTimeout, + onTimeout: function () { + self.close(); + }, + sendPing: function () { + self.send({msg: 'ping'}); + } + }); + self.heartbeat.start(); + } + + Package['facts-base'] && Package['facts-base'].Facts.incrementServerFact( + "livedata", "sessions", 1); +}; + +Object.assign(Session.prototype, { + + sendReady: function (subscriptionIds) { + var self = this; + if (self._isSending) + self.send({msg: "ready", subs: subscriptionIds}); + else { + _.each(subscriptionIds, function (subscriptionId) { + self._pendingReady.push(subscriptionId); + }); + } + }, + + _canSend(collectionName) { + return this._isSending || !this.server.getPublicationStrategy(collectionName).useCollectionView; + }, + + + sendAdded(collectionName, id, fields) { + if (this._canSend(collectionName)) + this.send({msg: "added", collection: collectionName, id, fields}); + }, + + sendChanged(collectionName, id, fields) { + if (_.isEmpty(fields)) + return; + + if (this._canSend(collectionName)) { + this.send({ + msg: "changed", + collection: collectionName, + id, + fields + }); + } + }, + + sendRemoved(collectionName, id) { + if (this._canSend(collectionName)) + this.send({msg: "removed", collection: collectionName, id}); + }, + + getSendCallbacks: function () { + var self = this; + return { + added: _.bind(self.sendAdded, self), + changed: _.bind(self.sendChanged, self), + removed: _.bind(self.sendRemoved, self) + }; + }, + + getCollectionView: function (collectionName) { + var self = this; + var ret = self.collectionViews.get(collectionName); + if (!ret) { + ret = new SessionCollectionView(collectionName, + self.getSendCallbacks()); + self.collectionViews.set(collectionName, ret); + } + return ret; + }, + + added(subscriptionHandle, collectionName, id, fields) { + if (this.server.getPublicationStrategy(collectionName).useCollectionView) { + const view = this.getCollectionView(collectionName); + view.added(subscriptionHandle, id, fields); + } else { + this.sendAdded(collectionName, id, fields); + } + }, + + removed(subscriptionHandle, collectionName, id) { + if (this.server.getPublicationStrategy(collectionName).useCollectionView) { + const view = this.getCollectionView(collectionName); + view.removed(subscriptionHandle, id); + if (view.isEmpty()) { + this.collectionViews.delete(collectionName); + } + } else { + this.sendRemoved(collectionName, id); + } + }, + + changed(subscriptionHandle, collectionName, id, fields) { + if (this.server.getPublicationStrategy(collectionName).useCollectionView) { + const view = this.getCollectionView(collectionName); + view.changed(subscriptionHandle, id, fields); + } else { + this.sendChanged(collectionName, id, fields); + } + }, + + startUniversalSubs: function () { + var self = this; + // Make a shallow copy of the set of universal handlers and start them. If + // additional universal publishers start while we're running them (due to + // yielding), they will run separately as part of Server.publish. + var handlers = _.clone(self.server.universal_publish_handlers); + _.each(handlers, function (handler) { + self._startSubscription(handler); + }); + }, + + // Destroy this session and unregister it at the server. + close: function () { + var self = this; + + // Destroy this session, even if it's not registered at the + // server. Stop all processing and tear everything down. If a socket + // was attached, close it. + + // Already destroyed. + if (! self.inQueue) + return; + + // Drop the merge box data immediately. + self.inQueue = null; + self.collectionViews = new Map(); + + if (self.heartbeat) { + self.heartbeat.stop(); + self.heartbeat = null; + } + + if (self.socket) { + self.socket.close(); + self.socket._meteorSession = null; + } + + Package['facts-base'] && Package['facts-base'].Facts.incrementServerFact( + "livedata", "sessions", -1); + + Meteor.defer(function () { + // Stop callbacks can yield, so we defer this on close. + // sub._isDeactivated() detects that we set inQueue to null and + // treats it as semi-deactivated (it will ignore incoming callbacks, etc). + self._deactivateAllSubscriptions(); + + // Defer calling the close callbacks, so that the caller closing + // the session isn't waiting for all the callbacks to complete. + _.each(self._closeCallbacks, function (callback) { + callback(); + }); + }); + + // Unregister the session. + self.server._removeSession(self); + }, + + // Send a message (doing nothing if no socket is connected right now). + // It should be a JSON object (it will be stringified). + send: function (msg) { + var self = this; + if (self.socket) { + if (Meteor._printSentDDP) + Meteor._debug("Sent DDP", DDPCommon.stringifyDDP(msg)); + self.socket.send(DDPCommon.stringifyDDP(msg)); + } + }, + + // Send a connection error. + sendError: function (reason, offendingMessage) { + var self = this; + var msg = {msg: 'error', reason: reason}; + if (offendingMessage) + msg.offendingMessage = offendingMessage; + self.send(msg); + }, + + // Process 'msg' as an incoming message. As a guard against + // race conditions during reconnection, ignore the message if + // 'socket' is not the currently connected socket. + // + // We run the messages from the client one at a time, in the order + // given by the client. The message handler is passed an idempotent + // function 'unblock' which it may call to allow other messages to + // begin running in parallel in another fiber (for example, a method + // that wants to yield). Otherwise, it is automatically unblocked + // when it returns. + // + // Actually, we don't have to 'totally order' the messages in this + // way, but it's the easiest thing that's correct. (unsub needs to + // be ordered against sub, methods need to be ordered against each + // other). + processMessage: function (msg_in) { + var self = this; + if (!self.inQueue) // we have been destroyed. + return; + + // Respond to ping and pong messages immediately without queuing. + // If the negotiated DDP version is "pre1" which didn't support + // pings, preserve the "pre1" behavior of responding with a "bad + // request" for the unknown messages. + // + // Fibers are needed because heartbeats use Meteor.setTimeout, which + // needs a Fiber. We could actually use regular setTimeout and avoid + // these new fibers, but it is easier to just make everything use + // Meteor.setTimeout and not think too hard. + // + // Any message counts as receiving a pong, as it demonstrates that + // the client is still alive. + if (self.heartbeat) { + Meteor._runAsync(function() { + self.heartbeat.messageReceived(); + }); + }; + + if (self.version !== 'pre1' && msg_in.msg === 'ping') { + if (self._respondToPings) + self.send({msg: "pong", id: msg_in.id}); + return; + } + if (self.version !== 'pre1' && msg_in.msg === 'pong') { + // Since everything is a pong, there is nothing to do + return; + } + + self.inQueue.push(msg_in); + if (self.workerRunning) + return; + self.workerRunning = true; + + var processNext = function () { + var msg = self.inQueue && self.inQueue.shift(); + if (!msg) { + self.workerRunning = false; + return; + } + + function runHandlers() { + var blocked = true; + + var unblock = function () { + if (!blocked) + return; // idempotent + blocked = false; + processNext(); + }; + + self.server.onMessageHook.each(function (callback) { + callback(msg, self); + return true; + }); + + if (_.has(self.protocol_handlers, msg.msg)) + self.protocol_handlers[msg.msg].call(self, msg, unblock); + else + self.sendError('Bad request', msg); + unblock(); // in case the handler didn't already do it + } + + Meteor._runAsync(runHandlers); + }; + + processNext(); + }, + + protocol_handlers: { + sub: function (msg, unblock) { + var self = this; + + // cacheUnblock temporarly, so we can capture it later + // we will use unblock in current eventLoop, so this is safe + self.cachedUnblock = unblock; + + // reject malformed messages + if (typeof (msg.id) !== "string" || + typeof (msg.name) !== "string" || + (('params' in msg) && !(msg.params instanceof Array))) { + self.sendError("Malformed subscription", msg); + return; + } + + if (!self.server.publish_handlers[msg.name]) { + self.send({ + msg: 'nosub', id: msg.id, + error: new Meteor.Error(404, `Subscription '${msg.name}' not found`)}); + return; + } + + if (self._namedSubs.has(msg.id)) + // subs are idempotent, or rather, they are ignored if a sub + // with that id already exists. this is important during + // reconnect. + return; + + // XXX It'd be much better if we had generic hooks where any package can + // hook into subscription handling, but in the mean while we special case + // ddp-rate-limiter package. This is also done for weak requirements to + // add the ddp-rate-limiter package in case we don't have Accounts. A + // user trying to use the ddp-rate-limiter must explicitly require it. + if (Package['ddp-rate-limiter']) { + var DDPRateLimiter = Package['ddp-rate-limiter'].DDPRateLimiter; + var rateLimiterInput = { + userId: self.userId, + clientAddress: self.connectionHandle.clientAddress, + type: "subscription", + name: msg.name, + connectionId: self.id + }; + + DDPRateLimiter._increment(rateLimiterInput); + var rateLimitResult = DDPRateLimiter._check(rateLimiterInput); + if (!rateLimitResult.allowed) { + self.send({ + msg: 'nosub', id: msg.id, + error: new Meteor.Error( + 'too-many-requests', + DDPRateLimiter.getErrorMessage(rateLimitResult), + {timeToReset: rateLimitResult.timeToReset}) + }); + return; + } + } + + var handler = self.server.publish_handlers[msg.name]; + + self._startSubscription(handler, msg.id, msg.params, msg.name); + + // cleaning cached unblock + self.cachedUnblock = null; + }, + + unsub: function (msg) { + var self = this; + + self._stopSubscription(msg.id); + }, + + method: function (msg, unblock) { + var self = this; + + // Reject malformed messages. + // For now, we silently ignore unknown attributes, + // for forwards compatibility. + if (typeof (msg.id) !== "string" || + typeof (msg.method) !== "string" || + (('params' in msg) && !(msg.params instanceof Array)) || + (('randomSeed' in msg) && (typeof msg.randomSeed !== "string"))) { + self.sendError("Malformed method invocation", msg); + return; + } + + var randomSeed = msg.randomSeed || null; + + // Set up to mark the method as satisfied once all observers + // (and subscriptions) have reacted to any writes that were + // done. + var fence = new DDPServer._WriteFence; + fence.onAllCommitted(function () { + // Retire the fence so that future writes are allowed. + // This means that callbacks like timers are free to use + // the fence, and if they fire before it's armed (for + // example, because the method waits for them) their + // writes will be included in the fence. + fence.retire(); + self.send({ + msg: 'updated', methods: [msg.id]}); + }); + + // Find the handler + var handler = self.server.method_handlers[msg.method]; + if (!handler) { + self.send({ + msg: 'result', id: msg.id, + error: new Meteor.Error(404, `Method '${msg.method}' not found`)}); + fence.arm(); + return; + } + + var setUserId = function(userId) { + self._setUserId(userId); + }; + + var invocation = new DDPCommon.MethodInvocation({ + isSimulation: false, + userId: self.userId, + setUserId: setUserId, + unblock: unblock, + connection: self.connectionHandle, + randomSeed: randomSeed + }); + + const promise = new Promise((resolve, reject) => { + // XXX It'd be better if we could hook into method handlers better but + // for now, we need to check if the ddp-rate-limiter exists since we + // have a weak requirement for the ddp-rate-limiter package to be added + // to our application. + if (Package['ddp-rate-limiter']) { + var DDPRateLimiter = Package['ddp-rate-limiter'].DDPRateLimiter; + var rateLimiterInput = { + userId: self.userId, + clientAddress: self.connectionHandle.clientAddress, + type: "method", + name: msg.method, + connectionId: self.id + }; + DDPRateLimiter._increment(rateLimiterInput); + var rateLimitResult = DDPRateLimiter._check(rateLimiterInput) + if (!rateLimitResult.allowed) { + reject(new Meteor.Error( + "too-many-requests", + DDPRateLimiter.getErrorMessage(rateLimitResult), + {timeToReset: rateLimitResult.timeToReset} + )); + return; + } + } + + const getCurrentMethodInvocationResult = () => { + const currentContext = DDP._CurrentMethodInvocation._setNewContextAndGetCurrent( + invocation + ); + + try { + let result; + const resultOrThenable = maybeAuditArgumentChecks( + handler, + invocation, + msg.params, + "call to '" + msg.method + "'" + ); + const isThenable = + resultOrThenable && typeof resultOrThenable.then === 'function'; + if (isThenable) { + result = Meteor._isFibersEnabled ? Promise.await(resultOrThenable) : resultOrThenable; + } else { + result = resultOrThenable; + } + return result; + } finally { + DDP._CurrentMethodInvocation._set(currentContext); + } + }; + + resolve(DDPServer._CurrentWriteFence.withValue(fence, getCurrentMethodInvocationResult)); + }); + + function finish() { + fence.arm(); + unblock(); + } + + const payload = { + msg: "result", + id: msg.id + }; + + promise.then(result => { + finish(); + if (result !== undefined) { + payload.result = result; + } + self.send(payload); + }, (exception) => { + finish(); + payload.error = wrapInternalException( + exception, + `while invoking method '${msg.method}'` + ); + self.send(payload); + }); + } + }, + + _eachSub: function (f) { + var self = this; + self._namedSubs.forEach(f); + self._universalSubs.forEach(f); + }, + + _diffCollectionViews: function (beforeCVs) { + var self = this; + DiffSequence.diffMaps(beforeCVs, self.collectionViews, { + both: function (collectionName, leftValue, rightValue) { + rightValue.diff(leftValue); + }, + rightOnly: function (collectionName, rightValue) { + rightValue.documents.forEach(function (docView, id) { + self.sendAdded(collectionName, id, docView.getFields()); + }); + }, + leftOnly: function (collectionName, leftValue) { + leftValue.documents.forEach(function (doc, id) { + self.sendRemoved(collectionName, id); + }); + } + }); + }, + + // Sets the current user id in all appropriate contexts and reruns + // all subscriptions + _setUserId: function(userId) { + var self = this; + + if (userId !== null && typeof userId !== "string") + throw new Error("setUserId must be called on string or null, not " + + typeof userId); + + // Prevent newly-created universal subscriptions from being added to our + // session. They will be found below when we call startUniversalSubs. + // + // (We don't have to worry about named subscriptions, because we only add + // them when we process a 'sub' message. We are currently processing a + // 'method' message, and the method did not unblock, because it is illegal + // to call setUserId after unblock. Thus we cannot be concurrently adding a + // new named subscription). + self._dontStartNewUniversalSubs = true; + + // Prevent current subs from updating our collectionViews and call their + // stop callbacks. This may yield. + self._eachSub(function (sub) { + sub._deactivate(); + }); + + // All subs should now be deactivated. Stop sending messages to the client, + // save the state of the published collections, reset to an empty view, and + // update the userId. + self._isSending = false; + var beforeCVs = self.collectionViews; + self.collectionViews = new Map(); + self.userId = userId; + + // _setUserId is normally called from a Meteor method with + // DDP._CurrentMethodInvocation set. But DDP._CurrentMethodInvocation is not + // expected to be set inside a publish function, so we temporary unset it. + // Inside a publish function DDP._CurrentPublicationInvocation is set. + DDP._CurrentMethodInvocation.withValue(undefined, function () { + // Save the old named subs, and reset to having no subscriptions. + var oldNamedSubs = self._namedSubs; + self._namedSubs = new Map(); + self._universalSubs = []; + + oldNamedSubs.forEach(function (sub, subscriptionId) { + var newSub = sub._recreate(); + self._namedSubs.set(subscriptionId, newSub); + // nb: if the handler throws or calls this.error(), it will in fact + // immediately send its 'nosub'. This is OK, though. + newSub._runHandler(); + }); + + // Allow newly-created universal subs to be started on our connection in + // parallel with the ones we're spinning up here, and spin up universal + // subs. + self._dontStartNewUniversalSubs = false; + self.startUniversalSubs(); + }); + + // Start sending messages again, beginning with the diff from the previous + // state of the world to the current state. No yields are allowed during + // this diff, so that other changes cannot interleave. + Meteor._noYieldsAllowed(function () { + self._isSending = true; + self._diffCollectionViews(beforeCVs); + if (!_.isEmpty(self._pendingReady)) { + self.sendReady(self._pendingReady); + self._pendingReady = []; + } + }); + }, + + _startSubscription: function (handler, subId, params, name) { + var self = this; + + var sub = new Subscription( + self, handler, subId, params, name); + + let unblockHander = self.cachedUnblock; + // _startSubscription may call from a lot places + // so cachedUnblock might be null in somecases + // assign the cachedUnblock + sub.unblock = unblockHander || (() => {}); + + if (subId) + self._namedSubs.set(subId, sub); + else + self._universalSubs.push(sub); + + sub._runHandler(); + }, + + // Tear down specified subscription + _stopSubscription: function (subId, error) { + var self = this; + + var subName = null; + if (subId) { + var maybeSub = self._namedSubs.get(subId); + if (maybeSub) { + subName = maybeSub._name; + maybeSub._removeAllDocuments(); + maybeSub._deactivate(); + self._namedSubs.delete(subId); + } + } + + var response = {msg: 'nosub', id: subId}; + + if (error) { + response.error = wrapInternalException( + error, + subName ? ("from sub " + subName + " id " + subId) + : ("from sub id " + subId)); + } + + self.send(response); + }, + + // Tear down all subscriptions. Note that this does NOT send removed or nosub + // messages, since we assume the client is gone. + _deactivateAllSubscriptions: function () { + var self = this; + + self._namedSubs.forEach(function (sub, id) { + sub._deactivate(); + }); + self._namedSubs = new Map(); + + self._universalSubs.forEach(function (sub) { + sub._deactivate(); + }); + self._universalSubs = []; + }, + + // Determine the remote client's IP address, based on the + // HTTP_FORWARDED_COUNT environment variable representing how many + // proxies the server is behind. + _clientAddress: function () { + var self = this; + + // For the reported client address for a connection to be correct, + // the developer must set the HTTP_FORWARDED_COUNT environment + // variable to an integer representing the number of hops they + // expect in the `x-forwarded-for` header. E.g., set to "1" if the + // server is behind one proxy. + // + // This could be computed once at startup instead of every time. + var httpForwardedCount = parseInt(process.env['HTTP_FORWARDED_COUNT']) || 0; + + if (httpForwardedCount === 0) + return self.socket.remoteAddress; + + var forwardedFor = self.socket.headers["x-forwarded-for"]; + if (! _.isString(forwardedFor)) + return null; + forwardedFor = forwardedFor.trim().split(/\s*,\s*/); + + // Typically the first value in the `x-forwarded-for` header is + // the original IP address of the client connecting to the first + // proxy. However, the end user can easily spoof the header, in + // which case the first value(s) will be the fake IP address from + // the user pretending to be a proxy reporting the original IP + // address value. By counting HTTP_FORWARDED_COUNT back from the + // end of the list, we ensure that we get the IP address being + // reported by *our* first proxy. + + if (httpForwardedCount < 0 || httpForwardedCount > forwardedFor.length) + return null; + + return forwardedFor[forwardedFor.length - httpForwardedCount]; + } +}); + +/******************************************************************************/ +/* Subscription */ +/******************************************************************************/ + +// Ctor for a sub handle: the input to each publish function + +// Instance name is this because it's usually referred to as this inside a +// publish +/** + * @summary The server's side of a subscription + * @class Subscription + * @instanceName this + * @showInstanceName true + */ +var Subscription = function ( + session, handler, subscriptionId, params, name) { + var self = this; + self._session = session; // type is Session + + /** + * @summary Access inside the publish function. The incoming [connection](#meteor_onconnection) for this subscription. + * @locus Server + * @name connection + * @memberOf Subscription + * @instance + */ + self.connection = session.connectionHandle; // public API object + + self._handler = handler; + + // My subscription ID (generated by client, undefined for universal subs). + self._subscriptionId = subscriptionId; + // Undefined for universal subs + self._name = name; + + self._params = params || []; + + // Only named subscriptions have IDs, but we need some sort of string + // internally to keep track of all subscriptions inside + // SessionDocumentViews. We use this subscriptionHandle for that. + if (self._subscriptionId) { + self._subscriptionHandle = 'N' + self._subscriptionId; + } else { + self._subscriptionHandle = 'U' + Random.id(); + } + + // Has _deactivate been called? + self._deactivated = false; + + // Stop callbacks to g/c this sub. called w/ zero arguments. + self._stopCallbacks = []; + + // The set of (collection, documentid) that this subscription has + // an opinion about. + self._documents = new Map(); + + // Remember if we are ready. + self._ready = false; + + // Part of the public API: the user of this sub. + + /** + * @summary Access inside the publish function. The id of the logged-in user, or `null` if no user is logged in. + * @locus Server + * @memberOf Subscription + * @name userId + * @instance + */ + self.userId = session.userId; + + // For now, the id filter is going to default to + // the to/from DDP methods on MongoID, to + // specifically deal with mongo/minimongo ObjectIds. + + // Later, you will be able to make this be "raw" + // if you want to publish a collection that you know + // just has strings for keys and no funny business, to + // a DDP consumer that isn't minimongo. + + self._idFilter = { + idStringify: MongoID.idStringify, + idParse: MongoID.idParse + }; + + Package['facts-base'] && Package['facts-base'].Facts.incrementServerFact( + "livedata", "subscriptions", 1); +}; + +Object.assign(Subscription.prototype, { + _runHandler: function() { + // XXX should we unblock() here? Either before running the publish + // function, or before running _publishCursor. + // + // Right now, each publish function blocks all future publishes and + // methods waiting on data from Mongo (or whatever else the function + // blocks on). This probably slows page load in common cases. + + if (!this.unblock) { + this.unblock = () => {}; + } + + const self = this; + let resultOrThenable = null; + try { + resultOrThenable = DDP._CurrentPublicationInvocation.withValue(self, () => + maybeAuditArgumentChecks( + self._handler, + self, + EJSON.clone(self._params), + // It's OK that this would look weird for universal subscriptions, + // because they have no arguments so there can never be an + // audit-argument-checks failure. + "publisher '" + self._name + "'" + ) + ); + } catch (e) { + self.error(e); + return; + } + + // Did the handler call this.error or this.stop? + if (self._isDeactivated()) return; + + // Both conventional and async publish handler functions are supported. + // If an object is returned with a then() function, it is either a promise + // or thenable and will be resolved asynchronously. + const isThenable = + resultOrThenable && typeof resultOrThenable.then === 'function'; + if (isThenable) { + Promise.resolve(resultOrThenable).then( + (...args) => self._publishHandlerResult.bind(self)(...args), + e => self.error(e) + ); + } else { + self._publishHandlerResult(resultOrThenable); + } + }, + + _publishHandlerResult: function (res) { + // SPECIAL CASE: Instead of writing their own callbacks that invoke + // this.added/changed/ready/etc, the user can just return a collection + // cursor or array of cursors from the publish function; we call their + // _publishCursor method which starts observing the cursor and publishes the + // results. Note that _publishCursor does NOT call ready(). + // + // XXX This uses an undocumented interface which only the Mongo cursor + // interface publishes. Should we make this interface public and encourage + // users to implement it themselves? Arguably, it's unnecessary; users can + // already write their own functions like + // var publishMyReactiveThingy = function (name, handler) { + // Meteor.publish(name, function () { + // var reactiveThingy = handler(); + // reactiveThingy.publishMe(); + // }); + // }; + + var self = this; + var isCursor = function (c) { + return c && c._publishCursor; + }; + if (isCursor(res)) { + if (Meteor._isFibersEnabled) { + try { + res._publishCursor(self); + } catch (e) { + self.error(e); + return; + } + // _publishCursor only returns after the initial added callbacks have run. + // mark subscription as ready. + self.ready(); + } else { + res._publishCursor(self).then(() => { + self.ready(); + }).catch((e) => self.error(e)); + } + } else if (_.isArray(res)) { + // Check all the elements are cursors + if (! _.all(res, isCursor)) { + self.error(new Error("Publish function returned an array of non-Cursors")); + return; + } + // Find duplicate collection names + // XXX we should support overlapping cursors, but that would require the + // merge box to allow overlap within a subscription + var collectionNames = {}; + for (var i = 0; i < res.length; ++i) { + var collectionName = res[i]._getCollectionName(); + if (_.has(collectionNames, collectionName)) { + self.error(new Error( + "Publish function returned multiple cursors for collection " + + collectionName)); + return; + } + collectionNames[collectionName] = true; + }; + + if (Meteor._isFibersEnabled) { + try { + _.each(res, function (cur) { + cur._publishCursor(self); + }); + } catch (e) { + self.error(e); + return; + } + self.ready(); + } else { + Promise.all(res.map((c) => c._publishCursor(self))).then(() => { + self.ready(); + }).catch((e) => self.error(e)); + } + } else if (res) { + // Truthy values other than cursors or arrays are probably a + // user mistake (possible returning a Mongo document via, say, + // `coll.findOne()`). + self.error(new Error("Publish function can only return a Cursor or " + + "an array of Cursors")); + } + }, + + // This calls all stop callbacks and prevents the handler from updating any + // SessionCollectionViews further. It's used when the user unsubscribes or + // disconnects, as well as during setUserId re-runs. It does *NOT* send + // removed messages for the published objects; if that is necessary, call + // _removeAllDocuments first. + _deactivate: function() { + var self = this; + if (self._deactivated) + return; + self._deactivated = true; + self._callStopCallbacks(); + Package['facts-base'] && Package['facts-base'].Facts.incrementServerFact( + "livedata", "subscriptions", -1); + }, + + _callStopCallbacks: function () { + var self = this; + // Tell listeners, so they can clean up + var callbacks = self._stopCallbacks; + self._stopCallbacks = []; + _.each(callbacks, function (callback) { + callback(); + }); + }, + + // Send remove messages for every document. + _removeAllDocuments: function () { + var self = this; + Meteor._noYieldsAllowed(function () { + self._documents.forEach(function (collectionDocs, collectionName) { + collectionDocs.forEach(function (strId) { + self.removed(collectionName, self._idFilter.idParse(strId)); + }); + }); + }); + }, + + // Returns a new Subscription for the same session with the same + // initial creation parameters. This isn't a clone: it doesn't have + // the same _documents cache, stopped state or callbacks; may have a + // different _subscriptionHandle, and gets its userId from the + // session, not from this object. + _recreate: function () { + var self = this; + return new Subscription( + self._session, self._handler, self._subscriptionId, self._params, + self._name); + }, + + /** + * @summary Call inside the publish function. Stops this client's subscription, triggering a call on the client to the `onStop` callback passed to [`Meteor.subscribe`](#meteor_subscribe), if any. If `error` is not a [`Meteor.Error`](#meteor_error), it will be [sanitized](#meteor_error). + * @locus Server + * @param {Error} error The error to pass to the client. + * @instance + * @memberOf Subscription + */ + error: function (error) { + var self = this; + if (self._isDeactivated()) + return; + self._session._stopSubscription(self._subscriptionId, error); + }, + + // Note that while our DDP client will notice that you've called stop() on the + // server (and clean up its _subscriptions table) we don't actually provide a + // mechanism for an app to notice this (the subscribe onError callback only + // triggers if there is an error). + + /** + * @summary Call inside the publish function. Stops this client's subscription and invokes the client's `onStop` callback with no error. + * @locus Server + * @instance + * @memberOf Subscription + */ + stop: function () { + var self = this; + if (self._isDeactivated()) + return; + self._session._stopSubscription(self._subscriptionId); + }, + + /** + * @summary Call inside the publish function. Registers a callback function to run when the subscription is stopped. + * @locus Server + * @memberOf Subscription + * @instance + * @param {Function} func The callback function + */ + onStop: function (callback) { + var self = this; + callback = Meteor.bindEnvironment(callback, 'onStop callback', self); + if (self._isDeactivated()) + callback(); + else + self._stopCallbacks.push(callback); + }, + + // This returns true if the sub has been deactivated, *OR* if the session was + // destroyed but the deferred call to _deactivateAllSubscriptions hasn't + // happened yet. + _isDeactivated: function () { + var self = this; + return self._deactivated || self._session.inQueue === null; + }, + + /** + * @summary Call inside the publish function. Informs the subscriber that a document has been added to the record set. + * @locus Server + * @memberOf Subscription + * @instance + * @param {String} collection The name of the collection that contains the new document. + * @param {String} id The new document's ID. + * @param {Object} fields The fields in the new document. If `_id` is present it is ignored. + */ + added (collectionName, id, fields) { + if (this._isDeactivated()) + return; + id = this._idFilter.idStringify(id); + + if (this._session.server.getPublicationStrategy(collectionName).doAccountingForCollection) { + let ids = this._documents.get(collectionName); + if (ids == null) { + ids = new Set(); + this._documents.set(collectionName, ids); + } + ids.add(id); + } + + this._session.added(this._subscriptionHandle, collectionName, id, fields); + }, + + /** + * @summary Call inside the publish function. Informs the subscriber that a document in the record set has been modified. + * @locus Server + * @memberOf Subscription + * @instance + * @param {String} collection The name of the collection that contains the changed document. + * @param {String} id The changed document's ID. + * @param {Object} fields The fields in the document that have changed, together with their new values. If a field is not present in `fields` it was left unchanged; if it is present in `fields` and has a value of `undefined` it was removed from the document. If `_id` is present it is ignored. + */ + changed (collectionName, id, fields) { + if (this._isDeactivated()) + return; + id = this._idFilter.idStringify(id); + this._session.changed(this._subscriptionHandle, collectionName, id, fields); + }, + + /** + * @summary Call inside the publish function. Informs the subscriber that a document has been removed from the record set. + * @locus Server + * @memberOf Subscription + * @instance + * @param {String} collection The name of the collection that the document has been removed from. + * @param {String} id The ID of the document that has been removed. + */ + removed (collectionName, id) { + if (this._isDeactivated()) + return; + id = this._idFilter.idStringify(id); + + if (this._session.server.getPublicationStrategy(collectionName).doAccountingForCollection) { + // We don't bother to delete sets of things in a collection if the + // collection is empty. It could break _removeAllDocuments. + this._documents.get(collectionName).delete(id); + } + + this._session.removed(this._subscriptionHandle, collectionName, id); + }, + + /** + * @summary Call inside the publish function. Informs the subscriber that an initial, complete snapshot of the record set has been sent. This will trigger a call on the client to the `onReady` callback passed to [`Meteor.subscribe`](#meteor_subscribe), if any. + * @locus Server + * @memberOf Subscription + * @instance + */ + ready: function () { + var self = this; + if (self._isDeactivated()) + return; + if (!self._subscriptionId) + return; // Unnecessary but ignored for universal sub + if (!self._ready) { + self._session.sendReady([self._subscriptionId]); + self._ready = true; + } + } +}); + +/******************************************************************************/ +/* Server */ +/******************************************************************************/ + +Server = function (options = {}) { + var self = this; + + // The default heartbeat interval is 30 seconds on the server and 35 + // seconds on the client. Since the client doesn't need to send a + // ping as long as it is receiving pings, this means that pings + // normally go from the server to the client. + // + // Note: Troposphere depends on the ability to mutate + // Meteor.server.options.heartbeatTimeout! This is a hack, but it's life. + self.options = { + heartbeatInterval: 15000, + heartbeatTimeout: 15000, + // For testing, allow responding to pings to be disabled. + respondToPings: true, + defaultPublicationStrategy: publicationStrategies.SERVER_MERGE, + ...options, + }; + + // Map of callbacks to call when a new connection comes in to the + // server and completes DDP version negotiation. Use an object instead + // of an array so we can safely remove one from the list while + // iterating over it. + self.onConnectionHook = new Hook({ + debugPrintExceptions: "onConnection callback" + }); + + // Map of callbacks to call when a new message comes in. + self.onMessageHook = new Hook({ + debugPrintExceptions: "onMessage callback" + }); + + self.publish_handlers = {}; + self.universal_publish_handlers = []; + + self.method_handlers = {}; + + self._publicationStrategies = {}; + + self.sessions = new Map(); // map from id to session + + self.stream_server = new StreamServer; + + self.stream_server.register(function (socket) { + // socket implements the SockJSConnection interface + socket._meteorSession = null; + + var sendError = function (reason, offendingMessage) { + var msg = {msg: 'error', reason: reason}; + if (offendingMessage) + msg.offendingMessage = offendingMessage; + socket.send(DDPCommon.stringifyDDP(msg)); + }; + + socket.on('data', function (raw_msg) { + if (Meteor._printReceivedDDP) { + Meteor._debug("Received DDP", raw_msg); + } + try { + try { + var msg = DDPCommon.parseDDP(raw_msg); + } catch (err) { + sendError('Parse error'); + return; + } + if (msg === null || !msg.msg) { + sendError('Bad request', msg); + return; + } + + if (msg.msg === 'connect') { + if (socket._meteorSession) { + sendError("Already connected", msg); + return; + } + + Meteor._runAsync(function() { + self._handleConnect(socket, msg); + }) + + return; + } + + if (!socket._meteorSession) { + sendError('Must connect first', msg); + return; + } + socket._meteorSession.processMessage(msg); + } catch (e) { + // XXX print stack nicely + Meteor._debug("Internal exception while processing message", msg, e); + } + }); + + socket.on('close', function () { + if (socket._meteorSession) { + Meteor._runAsync(function() { + socket._meteorSession.close(); + }); + } + }); + }); +}; + +Object.assign(Server.prototype, { + + /** + * @summary Register a callback to be called when a new DDP connection is made to the server. + * @locus Server + * @param {function} callback The function to call when a new DDP connection is established. + * @memberOf Meteor + * @importFromPackage meteor + */ + onConnection: function (fn) { + var self = this; + return self.onConnectionHook.register(fn); + }, + + /** + * @summary Set publication strategy for the given collection. Publications strategies are available from `DDPServer.publicationStrategies`. You call this method from `Meteor.server`, like `Meteor.server.setPublicationStrategy()` + * @locus Server + * @alias setPublicationStrategy + * @param collectionName {String} + * @param strategy {{useCollectionView: boolean, doAccountingForCollection: boolean}} + * @memberOf Meteor.server + * @importFromPackage meteor + */ + setPublicationStrategy(collectionName, strategy) { + if (!Object.values(publicationStrategies).includes(strategy)) { + throw new Error(`Invalid merge strategy: ${strategy} + for collection ${collectionName}`); + } + this._publicationStrategies[collectionName] = strategy; + }, + + /** + * @summary Gets the publication strategy for the requested collection. You call this method from `Meteor.server`, like `Meteor.server.getPublicationStrategy()` + * @locus Server + * @alias getPublicationStrategy + * @param collectionName {String} + * @memberOf Meteor.server + * @importFromPackage meteor + * @return {{useCollectionView: boolean, doAccountingForCollection: boolean}} + */ + getPublicationStrategy(collectionName) { + return this._publicationStrategies[collectionName] + || this.options.defaultPublicationStrategy; + }, + + /** + * @summary Register a callback to be called when a new DDP message is received. + * @locus Server + * @param {function} callback The function to call when a new DDP message is received. + * @memberOf Meteor + * @importFromPackage meteor + */ + onMessage: function (fn) { + var self = this; + return self.onMessageHook.register(fn); + }, + + _handleConnect: function (socket, msg) { + var self = this; + + // The connect message must specify a version and an array of supported + // versions, and it must claim to support what it is proposing. + if (!(typeof (msg.version) === 'string' && + _.isArray(msg.support) && + _.all(msg.support, _.isString) && + _.contains(msg.support, msg.version))) { + socket.send(DDPCommon.stringifyDDP({msg: 'failed', + version: DDPCommon.SUPPORTED_DDP_VERSIONS[0]})); + socket.close(); + return; + } + + // In the future, handle session resumption: something like: + // socket._meteorSession = self.sessions[msg.session] + var version = calculateVersion(msg.support, DDPCommon.SUPPORTED_DDP_VERSIONS); + + if (msg.version !== version) { + // The best version to use (according to the client's stated preferences) + // is not the one the client is trying to use. Inform them about the best + // version to use. + socket.send(DDPCommon.stringifyDDP({msg: 'failed', version: version})); + socket.close(); + return; + } + + // Yay, version matches! Create a new session. + // Note: Troposphere depends on the ability to mutate + // Meteor.server.options.heartbeatTimeout! This is a hack, but it's life. + socket._meteorSession = new Session(self, version, socket, self.options); + self.sessions.set(socket._meteorSession.id, socket._meteorSession); + self.onConnectionHook.each(function (callback) { + if (socket._meteorSession) + callback(socket._meteorSession.connectionHandle); + return true; + }); + }, + /** + * Register a publish handler function. + * + * @param name {String} identifier for query + * @param handler {Function} publish handler + * @param options {Object} + * + * Server will call handler function on each new subscription, + * either when receiving DDP sub message for a named subscription, or on + * DDP connect for a universal subscription. + * + * If name is null, this will be a subscription that is + * automatically established and permanently on for all connected + * client, instead of a subscription that can be turned on and off + * with subscribe(). + * + * options to contain: + * - (mostly internal) is_auto: true if generated automatically + * from an autopublish hook. this is for cosmetic purposes only + * (it lets us determine whether to print a warning suggesting + * that you turn off autopublish). + */ + + /** + * @summary Publish a record set. + * @memberOf Meteor + * @importFromPackage meteor + * @locus Server + * @param {String|Object} name If String, name of the record set. If Object, publications Dictionary of publish functions by name. If `null`, the set has no name, and the record set is automatically sent to all connected clients. + * @param {Function} func Function called on the server each time a client subscribes. Inside the function, `this` is the publish handler object, described below. If the client passed arguments to `subscribe`, the function is called with the same arguments. + */ + publish: function (name, handler, options) { + var self = this; + + if (! _.isObject(name)) { + options = options || {}; + + if (name && name in self.publish_handlers) { + Meteor._debug("Ignoring duplicate publish named '" + name + "'"); + return; + } + + if (Package.autopublish && !options.is_auto) { + // They have autopublish on, yet they're trying to manually + // pick stuff to publish. They probably should turn off + // autopublish. (This check isn't perfect -- if you create a + // publish before you turn on autopublish, it won't catch + // it, but this will definitely handle the simple case where + // you've added the autopublish package to your app, and are + // calling publish from your app code). + if (!self.warned_about_autopublish) { + self.warned_about_autopublish = true; + Meteor._debug( + "** You've set up some data subscriptions with Meteor.publish(), but\n" + + "** you still have autopublish turned on. Because autopublish is still\n" + + "** on, your Meteor.publish() calls won't have much effect. All data\n" + + "** will still be sent to all clients.\n" + + "**\n" + + "** Turn off autopublish by removing the autopublish package:\n" + + "**\n" + + "** $ meteor remove autopublish\n" + + "**\n" + + "** .. and make sure you have Meteor.publish() and Meteor.subscribe() calls\n" + + "** for each collection that you want clients to see.\n"); + } + } + + if (name) + self.publish_handlers[name] = handler; + else { + self.universal_publish_handlers.push(handler); + // Spin up the new publisher on any existing session too. Run each + // session's subscription in a new Fiber, so that there's no change for + // self.sessions to change while we're running this loop. + self.sessions.forEach(function (session) { + if (!session._dontStartNewUniversalSubs) { + Meteor._runAsync(function() { + session._startSubscription(handler); + }); + } + }); + } + } + else{ + _.each(name, function(value, key) { + self.publish(key, value, {}); + }); + } + }, + + _removeSession: function (session) { + var self = this; + self.sessions.delete(session.id); + }, + + /** + * @summary Defines functions that can be invoked over the network by clients. + * @locus Anywhere + * @param {Object} methods Dictionary whose keys are method names and values are functions. + * @memberOf Meteor + * @importFromPackage meteor + */ + methods: function (methods) { + var self = this; + _.each(methods, function (func, name) { + if (typeof func !== 'function') + throw new Error("Method '" + name + "' must be a function"); + if (self.method_handlers[name]) + throw new Error("A method named '" + name + "' is already defined"); + self.method_handlers[name] = func; + }); + }, + + call: function (name, ...args) { + if (args.length && typeof args[args.length - 1] === "function") { + // If it's a function, the last argument is the result callback, not + // a parameter to the remote method. + var callback = args.pop(); + } + + return this.apply(name, args, callback); + }, + + // A version of the call method that always returns a Promise. + callAsync: function (name, ...args) { + return this.applyAsync(name, args); + }, + + apply: function (name, args, options, callback) { + // We were passed 3 arguments. They may be either (name, args, options) + // or (name, args, callback) + if (! callback && typeof options === 'function') { + callback = options; + options = {}; + } else { + options = options || {}; + } + + const promise = this.applyAsync(name, args, options); + + // Return the result in whichever way the caller asked for it. Note that we + // do NOT block on the write fence in an analogous way to how the client + // blocks on the relevant data being visible, so you are NOT guaranteed that + // cursor observe callbacks have fired when your callback is invoked. (We + // can change this if there's a real use case). + if (callback) { + promise.then( + result => callback(undefined, result), + exception => callback(exception) + ); + } else { + return promise.await(); + } + }, + + // @param options {Optional Object} + applyAsync: function (name, args, options) { + // Run the handler + var handler = this.method_handlers[name]; + if (! handler) { + return Promise.reject( + new Meteor.Error(404, `Method '${name}' not found`) + ); + } + + // If this is a method call from within another method or publish function, + // get the user state from the outer method or publish function, otherwise + // don't allow setUserId to be called + var userId = null; + var setUserId = function() { + throw new Error("Can't call setUserId on a server initiated method call"); + }; + var connection = null; + var currentMethodInvocation = DDP._CurrentMethodInvocation.get(); + var currentPublicationInvocation = DDP._CurrentPublicationInvocation.get(); + var randomSeed = null; + if (currentMethodInvocation) { + userId = currentMethodInvocation.userId; + setUserId = function(userId) { + currentMethodInvocation.setUserId(userId); + }; + connection = currentMethodInvocation.connection; + randomSeed = DDPCommon.makeRpcSeed(currentMethodInvocation, name); + } else if (currentPublicationInvocation) { + userId = currentPublicationInvocation.userId; + setUserId = function(userId) { + currentPublicationInvocation._session._setUserId(userId); + }; + connection = currentPublicationInvocation.connection; + } + + var invocation = new DDPCommon.MethodInvocation({ + isSimulation: false, + userId, + setUserId, + connection, + randomSeed + }); + + return new Promise(resolve => resolve( + DDP._CurrentMethodInvocation.withValue( + invocation, + () => maybeAuditArgumentChecks( + handler, invocation, EJSON.clone(args), + "internal call to '" + name + "'" + ) + ) + )).then(EJSON.clone); + }, + + _urlForSession: function (sessionId) { + var self = this; + var session = self.sessions.get(sessionId); + if (session) + return session._socketUrl; + else + return null; + } +}); + +var calculateVersion = function (clientSupportedVersions, + serverSupportedVersions) { + var correctVersion = _.find(clientSupportedVersions, function (version) { + return _.contains(serverSupportedVersions, version); + }); + if (!correctVersion) { + correctVersion = serverSupportedVersions[0]; + } + return correctVersion; +}; + +DDPServer._calculateVersion = calculateVersion; + + +// "blind" exceptions other than those that were deliberately thrown to signal +// errors to the client +var wrapInternalException = function (exception, context) { + if (!exception) return exception; + + // To allow packages to throw errors intended for the client but not have to + // depend on the Meteor.Error class, `isClientSafe` can be set to true on any + // error before it is thrown. + if (exception.isClientSafe) { + if (!(exception instanceof Meteor.Error)) { + const originalMessage = exception.message; + exception = new Meteor.Error(exception.error, exception.reason, exception.details); + exception.message = originalMessage; + } + return exception; + } + + // Tests can set the '_expectedByTest' flag on an exception so it won't go to + // the server log. + if (!exception._expectedByTest) { + Meteor._debug("Exception " + context, exception.stack); + if (exception.sanitizedError) { + Meteor._debug("Sanitized and reported to the client as:", exception.sanitizedError); + Meteor._debug(); + } + } + + // Did the error contain more details that could have been useful if caught in + // server code (or if thrown from non-client-originated code), but also + // provided a "sanitized" version with more context than 500 Internal server + // error? Use that. + if (exception.sanitizedError) { + if (exception.sanitizedError.isClientSafe) + return exception.sanitizedError; + Meteor._debug("Exception " + context + " provides a sanitizedError that " + + "does not have isClientSafe property set; ignoring"); + } + + return new Meteor.Error(500, "Internal server error"); +}; + + +// Audit argument checks, if the audit-argument-checks package exists (it is a +// weak dependency of this package). +var maybeAuditArgumentChecks = function (f, context, args, description) { + args = args || []; + if (Package['audit-argument-checks']) { + return Match._failIfArgumentsAreNotAllChecked( + f, context, args, description); + } + return f.apply(context, args); +}; diff --git a/packages/ddp-server-async/livedata_server_async_tests.js b/packages/ddp-server-async/livedata_server_async_tests.js new file mode 100644 index 0000000000..d145aeee92 --- /dev/null +++ b/packages/ddp-server-async/livedata_server_async_tests.js @@ -0,0 +1,192 @@ +var Fiber = Npm.require('fibers'); + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// connectionId -> callback +var onSubscription = {}; + +Meteor.publish('livedata_server_test_sub_async', async function(connectionId) { + await sleep(50); + var callback = onSubscription[connectionId]; + if (callback) callback(this); + this.stop(); +}); + +Meteor.publish('livedata_server_test_sub_context_async', async function( + connectionId, + userId +) { + await sleep(50); + var callback = onSubscription[connectionId]; + var methodInvocation = DDP._CurrentMethodInvocation.get(); + var publicationInvocation = DDP._CurrentPublicationInvocation.get(); + + // Check the publish function's environment variables and context. + if (callback) { + callback.call(this, methodInvocation, publicationInvocation); + } + + // Check that onStop callback is have the same context as the publish function + // and that it runs with the same environment variables as this publish function. + this.onStop(function() { + var onStopMethodInvocation = DDP._CurrentMethodInvocation.get(); + var onStopPublicationInvocation = DDP._CurrentPublicationInvocation.get(); + callback.call( + this, + onStopMethodInvocation, + onStopPublicationInvocation, + true + ); + }); + + if (this.userId) { + this.stop(); + } else { + this.ready(); + Meteor.call('livedata_server_test_setuserid', userId); + } +}); + +Tinytest.addAsync( + 'livedata server - connection in async publish function', + function(test, onComplete) { + makeTestConnection(test, function(clientConn, serverConn) { + onSubscription[serverConn.id] = function(subscription) { + delete onSubscription[serverConn.id]; + test.equal(subscription.connection.id, serverConn.id); + clientConn.disconnect(); + onComplete(); + }; + clientConn.subscribe('livedata_server_test_sub_async', serverConn.id); + }); + } +); + +Tinytest.addAsync( + 'livedata server - verify context in async publish function', + function(test, onComplete) { + makeTestConnection(test, function(clientConn, serverConn) { + var userId = 'someUserId'; + onSubscription[serverConn.id] = function( + methodInvocation, + publicationInvocation, + fromOnStop + ) { + // DDP._CurrentMethodInvocation should be undefined in a publish function + test.isUndefined(methodInvocation, 'Should have been undefined'); + // DDP._CurrentPublicationInvocation should be set in a publish function + test.isNotUndefined(publicationInvocation, 'Should have been defined'); + if (this.userId === userId && fromOnStop) { + delete onSubscription[serverConn.id]; + clientConn.disconnect(); + onComplete(); + } + }; + clientConn.subscribe( + 'livedata_server_test_sub_context_async', + serverConn.id, + userId + ); + }); + } +); + +let onSubscriptions = {}; + +Meteor.publish({ + async publicationObjectAsync() { + await sleep(50); + let callback = onSubscriptions; + if (callback) callback(); + this.stop(); + }, +}); + +Meteor.publish({ + publication_object_async: async function() { + await sleep(50); + let callback = onSubscriptions; + if (callback) callback(); + this.stop(); + }, +}); + +Meteor.publish('publication_compatibility_async', async function() { + await sleep(50); + let callback = onSubscriptions; + if (callback) callback(); + this.stop(); +}); + +Tinytest.addAsync('livedata server - async publish object', function( + test, + onComplete +) { + makeTestConnection(test, function(clientConn, serverConn) { + let testsLength = 0; + + onSubscriptions = function(subscription) { + delete onSubscriptions; + clientConn.disconnect(); + testsLength++; + if (testsLength == 3) { + onComplete(); + } + }; + clientConn.subscribe('publicationObjectAsync'); + clientConn.subscribe('publication_object_async'); + clientConn.subscribe('publication_compatibility_async'); + }); +}); +const collection = new Mongo.Collection('names'); + +async function getAllNames(shouldThrow = false) { + const count = await collection.rawCollection().count(); + if (shouldThrow) { + throw new Meteor.Error('Expected error'); + } + if (count <= 0) { + collection.insert({ name: 'async' }); + } +} +Meteor.publish('asyncPublishCursor', async function() { + await getAllNames(); + return collection.find(); +}); + +Tinytest.addAsync('livedata server - async publish cursor', function( + test, + onComplete +) { + makeTestConnection(test, (clientConn, serverConn) => { + const remoteCollection = new Mongo.Collection('names', { + connection: clientConn, + }); + clientConn.subscribe('asyncPublishCursor', () => { + const actual = remoteCollection.find().fetch(); + test.equal(actual[0].name, 'async'); + onComplete(); + }); + }); +}); + +Meteor.publish('asyncPublishErrorCursor', async function() { + await getAllNames(true); + return collection.find(); +}); + +Tinytest.addAsync('livedata server - async publish test error thrown', function( + test, + onComplete +) { + makeTestConnection(test, (clientConn, serverConn) => { + clientConn.subscribe('asyncPublishErrorCursor', { + onStop: e => { + test.equal(e.error, 'Expected error'); + onComplete(); + }, + }); + }); +}); diff --git a/packages/ddp-server-async/livedata_server_tests.js b/packages/ddp-server-async/livedata_server_tests.js new file mode 100644 index 0000000000..cde56b6196 --- /dev/null +++ b/packages/ddp-server-async/livedata_server_tests.js @@ -0,0 +1,411 @@ +var Fiber = Npm.require('fibers'); + + +Tinytest.addAsync( + "livedata server - connectionHandle.onClose()", + function (test, onComplete) { + makeTestConnection( + test, + function (clientConn, serverConn) { + // On the server side, wait for the connection to be closed. + serverConn.onClose(function () { + test.isTrue(true); + // Add a new onClose after the connection is already + // closed. See that it fires. + serverConn.onClose(function () { + onComplete(); + }); + }); + // Close the connection from the client. + clientConn.disconnect(); + }, + onComplete + ); + } +); + +Tinytest.addAsync( + "livedata server - connectionHandle.close()", + function (test, onComplete) { + makeTestConnection( + test, + function (clientConn, serverConn) { + // Wait for the connection to be closed from the server side. + simplePoll( + function () { + return ! clientConn.status().connected; + }, + onComplete, + function () { + test.fail("timeout waiting for the connection to be closed on the server side"); + onComplete(); + } + ); + + // Close the connection from the server. + serverConn.close(); + }, + onComplete + ); + } +); + + +testAsyncMulti( + "livedata server - onConnection doesn't get callback after stop.", + [function (test, expect) { + var afterStop = false; + var expectStop1 = expect(); + var stopHandle1 = Meteor.onConnection(function (conn) { + stopHandle2.stop(); + stopHandle1.stop(); + afterStop = true; + // yield to the event loop for a moment to see that no other calls + // to listener2 are called. + Meteor.setTimeout(expectStop1, 10); + }); + var stopHandle2 = Meteor.onConnection(function (conn) { + test.isFalse(afterStop); + }); + + // trigger a connection + var expectConnection = expect(); + makeTestConnection( + test, + function (clientConn, serverConn) { + // Close the connection from the client. + clientConn.disconnect(); + expectConnection(); + }, + expectConnection + ); + }] +); + +Meteor.methods({ + livedata_server_test_inner: function () { + return this.connection && this.connection.id; + }, + + livedata_server_test_outer: function () { + return Meteor.call('livedata_server_test_inner'); + }, + + livedata_server_test_setuserid: function (userId) { + this.setUserId(userId); + } +}); + + +Tinytest.addAsync( + "livedata server - onMessage hook", + function (test, onComplete) { + + var cb = Meteor.onMessage(function (msg, session) { + test.equal(msg.method, 'livedata_server_test_inner'); + cb.stop(); + onComplete(); + }); + + makeTestConnection( + test, + function (clientConn, serverConn) { + clientConn.call('livedata_server_test_inner'); + clientConn.disconnect(); + }, + onComplete + ); + } +); + + +Tinytest.addAsync( + "livedata server - connection in method invocation", + function (test, onComplete) { + makeTestConnection( + test, + function (clientConn, serverConn) { + var res = clientConn.call('livedata_server_test_inner'); + test.equal(res, serverConn.id); + clientConn.disconnect(); + onComplete(); + }, + onComplete + ); + } +); + + +Tinytest.addAsync( + "livedata server - connection in nested method invocation", + function (test, onComplete) { + makeTestConnection( + test, + function (clientConn, serverConn) { + var res = clientConn.call('livedata_server_test_outer'); + test.equal(res, serverConn.id); + clientConn.disconnect(); + onComplete(); + }, + onComplete + ); + } +); + + +// connectionId -> callback +var onSubscription = {}; + +Meteor.publish("livedata_server_test_sub", function (connectionId) { + var callback = onSubscription[connectionId]; + if (callback) + callback(this); + this.stop(); +}); + +Meteor.publish("livedata_server_test_sub_method", function (connectionId) { + var callback = onSubscription[connectionId]; + if (callback) { + var id = Meteor.call('livedata_server_test_inner'); + callback(id); + } + this.stop(); +}); + +Meteor.publish("livedata_server_test_sub_context", function (connectionId, userId) { + var callback = onSubscription[connectionId]; + var methodInvocation = DDP._CurrentMethodInvocation.get(); + var publicationInvocation = DDP._CurrentPublicationInvocation.get(); + + // Check the publish function's environment variables and context. + if (callback) { + callback.call(this, methodInvocation, publicationInvocation); + } + + // Check that onStop callback is have the same context as the publish function + // and that it runs with the same environment variables as this publish function. + this.onStop(function () { + var onStopMethodInvocation = DDP._CurrentMethodInvocation.get(); + var onStopPublicationInvocation = DDP._CurrentPublicationInvocation.get(); + callback.call(this, onStopMethodInvocation, onStopPublicationInvocation, true); + }); + + if (this.userId) { + this.stop(); + } else { + this.ready(); + Meteor.call('livedata_server_test_setuserid', userId); + } +}); + + +Tinytest.addAsync( + "livedata server - connection in publish function", + function (test, onComplete) { + makeTestConnection( + test, + function (clientConn, serverConn) { + onSubscription[serverConn.id] = function (subscription) { + delete onSubscription[serverConn.id]; + test.equal(subscription.connection.id, serverConn.id); + clientConn.disconnect(); + onComplete(); + }; + clientConn.subscribe("livedata_server_test_sub", serverConn.id); + } + ); + } +); + +Tinytest.addAsync( + "livedata server - connection in method called from publish function", + function (test, onComplete) { + makeTestConnection( + test, + function (clientConn, serverConn) { + onSubscription[serverConn.id] = function (id) { + delete onSubscription[serverConn.id]; + test.equal(id, serverConn.id); + clientConn.disconnect(); + onComplete(); + }; + clientConn.subscribe("livedata_server_test_sub_method", serverConn.id); + } + ); + } +); + +Tinytest.addAsync( + "livedata server - verify context in publish function", + function (test, onComplete) { + makeTestConnection( + test, + function (clientConn, serverConn) { + var userId = 'someUserId'; + onSubscription[serverConn.id] = function (methodInvocation, publicationInvocation, fromOnStop) { + // DDP._CurrentMethodInvocation should be undefined in a publish function + test.isUndefined(methodInvocation, 'Should have been undefined'); + // DDP._CurrentPublicationInvocation should be set in a publish function + test.isNotUndefined(publicationInvocation, 'Should have been defined'); + if (this.userId === userId && fromOnStop) { + delete onSubscription[serverConn.id]; + clientConn.disconnect(); + onComplete(); + } + } + clientConn.subscribe("livedata_server_test_sub_context", serverConn.id, userId); + } + ); + } +); + +let onSubscriptions = {}; + +Meteor.publish({ + publicationObject () { + let callback = onSubscriptions; + if (callback) + callback(); + this.stop(); + } +}); + +Meteor.publish({ + "publication_object": function () { + let callback = onSubscriptions; + if (callback) + callback(); + this.stop(); + } +}); + +Meteor.publish("publication_compatibility", function () { + let callback = onSubscriptions; + if (callback) + callback(); + this.stop(); +}); + +Tinytest.addAsync( + "livedata server - publish object", + function (test, onComplete) { + makeTestConnection( + test, + function (clientConn, serverConn) { + let testsLength = 0; + + onSubscriptions = function (subscription) { + delete onSubscriptions; + clientConn.disconnect(); + testsLength++; + if(testsLength == 3){ + onComplete(); + } + }; + clientConn.subscribe("publicationObject"); + clientConn.subscribe("publication_object"); + clientConn.subscribe("publication_compatibility"); + } + ); + } +); + +Meteor.methods({ + testResolvedPromise(arg) { + const invocation1 = DDP._CurrentMethodInvocation.get(); + return Promise.resolve(arg).then(result => { + const invocation2 = DDP._CurrentMethodInvocation.get(); + // This equality holds because Promise callbacks are bound to the + // dynamic environment where .then was called. + if (invocation1 !== invocation2) { + throw new Meteor.Error("invocation mismatch"); + } + return result + " after waiting"; + }); + }, + + testRejectedPromise(arg) { + return Promise.resolve(arg).then(result => { + throw new Meteor.Error(result + " raised Meteor.Error"); + }); + }, + + testRejectedPromiseWithGenericError(arg) { + return Promise.resolve(arg).then(result => { + const error = new Error('MESSAGE'); + error.error = 'ERROR'; + error.reason = 'REASON'; + error.details = { foo: 'bar' }; + error.isClientSafe = true; + throw error; + }); + } +}); + +Tinytest.addAsync( + "livedata server - waiting for Promise", + (test, onComplete) => makeTestConnection(test, (clientConn, serverConn) => { + test.equal( + clientConn.call("testResolvedPromise", "clientConn.call"), + "clientConn.call after waiting" + ); + + const clientCallPromise = new Promise( + (resolve, reject) => clientConn.call( + "testResolvedPromise", + "clientConn.call with callback", + (error, result) => error ? reject(error) : resolve(result) + ) + ); + + const serverCallAsyncPromise = Meteor.server.callAsync( + "testResolvedPromise", + "Meteor.server.callAsync" + ); + + const serverApplyAsyncPromise = Meteor.server.applyAsync( + "testResolvedPromise", + ["Meteor.server.applyAsync"] + ); + + const clientCallRejectedPromise = new Promise(resolve => { + clientConn.call( + "testRejectedPromise", + "with callback", + (error, result) => resolve(error.message) + ); + }); + + const clientCallRejectedPromiseWithGenericError = new Promise(resolve => { + clientConn.call( + "testRejectedPromiseWithGenericError", + (error, result) => resolve({ + message: error.message, + error: error.error, + reason: error.reason, + details: error.details, + }) + ); + }); + + Promise.all([ + clientCallPromise, + clientCallRejectedPromise, + clientCallRejectedPromiseWithGenericError, + serverCallAsyncPromise, + serverApplyAsyncPromise + ]).then(results => test.equal(results, [ + "clientConn.call with callback after waiting", + "[with callback raised Meteor.Error]", + { + message: 'REASON [ERROR]', + error: 'ERROR', + reason: 'REASON', + details: { foo: 'bar' }, + }, + "Meteor.server.callAsync after waiting", + "Meteor.server.applyAsync after waiting" + ]), error => test.fail(error)) + .then(onComplete); + }) +); diff --git a/packages/ddp-server-async/package.js b/packages/ddp-server-async/package.js new file mode 100644 index 0000000000..da413690b0 --- /dev/null +++ b/packages/ddp-server-async/package.js @@ -0,0 +1,61 @@ +Package.describe({ + summary: "Meteor's latency-compensated distributed data server", + version: '2.6.0', + documentation: null +}); + +Npm.depends({ + "permessage-deflate": "0.1.7", + sockjs: "0.3.21" +}); + +Package.onUse(function (api) { + api.use(['check', 'random', 'ejson', 'underscore', + 'retry', 'mongo-id', 'diff-sequence', 'ecmascript'], + 'server'); + + // common functionality + api.use('ddp-common', 'server'); // heartbeat + api.use('ddp-rate-limiter', 'server', {weak: true}); + // Transport + api.use('ddp-client', 'server'); + api.imply('ddp-client'); + + api.use(['webapp', 'routepolicy'], 'server'); + + // Detect whether or not the user wants us to audit argument checks. + api.use(['audit-argument-checks'], 'server', {weak: true}); + + // Allow us to detect 'autopublish', so we can print a warning if the user + // runs Meteor.publish while it's loaded. + api.use('autopublish', 'server', {weak: true}); + + // If the facts package is loaded, publish some statistics. + api.use('facts-base', 'server', {weak: true}); + + api.use('callback-hook', 'server'); + api.export('DDPServer', 'server'); + + api.addFiles('stream_server.js', 'server'); + + api.addFiles('livedata_server.js', 'server'); + api.addFiles('writefence.js', 'server'); + api.addFiles('crossbar.js', 'server'); + + api.addFiles('server_convenience.js', 'server'); +}); + + + +Package.onTest(function (api) { + api.use('ecmascript', ['client', 'server']); + api.use('livedata', ['client', 'server']); + api.use('mongo', ['client', 'server']); + api.use('test-helpers', ['client', 'server']); + api.use(['underscore', 'tinytest', 'random', 'tracker', 'minimongo', 'reactive-var']); + + api.addFiles('livedata_server_tests.js', 'server'); + api.addFiles('livedata_server_async_tests.js', 'server'); + api.addFiles('session_view_tests.js', ['server']); + api.addFiles('crossbar_tests.js', ['server']); +}); diff --git a/packages/ddp-server-async/server_convenience.js b/packages/ddp-server-async/server_convenience.js new file mode 100755 index 0000000000..063bbe6385 --- /dev/null +++ b/packages/ddp-server-async/server_convenience.js @@ -0,0 +1,28 @@ +if (process.env.DDP_DEFAULT_CONNECTION_URL) { + __meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL = + process.env.DDP_DEFAULT_CONNECTION_URL; +} + +Meteor.server = new Server; + +Meteor.refresh = function (notification) { + DDPServer._InvalidationCrossbar.fire(notification); +}; + +// Proxy the public methods of Meteor.server so they can +// be called directly on Meteor. +_.each( + [ + 'publish', + 'methods', + 'call', + 'callAsync', + 'apply', + 'applyAsync', + 'onConnection', + 'onMessage', + ], + function(name) { + Meteor[name] = _.bind(Meteor.server[name], Meteor.server); + } +); diff --git a/packages/ddp-server-async/session_view_tests.js b/packages/ddp-server-async/session_view_tests.js new file mode 100644 index 0000000000..0459a45107 --- /dev/null +++ b/packages/ddp-server-async/session_view_tests.js @@ -0,0 +1,393 @@ +var newView = function(test) { + var results = []; + var view = new DDPServer._SessionCollectionView('test', { + added: function (collection, id, fields) { + results.push({fun: 'added', id: id, fields: fields}); + }, + changed: function (collection, id, changed) { + if (_.isEmpty(changed)) + return; + results.push({fun: 'changed', id: id, changed: changed}); + }, + removed: function (collection, id) { + results.push({fun: 'removed', id: id}); + } + }); + var v = { + view: view, + results: results + }; + _.each(["added", "changed", "removed"], function (it) { + v[it] = _.bind(view[it], view); + }); + v.expectResult = function (result) { + test.equal(results.shift(), result); + }; + v.expectNoResult = function () { + test.equal(results, []); + }; + v.drain = function() { + var ret = results; + results = []; + return ret; + }; + return v; +}; + +Tinytest.add('livedata - sessionview - exists reveal', function (test) { + var v = newView(test); + + v.added("A", "A1", {}); + v.expectResult({fun: 'added', id: "A1", fields: {}}); + v.expectNoResult(); + + v.added("B", "A1", {}); + v.expectNoResult(); + + v.removed("A", "A1"); + v.expectNoResult(); + + v.removed("B", "A1"); + v.expectResult({fun: 'removed', id: "A1"}); + v.expectNoResult(); +}); + +Tinytest.add('livedata - sessionview - added a second field in another sub', function (test) { + var v = newView(test); + + v.added("A", "A1", {a: "foo"}); + v.expectResult({fun: 'added', id: "A1", fields: {a: "foo"}}); + v.expectNoResult(); + + v.added("B", "A1", {a: "foo", b: "bar"}); + v.expectResult({fun: 'changed', 'id': "A1", changed: {b: "bar"}}); + + v.removed("A", "A1"); + v.expectNoResult(); + + v.removed("B", "A1"); + v.expectResult({fun: 'removed', id: "A1"}); + v.expectNoResult(); +}); + + +Tinytest.add('livedata - sessionview - field reveal', function (test) { + var v = newView(test); + + v.added("A", "A1", {foo: "bar"}); + v.expectResult({fun: 'added', id: "A1", fields: {foo: "bar"}}); + v.expectNoResult(); + + v.added("B", "A1", {foo: "baz"}); + v.removed("A", "A1"); + v.expectResult({fun: 'changed', id: "A1", changed: {foo: "baz"}}); + v.expectNoResult(); + // Somewhere in here we must have changed foo to baz. Legal either on the + // added or on the removed, but only once. + + v.removed("B", "A1"); + v.expectResult({fun: 'removed', id: "A1"}); + v.expectNoResult(); +}); + +Tinytest.add('livedata - sessionview - field change', function (test) { + var v = newView(test); + + v.added("A", "A1", {foo: "bar"}); + v.expectResult({fun: 'added', id: "A1", fields: {foo: "bar"}}); + v.expectNoResult(); + + v.changed("A", "A1", {foo: "baz"}, []); + v.expectResult({fun: 'changed', id: "A1", changed: {foo: "baz"}}); + v.expectNoResult(); + + v.removed("A", "A1"); + v.expectResult({fun: 'removed', id: "A1"}); + v.expectNoResult(); +}); + +Tinytest.add('livedata - sessionview - field clear', function (test) { + var v = newView(test); + + v.added("A", "A1", {foo: "bar"}); + v.expectResult({fun: 'added', id: "A1", fields: {foo: "bar"}}); + v.expectNoResult(); + + v.changed("A", "A1", {foo: undefined}); + v.expectResult({fun: 'changed', id: "A1", changed: {foo: undefined}}); + v.expectNoResult(); + + v.removed("A", "A1"); + v.expectResult({fun: 'removed', id: "A1"}); + v.expectNoResult(); +}); + +Tinytest.add('livedata - sessionview - change makes a new field', function (test) { + var v = newView(test); + + v.added("A", "A1", {foo: "bar"}); + v.expectResult({fun: 'added', id: "A1", fields: {foo: "bar"}}); + v.expectNoResult(); + + v.changed("A", "A1", {baz:"quux"}); + v.expectResult({fun: 'changed', id: "A1", changed: {baz: "quux"}}); + v.expectNoResult(); +}); + +Tinytest.add('livedata - sessionview - add, remove, add', function (test) { + var v = newView(test); + + v.added("A", "A1", {foo: "bar"}); + v.expectResult({fun: 'added', id: "A1", fields: {foo: "bar"}}); + v.expectNoResult(); + + v.removed("A", "A1"); + v.expectResult({fun: 'removed', id: "A1"}); + v.expectNoResult(); + + v.added("A", "A1", {foo: "bar"}); + v.expectResult({fun: 'added', id: "A1", fields: {foo: "bar"}}); + v.expectNoResult(); + +}); + +Tinytest.add('livedata - sessionview - field clear reveal', function (test) { + var v = newView(test); + + v.added("A", "A1", {foo: "bar"}); + v.expectResult({fun: 'added', id: "A1", fields: {foo: "bar"}}); + v.expectNoResult(); + + + v.added("B", "A1", {foo: "baz"}); + v.changed("A", "A1", {foo: undefined}); + v.expectResult({fun: 'changed', id: "A1", changed: {foo: "baz"}}); + v.expectNoResult(); + + v.removed("A", "A1"); + v.expectNoResult(); + v.removed("B", "A1"); + v.expectResult({fun: 'removed', id: "A1"}); + v.expectNoResult(); +}); + +Tinytest.add('livedata - sessionview - change to canonical value produces no change', function (test) { + var v = newView(test); + + v.added("A", "A1", {foo: "bar"}); + v.expectResult({fun: 'added', id: "A1", fields: {foo: "bar"}}); + v.expectNoResult(); + + + v.added("B", "A1", {foo: "baz"}); + var canon = "bar"; + var maybeResults = v.drain(); + if (!_.isEmpty(maybeResults)) { + // if something happened, it was a change message to baz. + // if nothing did, canon is still bar. + test.length(maybeResults, 1); + test.equal(maybeResults[0], {fun: 'changed', id: "A1", changed: {foo: "baz"}}); + canon = "baz"; + } + v.changed("B", "A1", {foo: canon}); + v.expectNoResult(); + + v.removed("A", "A1"); + v.expectNoResult(); + v.removed("B", "A1"); + v.expectResult({fun: 'removed', id: "A1"}); + v.expectNoResult(); +}); + +Tinytest.add('livedata - sessionview - new field of canonical value produces no change', function (test) { + var v = newView(test); + + v.added("A", "A1", {foo: "bar"}); + v.expectResult({fun: 'added', id: "A1", fields: {foo: "bar"}}); + v.expectNoResult(); + + + v.added("B", "A1", {}); + + v.changed("B", "A1", {foo: "bar"}); + v.expectNoResult(); + + v.removed("A", "A1"); + v.expectNoResult(); + v.removed("B", "A1"); + v.expectResult({fun: 'removed', id: "A1"}); + v.expectNoResult(); +}); + +Tinytest.add('livedata - sessionview - clear all clears only once', function (test) { + var v = newView(test); + + v.added("A", "A1", {foo: "bar"}); + v.expectResult({fun: 'added', id: "A1", fields: {foo: "bar"}}); + v.expectNoResult(); + + v.added("B", "A1", {foo: "bar"}); + v.added("C", "A1", {foo: "bar"}); + v.changed("A", "A1", {foo: undefined}); + v.changed("B", "A1", {foo: undefined}); + v.changed("C", "A1", {foo: undefined}); + v.expectResult({fun: 'changed', id: "A1", changed: {foo: undefined}}); + v.expectNoResult(); + + v.removed("A", "A1"); + v.expectNoResult(); + v.removed("B", "A1"); + v.expectNoResult(); +}); + +Tinytest.add('livedata - sessionview - change all changes only once', function (test) { + var v = newView(test); + + v.added("A", "A1", {foo: "bar"}); + v.expectResult({fun: 'added', id: "A1", fields: {foo: "bar"}}); + v.expectNoResult(); + + v.added("B", "A1", {foo: "bar"}); + v.added("C", "A1", {foo: "bar"}); + v.changed("B", "A1", {foo: "baz"}); + v.changed("A", "A1", {foo: "baz"}); + v.changed("C", "A1", {foo: "baz"}); + v.expectResult({fun: 'changed', id: "A1", changed: {foo: "baz"}}); + v.expectNoResult(); + + v.removed("A", "A1"); + v.expectNoResult(); + v.removed("B", "A1"); + v.expectNoResult(); +}); + +Tinytest.add('livedata - sessionview - multiple operations at once in a change', function (test) { + var v = newView(test); + + v.added("A", "A1", {foo: "bar", baz: "quux"}); + v.expectResult({fun: 'added', id: "A1", fields: {foo: "bar", baz: "quux"}}); + v.expectNoResult(); + + + v.added("B", "A1", {foo: "baz"}); + v.changed("A", "A1", {thing: "stuff", foo: undefined, baz: undefined}); + v.expectResult({fun: 'changed', id: "A1", changed: {foo: "baz", thing: "stuff", baz: undefined}}); + v.expectNoResult(); + + v.removed("A", "A1"); + v.expectResult({fun: 'changed', id: "A1", changed: {thing: undefined}}); + v.expectNoResult(); + v.removed("B", "A1"); + v.expectResult({fun: 'removed', id: "A1"}); + v.expectNoResult(); +}); + +Tinytest.add('livedata - sessionview - more than one document', function (test) { + var v = newView(test); + + v.added("A", "A1", {foo: "bar", baz: "quux"}); + v.expectResult({fun: 'added', id: "A1", fields: {foo: "bar", baz: "quux"}}); + v.expectNoResult(); + + + v.added("A", "A2", {foo: "baz"}); + v.expectResult({fun: 'added', id: "A2", fields: {foo: "baz"}}); + v.changed("A", "A1", {thing: "stuff", foo: undefined, baz: undefined}); + v.expectResult({fun: 'changed', id: "A1", changed: {thing: "stuff", foo: undefined, baz: undefined}}); + v.expectNoResult(); + + v.removed("A", "A1"); + v.expectResult({fun: 'removed', id: "A1"}); + v.expectNoResult(); + v.removed("A", "A2"); + v.expectResult({fun: 'removed', id: "A2"}); + v.expectNoResult(); + +}); + +Tinytest.add('livedata - sessionview - multiple docs removed', function (test) { + var v = newView(test); + + v.added("A", "A1", {foo: "bar", baz: "quux"}); + v.expectResult({fun: 'added', id: "A1", fields: {foo: "bar", baz: "quux"}}); + v.expectNoResult(); + + + v.added("A", "A2", {foo: "baz"}); + v.expectResult({fun: 'added', id: "A2", fields: {foo: "baz"}}); + v.expectNoResult(); + + v.removed("A", "A1"); + v.expectResult({fun: 'removed', id: "A1"}); + v.removed("A", "A2"); + v.expectResult({fun: 'removed', id: "A2"}); + v.expectNoResult(); +}); + + +Tinytest.add('livedata - sessionview - complicated sequence', function (test) { + var v = newView(test); + + v.added("A", "A1", {foo: "bar", baz: "quux"}); + v.expectResult({fun: 'added', id: "A1", fields: {foo: "bar", baz: "quux"}}); + v.expectNoResult(); + + v.added("A", "A2", {foo: "eats"}); + v.expectResult({fun: 'added', id: "A2", fields: {foo: "eats"}}); + + v.added("B", "A1", {foo: "baz"}); + v.changed("A", "A1", {thing: "stuff", foo: undefined, baz: undefined}); + v.expectResult({fun: 'changed', id: "A1", changed: {foo: "baz", thing: "stuff", baz: undefined}}); + v.expectNoResult(); + + v.removed("A", "A1"); + v.removed("A", "A2"); + v.expectResult({fun: 'changed', id: "A1", changed: {thing: undefined}}); + v.expectResult({fun: 'removed', id: "A2"}); + v.expectNoResult(); + v.removed("B", "A1"); + v.expectResult({fun: 'removed', id: "A1"}); + v.expectNoResult(); +}); + +Tinytest.add('livedata - sessionview - added becomes changed', function (test) { + var v = newView(test); + + v.added('A', "A1", {foo: 'bar'}); + v.expectResult({fun: 'added', id: "A1", fields: {foo: 'bar'}}); + + v.added('B', "A1", {hi: 'there'}); + v.expectResult({fun: 'changed', id: 'A1', changed: {hi: 'there'}}); + + v.removed('A', 'A1'); + v.expectResult({fun: 'changed', id: 'A1', changed: {foo: undefined}}); + + v.removed('B', 'A1'); + v.expectResult({fun: 'removed', id: 'A1'}); +}); + +Tinytest.add('livedata - sessionview - weird key names', function (test) { + var v = newView(test); + + v.added('A', "A1", {}); + v.expectResult({fun: 'added', id: "A1", fields: {}}); + + v.changed('A', "A1", {constructor: 'bla'}); + v.expectResult({fun: 'changed', id: 'A1', changed: {constructor: 'bla'}}); +}); + +Tinytest.add('livedata - sessionview - clear undefined value', function (test) { + var v = newView(test); + + v.added("A", "A1", {field: "value"}); + v.expectResult({fun: 'added', id: "A1", fields: {field: "value"}}); + v.expectNoResult(); + + v.changed("A", "A1", {field: undefined}); + v.expectResult({fun: 'changed', id: 'A1', changed: {field: undefined}}); + v.expectNoResult(); + + v.changed("A", "A1", {field: undefined}); + v.expectNoResult(); + +}); diff --git a/packages/ddp-server-async/stream_server.js b/packages/ddp-server-async/stream_server.js new file mode 100644 index 0000000000..49c0f1385d --- /dev/null +++ b/packages/ddp-server-async/stream_server.js @@ -0,0 +1,190 @@ +// By default, we use the permessage-deflate extension with default +// configuration. If $SERVER_WEBSOCKET_COMPRESSION is set, then it must be valid +// JSON. If it represents a falsey value, then we do not use permessage-deflate +// at all; otherwise, the JSON value is used as an argument to deflate's +// configure method; see +// https://github.com/faye/permessage-deflate-node/blob/master/README.md +// +// (We do this in an _.once instead of at startup, because we don't want to +// crash the tool during isopacket load if your JSON doesn't parse. This is only +// a problem because the tool has to load the DDP server code just in order to +// be a DDP client; see https://github.com/meteor/meteor/issues/3452 .) +var websocketExtensions = _.once(function () { + var extensions = []; + + var websocketCompressionConfig = process.env.SERVER_WEBSOCKET_COMPRESSION + ? JSON.parse(process.env.SERVER_WEBSOCKET_COMPRESSION) : {}; + if (websocketCompressionConfig) { + extensions.push(Npm.require('permessage-deflate').configure( + websocketCompressionConfig + )); + } + + return extensions; +}); + +var pathPrefix = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || ""; + +StreamServer = function () { + var self = this; + self.registration_callbacks = []; + self.open_sockets = []; + + // Because we are installing directly onto WebApp.httpServer instead of using + // WebApp.app, we have to process the path prefix ourselves. + self.prefix = pathPrefix + '/sockjs'; + RoutePolicy.declare(self.prefix + '/', 'network'); + + // set up sockjs + var sockjs = Npm.require('sockjs'); + var serverOptions = { + prefix: self.prefix, + log: function() {}, + // this is the default, but we code it explicitly because we depend + // on it in stream_client:HEARTBEAT_TIMEOUT + heartbeat_delay: 45000, + // The default disconnect_delay is 5 seconds, but if the server ends up CPU + // bound for that much time, SockJS might not notice that the user has + // reconnected because the timer (of disconnect_delay ms) can fire before + // SockJS processes the new connection. Eventually we'll fix this by not + // combining CPU-heavy processing with SockJS termination (eg a proxy which + // converts to Unix sockets) but for now, raise the delay. + disconnect_delay: 60 * 1000, + // Set the USE_JSESSIONID environment variable to enable setting the + // JSESSIONID cookie. This is useful for setting up proxies with + // session affinity. + jsessionid: !!process.env.USE_JSESSIONID + }; + + // If you know your server environment (eg, proxies) will prevent websockets + // from ever working, set $DISABLE_WEBSOCKETS and SockJS clients (ie, + // browsers) will not waste time attempting to use them. + // (Your server will still have a /websocket endpoint.) + if (process.env.DISABLE_WEBSOCKETS) { + serverOptions.websocket = false; + } else { + serverOptions.faye_server_options = { + extensions: websocketExtensions() + }; + } + + self.server = sockjs.createServer(serverOptions); + + // Install the sockjs handlers, but we want to keep around our own particular + // request handler that adjusts idle timeouts while we have an outstanding + // request. This compensates for the fact that sockjs removes all listeners + // for "request" to add its own. + WebApp.httpServer.removeListener( + 'request', WebApp._timeoutAdjustmentRequestCallback); + self.server.installHandlers(WebApp.httpServer); + WebApp.httpServer.addListener( + 'request', WebApp._timeoutAdjustmentRequestCallback); + + // Support the /websocket endpoint + self._redirectWebsocketEndpoint(); + + self.server.on('connection', function (socket) { + // sockjs sometimes passes us null instead of a socket object + // so we need to guard against that. see: + // https://github.com/sockjs/sockjs-node/issues/121 + // https://github.com/meteor/meteor/issues/10468 + if (!socket) return; + + // We want to make sure that if a client connects to us and does the initial + // Websocket handshake but never gets to the DDP handshake, that we + // eventually kill the socket. Once the DDP handshake happens, DDP + // heartbeating will work. And before the Websocket handshake, the timeouts + // we set at the server level in webapp_server.js will work. But + // faye-websocket calls setTimeout(0) on any socket it takes over, so there + // is an "in between" state where this doesn't happen. We work around this + // by explicitly setting the socket timeout to a relatively large time here, + // and setting it back to zero when we set up the heartbeat in + // livedata_server.js. + socket.setWebsocketTimeout = function (timeout) { + if ((socket.protocol === 'websocket' || + socket.protocol === 'websocket-raw') + && socket._session.recv) { + socket._session.recv.connection.setTimeout(timeout); + } + }; + socket.setWebsocketTimeout(45 * 1000); + + socket.send = function (data) { + socket.write(data); + }; + socket.on('close', function () { + self.open_sockets = _.without(self.open_sockets, socket); + }); + self.open_sockets.push(socket); + + // only to send a message after connection on tests, useful for + // socket-stream-client/server-tests.js + if (process.env.TEST_METADATA && process.env.TEST_METADATA !== "{}") { + socket.send(JSON.stringify({ testMessageOnConnect: true })); + } + + // call all our callbacks when we get a new socket. they will do the + // work of setting up handlers and such for specific messages. + _.each(self.registration_callbacks, function (callback) { + callback(socket); + }); + }); + +}; + +Object.assign(StreamServer.prototype, { + // call my callback when a new socket connects. + // also call it for all current connections. + register: function (callback) { + var self = this; + self.registration_callbacks.push(callback); + _.each(self.all_sockets(), function (socket) { + callback(socket); + }); + }, + + // get a list of all sockets + all_sockets: function () { + var self = this; + return _.values(self.open_sockets); + }, + + // Redirect /websocket to /sockjs/websocket in order to not expose + // sockjs to clients that want to use raw websockets + _redirectWebsocketEndpoint: function() { + var self = this; + // Unfortunately we can't use a connect middleware here since + // sockjs installs itself prior to all existing listeners + // (meaning prior to any connect middlewares) so we need to take + // an approach similar to overshadowListeners in + // https://github.com/sockjs/sockjs-node/blob/cf820c55af6a9953e16558555a31decea554f70e/src/utils.coffee + ['request', 'upgrade'].forEach((event) => { + var httpServer = WebApp.httpServer; + var oldHttpServerListeners = httpServer.listeners(event).slice(0); + httpServer.removeAllListeners(event); + + // request and upgrade have different arguments passed but + // we only care about the first one which is always request + var newListener = function(request /*, moreArguments */) { + // Store arguments for use within the closure below + var args = arguments; + + // TODO replace with url package + var url = Npm.require('url'); + + // Rewrite /websocket and /websocket/ urls to /sockjs/websocket while + // preserving query string. + var parsedUrl = url.parse(request.url); + if (parsedUrl.pathname === pathPrefix + '/websocket' || + parsedUrl.pathname === pathPrefix + '/websocket/') { + parsedUrl.pathname = self.prefix + '/websocket'; + request.url = url.format(parsedUrl); + } + _.each(oldHttpServerListeners, function(oldListener) { + oldListener.apply(httpServer, args); + }); + }; + httpServer.addListener(event, newListener); + }); + } +}); diff --git a/packages/ddp-server-async/writefence.js b/packages/ddp-server-async/writefence.js new file mode 100644 index 0000000000..d85f028ff8 --- /dev/null +++ b/packages/ddp-server-async/writefence.js @@ -0,0 +1,125 @@ +// A write fence collects a group of writes, and provides a callback +// when all of the writes are fully committed and propagated (all +// observers have been notified of the write and acknowledged it.) +// +DDPServer._WriteFence = class { + constructor() { + this.armed = false; + this.fired = false; + this.retired = false; + this.outstanding_writes = 0; + this.before_fire_callbacks = []; + this.completion_callbacks = []; + } + + // Start tracking a write, and return an object to represent it. The + // object has a single method, committed(). This method should be + // called when the write is fully committed and propagated. You can + // continue to add writes to the WriteFence up until it is triggered + // (calls its callbacks because all writes have committed.) + beginWrite() { + if (this.retired) + return { committed: function () {} }; + + if (this.fired) + throw new Error("fence has already activated -- too late to add writes"); + + this.outstanding_writes++; + let committed = false; + const _committedFn = async () => { + if (committed) + throw new Error("committed called twice on the same write"); + committed = true; + this.outstanding_writes--; + await this._maybeFire(); + }; + + const self = this; + return { + committed: Meteor._isFibersEnabled ? () => Promise.await(_committedFn.apply(self)) : _committedFn, + }; + } + + // Arm the fence. Once the fence is armed, and there are no more + // uncommitted writes, it will activate. + arm() { + if (this === DDPServer._CurrentWriteFence.get()) + throw Error("Can't arm the current fence"); + this.armed = true; + return Meteor._isFibersEnabled ? Promise.await(this._maybeFire()) : this._maybeFire(); + } + + // Register a function to be called once before firing the fence. + // Callback function can add new writes to the fence, in which case + // it won't fire until those writes are done as well. + onBeforeFire(func) { + if (this.fired) + throw new Error("fence has already activated -- too late to " + + "add a callback"); + this.before_fire_callbacks.push(func); + } + + // Register a function to be called when the fence fires. + onAllCommitted(func) { + if (this.fired) + throw new Error("fence has already activated -- too late to " + + "add a callback"); + this.completion_callbacks.push(func); + } + + _armAndWait() { + let resolver; + const returnValue = new Promise(r => resolver = r); + this.onAllCommitted(resolver); + this.arm(); + + return returnValue; + } + // Convenience function. Arms the fence, then blocks until it fires. + armAndWait() { + return Meteor._isFibersEnabled ? Promise.await(this._armAndWait()) : this._armAndWait(); + } + + async _maybeFire() { + if (this.fired) + throw new Error("write fence already activated?"); + if (this.armed && !this.outstanding_writes) { + const invokeCallback = async (func) => { + try { + await func(this); + } catch (err) { + Meteor._debug("exception in write fence callback:", err); + } + }; + + this.outstanding_writes++; + while (this.before_fire_callbacks.length > 0) { + const cb = this.before_fire_callbacks.shift(); + await invokeCallback(cb); + } + this.outstanding_writes--; + + if (!this.outstanding_writes) { + this.fired = true; + while (this.completion_callbacks.length > 0) { + const cb = this.completion_callbacks.shift(); + await invokeCallback(cb); + } + } + } + } + + // Deactivate this fence so that adding more writes has no effect. + // The fence must have already fired. + retire() { + if (!this.fired) + throw new Error("Can't retire a fence that hasn't fired."); + this.retired = true; + } +}; + +// The current write fence. When there is a current write fence, code +// that writes to databases should register their writes with it using +// beginWrite(). +// +DDPServer._CurrentWriteFence = new Meteor.EnvironmentVariable; diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index fcdbc29003..3b4853c39e 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -1,5 +1,7 @@ DDPServer = {}; +var Fiber = Npm.require('fibers'); + // Publication strategies define how we handle data from published cursors at the collection level // This allows someone to: // - Choose a trade-off between client-server bandwidth and server memory usage @@ -328,9 +330,9 @@ var Session = function (server, version, socket, options) { self.send({ msg: 'connected', session: self.id }); // On initial connect, spin up all the universal publishers. - Meteor._runAsync(function() { + Fiber(function () { self.startUniversalSubs(); - }); + }).run(); if (version !== 'pre1' && options.heartbeatInterval !== 0) { // We no longer need the low level timeout because we have heartbeats. @@ -555,10 +557,10 @@ Object.assign(Session.prototype, { // Any message counts as receiving a pong, as it demonstrates that // the client is still alive. if (self.heartbeat) { - Meteor._runAsync(function() { + Fiber(function () { self.heartbeat.messageReceived(); - }); - }; + }).run(); + } if (self.version !== 'pre1' && msg_in.msg === 'ping') { if (self._respondToPings) @@ -582,7 +584,7 @@ Object.assign(Session.prototype, { return; } - function runHandlers() { + Fiber(function () { var blocked = true; var unblock = function () { @@ -602,9 +604,7 @@ Object.assign(Session.prototype, { else self.sendError('Bad request', msg); unblock(); // in case the handler didn't already do it - } - - Meteor._runAsync(runHandlers); + }).run(); }; processNext(); @@ -1177,21 +1177,15 @@ Object.assign(Subscription.prototype, { return c && c._publishCursor; }; if (isCursor(res)) { - if (Meteor._isFibersEnabled) { - try { - res._publishCursor(self); - } catch (e) { - self.error(e); - return; - } - // _publishCursor only returns after the initial added callbacks have run. - // mark subscription as ready. - self.ready(); - } else { - res._publishCursor(self).then(() => { - self.ready(); - }).catch((e) => self.error(e)); + try { + res._publishCursor(self); + } catch (e) { + self.error(e); + return; } + // _publishCursor only returns after the initial added callbacks have run. + // mark subscription as ready. + self.ready(); } else if (_.isArray(res)) { // Check all the elements are cursors if (! _.all(res, isCursor)) { @@ -1213,21 +1207,15 @@ Object.assign(Subscription.prototype, { collectionNames[collectionName] = true; }; - if (Meteor._isFibersEnabled) { - try { - _.each(res, function (cur) { - cur._publishCursor(self); - }); - } catch (e) { - self.error(e); - return; - } - self.ready(); - } else { - Promise.all(res.map((c) => c._publishCursor(self))).then(() => { - self.ready(); - }).catch((e) => self.error(e)); + try { + _.each(res, function (cur) { + cur._publishCursor(self); + }); + } catch (e) { + self.error(e); + return; } + self.ready(); } else if (res) { // Truthy values other than cursors or arrays are probably a // user mistake (possible returning a Mongo document via, say, @@ -1504,11 +1492,9 @@ Server = function (options = {}) { sendError("Already connected", msg); return; } - - Meteor._runAsync(function() { + Fiber(function () { self._handleConnect(socket, msg); - }) - + }).run(); return; } @@ -1525,9 +1511,9 @@ Server = function (options = {}) { socket.on('close', function () { if (socket._meteorSession) { - Meteor._runAsync(function() { + Fiber(function () { socket._meteorSession.close(); - }); + }).run(); } }); }); @@ -1705,9 +1691,9 @@ Object.assign(Server.prototype, { // self.sessions to change while we're running this loop. self.sessions.forEach(function (session) { if (!session._dontStartNewUniversalSubs) { - Meteor._runAsync(function() { + Fiber(function() { session._startSubscription(handler); - }); + }).run(); } }); } diff --git a/packages/ddp-server/package.js b/packages/ddp-server/package.js index af3d53f069..4077518df0 100644 --- a/packages/ddp-server/package.js +++ b/packages/ddp-server/package.js @@ -10,6 +10,11 @@ Npm.depends({ }); Package.onUse(function (api) { + if (process.env.DISABLE_FIBERS) { + api.use('ddp-server-async', 'server'); + api.export('DDPServer', 'server'); + return; + } api.use(['check', 'random', 'ejson', 'underscore', 'retry', 'mongo-id', 'diff-sequence', 'ecmascript'], 'server'); @@ -50,11 +55,7 @@ Package.onUse(function (api) { Package.onTest(function (api) { api.use('ecmascript', ['client', 'server']); api.use('livedata', ['client', 'server']); - if (!process.env.DISABLE_FIBERS) { - api.use('mongo', ['client', 'server']); - } else { - api.use('mongo-async', ['client', 'server']); - } + api.use('mongo', ['client', 'server']); api.use('test-helpers', ['client', 'server']); api.use(['underscore', 'tinytest', 'random', 'tracker', 'minimongo', 'reactive-var']); diff --git a/packages/ddp-server/writefence.js b/packages/ddp-server/writefence.js index d85f028ff8..e9310c9f7f 100644 --- a/packages/ddp-server/writefence.js +++ b/packages/ddp-server/writefence.js @@ -1,121 +1,18 @@ +var Future = Npm.require('fibers/future'); + // A write fence collects a group of writes, and provides a callback // when all of the writes are fully committed and propagated (all // observers have been notified of the write and acknowledged it.) // -DDPServer._WriteFence = class { - constructor() { - this.armed = false; - this.fired = false; - this.retired = false; - this.outstanding_writes = 0; - this.before_fire_callbacks = []; - this.completion_callbacks = []; - } +DDPServer._WriteFence = function () { + var self = this; - // Start tracking a write, and return an object to represent it. The - // object has a single method, committed(). This method should be - // called when the write is fully committed and propagated. You can - // continue to add writes to the WriteFence up until it is triggered - // (calls its callbacks because all writes have committed.) - beginWrite() { - if (this.retired) - return { committed: function () {} }; - - if (this.fired) - throw new Error("fence has already activated -- too late to add writes"); - - this.outstanding_writes++; - let committed = false; - const _committedFn = async () => { - if (committed) - throw new Error("committed called twice on the same write"); - committed = true; - this.outstanding_writes--; - await this._maybeFire(); - }; - - const self = this; - return { - committed: Meteor._isFibersEnabled ? () => Promise.await(_committedFn.apply(self)) : _committedFn, - }; - } - - // Arm the fence. Once the fence is armed, and there are no more - // uncommitted writes, it will activate. - arm() { - if (this === DDPServer._CurrentWriteFence.get()) - throw Error("Can't arm the current fence"); - this.armed = true; - return Meteor._isFibersEnabled ? Promise.await(this._maybeFire()) : this._maybeFire(); - } - - // Register a function to be called once before firing the fence. - // Callback function can add new writes to the fence, in which case - // it won't fire until those writes are done as well. - onBeforeFire(func) { - if (this.fired) - throw new Error("fence has already activated -- too late to " + - "add a callback"); - this.before_fire_callbacks.push(func); - } - - // Register a function to be called when the fence fires. - onAllCommitted(func) { - if (this.fired) - throw new Error("fence has already activated -- too late to " + - "add a callback"); - this.completion_callbacks.push(func); - } - - _armAndWait() { - let resolver; - const returnValue = new Promise(r => resolver = r); - this.onAllCommitted(resolver); - this.arm(); - - return returnValue; - } - // Convenience function. Arms the fence, then blocks until it fires. - armAndWait() { - return Meteor._isFibersEnabled ? Promise.await(this._armAndWait()) : this._armAndWait(); - } - - async _maybeFire() { - if (this.fired) - throw new Error("write fence already activated?"); - if (this.armed && !this.outstanding_writes) { - const invokeCallback = async (func) => { - try { - await func(this); - } catch (err) { - Meteor._debug("exception in write fence callback:", err); - } - }; - - this.outstanding_writes++; - while (this.before_fire_callbacks.length > 0) { - const cb = this.before_fire_callbacks.shift(); - await invokeCallback(cb); - } - this.outstanding_writes--; - - if (!this.outstanding_writes) { - this.fired = true; - while (this.completion_callbacks.length > 0) { - const cb = this.completion_callbacks.shift(); - await invokeCallback(cb); - } - } - } - } - - // Deactivate this fence so that adding more writes has no effect. - // The fence must have already fired. - retire() { - if (!this.fired) - throw new Error("Can't retire a fence that hasn't fired."); - this.retired = true; - } + self.armed = false; + self.fired = false; + self.retired = false; + self.outstanding_writes = 0; + self.before_fire_callbacks = []; + self.completion_callbacks = []; }; // The current write fence. When there is a current write fence, code @@ -123,3 +20,112 @@ DDPServer._WriteFence = class { // beginWrite(). // DDPServer._CurrentWriteFence = new Meteor.EnvironmentVariable; + +_.extend(DDPServer._WriteFence.prototype, { + // Start tracking a write, and return an object to represent it. The + // object has a single method, committed(). This method should be + // called when the write is fully committed and propagated. You can + // continue to add writes to the WriteFence up until it is triggered + // (calls its callbacks because all writes have committed.) + beginWrite: function () { + var self = this; + + if (self.retired) + return { committed: function () {} }; + + if (self.fired) + throw new Error("fence has already activated -- too late to add writes"); + + self.outstanding_writes++; + var committed = false; + return { + committed: function () { + if (committed) + throw new Error("committed called twice on the same write"); + committed = true; + self.outstanding_writes--; + self._maybeFire(); + } + }; + }, + + // Arm the fence. Once the fence is armed, and there are no more + // uncommitted writes, it will activate. + arm: function () { + var self = this; + if (self === DDPServer._CurrentWriteFence.get()) + throw Error("Can't arm the current fence"); + self.armed = true; + self._maybeFire(); + }, + + // Register a function to be called once before firing the fence. + // Callback function can add new writes to the fence, in which case + // it won't fire until those writes are done as well. + onBeforeFire: function (func) { + var self = this; + if (self.fired) + throw new Error("fence has already activated -- too late to " + + "add a callback"); + self.before_fire_callbacks.push(func); + }, + + // Register a function to be called when the fence fires. + onAllCommitted: function (func) { + var self = this; + if (self.fired) + throw new Error("fence has already activated -- too late to " + + "add a callback"); + self.completion_callbacks.push(func); + }, + + // Convenience function. Arms the fence, then blocks until it fires. + armAndWait: function () { + var self = this; + var future = new Future; + self.onAllCommitted(function () { + future['return'](); + }); + self.arm(); + future.wait(); + }, + + _maybeFire: function () { + var self = this; + if (self.fired) + throw new Error("write fence already activated?"); + if (self.armed && !self.outstanding_writes) { + function invokeCallback (func) { + try { + func(self); + } catch (err) { + Meteor._debug("exception in write fence callback", err); + } + } + + self.outstanding_writes++; + while (self.before_fire_callbacks.length > 0) { + var callbacks = self.before_fire_callbacks; + self.before_fire_callbacks = []; + _.each(callbacks, invokeCallback); + } + self.outstanding_writes--; + + if (!self.outstanding_writes) { + self.fired = true; + var callbacks = self.completion_callbacks; + self.completion_callbacks = []; + _.each(callbacks, invokeCallback); + } + } + }, + + // Deactivate this fence so that adding more writes has no effect. + // The fence must have already fired. + retire: function () { + var self = this; + if (! self.fired) + throw new Error("Can't retire a fence that hasn't fired."); + self.retired = true; + } +}); diff --git a/packages/deprecated/facts/package.js b/packages/deprecated/facts/package.js index 82995487cd..0279df5748 100644 --- a/packages/deprecated/facts/package.js +++ b/packages/deprecated/facts/package.js @@ -7,12 +7,8 @@ Package.describe({ Package.onUse(function (api) { api.use(['underscore'], ['client', 'server']); - api.use(['templating@1.2.13', 'ddp'], ['client']); - if (!process.env.DISABLE_FIBERS) { - api.use('mongo', ['client', 'server']); - } else { - api.use('mongo-async', ['client', 'server']); - } + api.use(['templating@1.2.13', 'mongo', 'ddp'], ['client']); + // Detect whether autopublish is used. api.use('autopublish', 'server', {weak: true}); diff --git a/packages/deprecated/meteor-platform/package.js b/packages/deprecated/meteor-platform/package.js index f851ac8e96..3f2371dfc6 100644 --- a/packages/deprecated/meteor-platform/package.js +++ b/packages/deprecated/meteor-platform/package.js @@ -30,6 +30,8 @@ Package.onUse(function(api) { // DDP: Meteor's client/server protocol. 'ddp', 'livedata', // XXX COMPAT WITH PACKAGES BUILT FOR 0.9.0. + // You want to keep your data somewhere? How about MongoDB? + 'mongo', // Blaze: Reactive DOM! 'blaze', 'ui', // XXX COMPAT WITH PACKAGES BUILT FOR 0.9.0. @@ -48,12 +50,6 @@ Package.onUse(function(api) { // People like being able to clone objects. 'ejson' ]); - // You want to keep your data somewhere? How about MongoDB? - if (!process.env.DISABLE_FIBERS) { - api.imply(['mongo']); - } else { - api.imply(['mongo-async']); - } // These are useful too! But you don't have to see their exports // unless you want to. diff --git a/packages/ejson/package.js b/packages/ejson/package.js index 7ec8b9aee2..654f16c568 100644 --- a/packages/ejson/package.js +++ b/packages/ejson/package.js @@ -11,12 +11,7 @@ Package.onUse(function onUse(api) { }); Package.onTest(function onTest(api) { - api.use(['ecmascript', 'tinytest']); - if (!process.env.DISABLE_FIBERS) { - api.use('mongo'); - } else { - api.use('mongo-async'); - } + api.use(['ecmascript', 'tinytest', 'mongo']); api.use('ejson'); api.mainModule('ejson_tests.js'); }); diff --git a/packages/facts-ui/package.js b/packages/facts-ui/package.js index 2d8bbb0aa8..5f367eb09d 100644 --- a/packages/facts-ui/package.js +++ b/packages/facts-ui/package.js @@ -7,13 +7,10 @@ Package.onUse(function (api) { api.use([ 'ecmascript', 'facts-base', + 'mongo', 'templating@1.2.13' ], 'client'); - if (!process.env.DISABLE_FIBERS) { - api.use('mongo', 'client'); - } else { - api.use('mongo-async', 'client'); - } + api.imply('facts-base'); api.addFiles('facts_ui.html', 'client'); diff --git a/packages/meteor/async_helpers.js b/packages/meteor/async_helpers.js new file mode 100644 index 0000000000..e326265c03 --- /dev/null +++ b/packages/meteor/async_helpers.js @@ -0,0 +1,132 @@ +var Fiber = Npm.require('fibers'); +var Future = Npm.require('fibers/future'); + +Meteor._noYieldsAllowed = function (f) { + return f(); +}; + +Meteor._DoubleEndedQueue = Npm.require('double-ended-queue'); + +// Meteor._SynchronousQueue is a queue which runs task functions serially. +// Tasks are assumed to be synchronous: ie, it's assumed that they are +// done when they return. +// +// It has two methods: +// - queueTask queues a task to be run, and returns immediately. +// - runTask queues a task to be run, and then yields. It returns +// when the task finishes running. +// +// It's safe to call queueTask from within a task, but not runTask (unless +// you're calling runTask from a nested Fiber). +// +// Somewhat inspired by async.queue, but specific to blocking tasks. +// XXX break this out into an NPM module? +// XXX could maybe use the npm 'schlock' module instead, which would +// also support multiple concurrent "read" tasks +// +class AsynchronousQueue { + constructor() { + this._taskHandles = new Meteor._DoubleEndedQueue(); + this._runningOrRunScheduled = false; + // This is true if we're currently draining. While we're draining, a further + // drain is a noop, to prevent infinite loops. "drain" is a heuristic type + // operation, that has a meaning like unto "what a naive person would expect + // when modifying a table from an observe" + this._draining = false; + } + + queueTask(task) { + this._taskHandles.push({ + task: task, + name: task.name + }); + return this._scheduleRun(); + } + + _scheduleRun() { + // Already running or scheduled? Do nothing. + if (this._runningOrRunScheduled) + return; + + this._runningOrRunScheduled = true; + + let resolver; + const returnValue = new Promise(r => resolver = r); + setImmediate(() => { + Meteor._runAsync(async () => { + await this._run(); + + if (!resolver) { + throw new Error("Resolver not found for task"); + } + + resolver(); + }); + }); + + return returnValue; + } + + async _run() { + if (!this._runningOrRunScheduled) + throw new Error("expected to be _runningOrRunScheduled"); + + if (this._taskHandles.isEmpty()) { + // Done running tasks! Don't immediately schedule another run, but + // allow future tasks to do so. + this._runningOrRunScheduled = false; + return; + } + const taskHandle = this._taskHandles.shift(); + + // Run the task. + try { + await taskHandle.task(); + } catch (err) { + Meteor._debug("Exception in queued task", err); + } + + // Soon, run the next task, if there is any. + this._runningOrRunScheduled = false; + await this._scheduleRun(); + } + + runTask(task) { + const handle = { + task: Meteor.bindEnvironment(task, function(e) { + Meteor._debug('Exception from task', e); + throw e; + }), + name: task.name + }; + this._taskHandles.push(handle); + return this._scheduleRun(); + } + + flush() { + return this.runTask(() => {}); + } + + async drain() { + if (this._draining) + return; + + this._draining = true; + while (!this._taskHandles.isEmpty()) { + await this.flush(); + } + this._draining = false; + } +} + +Meteor._AsynchronousQueue = AsynchronousQueue; +Meteor._SynchronousQueue = AsynchronousQueue; + + +// Sleep. Mostly used for debugging (eg, inserting latency into server +// methods). +// +const _sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); +Meteor._sleepForMs = function (ms) { + return _sleep(ms); +}; diff --git a/packages/meteor/package.js b/packages/meteor/package.js index c8a131bfa4..7effcd9675 100644 --- a/packages/meteor/package.js +++ b/packages/meteor/package.js @@ -34,7 +34,11 @@ Package.onUse(function (api) { api.addFiles('timers.js', ['client', 'server']); api.addFiles('errors.js', ['client', 'server']); api.addFiles('asl-helpers.js', 'server'); - api.addFiles('fiber_helpers.js', 'server'); + if (process.env.DISABLE_FIBERS) { + api.addFiles('async_helpers.js', 'server'); + } else { + api.addFiles('fiber_helpers.js', 'server'); + } api.addFiles('fiber_stubs_client.js', 'client'); api.addFiles('asl-helpers-client.js', 'client'); api.addFiles('startup_client.js', ['client']); diff --git a/packages/mongo-async/collection.js b/packages/mongo-async/collection.js index b6663e3682..340125b260 100644 --- a/packages/mongo-async/collection.js +++ b/packages/mongo-async/collection.js @@ -12,7 +12,7 @@ import { normalizeProjection } from "./mongo_utils"; * @namespace */ Mongo = {}; - +console.log('Using package: mongo-async'); /** * @summary Constructor for a Collection * @locus Anywhere diff --git a/packages/mongo-async/mongo_driver.js b/packages/mongo-async/mongo_driver.js index 1bd3576977..cb0eb6e350 100644 --- a/packages/mongo-async/mongo_driver.js +++ b/packages/mongo-async/mongo_driver.js @@ -14,7 +14,6 @@ const util = require("util"); /** @type {import('mongodb')} */ var MongoDB = NpmModuleMongodb; -var Future = Npm.require('fibers/future'); import { DocFetcher } from "./doc_fetcher.js"; import { ASYNC_CURSOR_METHODS, @@ -23,6 +22,9 @@ import { MongoInternals = {}; +// TODO remove after test +MongoInternals.__packageName = 'mongo-async'; + MongoInternals.NpmModules = { mongodb: { version: NpmModuleMongodbVersion, @@ -244,19 +246,15 @@ MongoConnection.prototype.rawCollection = function (collectionName) { return self.db.collection(collectionName); }; -MongoConnection.prototype._createCappedCollection = function ( +MongoConnection.prototype._createCappedCollection = async function ( collectionName, byteSize, maxDocuments) { var self = this; if (! self.db) throw Error("_createCappedCollection called before Connection created?"); - var future = new Future(); - self.db.createCollection( - collectionName, - { capped: true, size: byteSize, max: maxDocuments }, - future.resolver()); - future.wait(); + await self.db.createCollection(collectionName, + { capped: true, size: byteSize, max: maxDocuments }); }; // This should be called synchronously with a write, to create a @@ -834,29 +832,25 @@ MongoConnection.prototype.findOne = async function (collection_name, selector, o // We'll actually design an index API later. For now, we just pass through to // Mongo's, but make it synchronous. -MongoConnection.prototype.createIndex = function (collectionName, index, +MongoConnection.prototype.createIndex = async function (collectionName, index, options) { var self = this; // We expect this function to be called at startup, not from within a method, // so we don't interact with the write fence. var collection = self.rawCollection(collectionName); - var future = new Future; - var indexName = collection.createIndex(index, options, future.resolver()); - future.wait(); + var indexName = await collection.createIndex(index, options); }; MongoConnection.prototype._ensureIndex = MongoConnection.prototype.createIndex; -MongoConnection.prototype._dropIndex = function (collectionName, index) { +MongoConnection.prototype._dropIndex = async function (collectionName, index) { var self = this; // This function is only used by test code, not within a method, so we don't // interact with the write fence. var collection = self.rawCollection(collectionName); - var future = new Future; - var indexName = collection.dropIndex(index, future.resolver()); - future.wait(); + var indexName = await collection.dropIndex(index); }; // CURSORS diff --git a/packages/mongo-async/package.js b/packages/mongo-async/package.js index ce39022e90..f31b7efe27 100644 --- a/packages/mongo-async/package.js +++ b/packages/mongo-async/package.js @@ -85,7 +85,7 @@ Package.onUse(function (api) { }); Package.onTest(function (api) { - api.use('mongo-async'); + api.use('mongo'); api.use('check'); api.use('ecmascript'); api.use('npm-mongo', 'server'); diff --git a/packages/mongo-livedata/package.js b/packages/mongo-livedata/package.js index 279ff74c1c..2792c6b030 100644 --- a/packages/mongo-livedata/package.js +++ b/packages/mongo-livedata/package.js @@ -4,9 +4,5 @@ Package.describe({ }); Package.onUse(function (api) { - if (!process.env.DISABLE_FIBERS) { - api.imply('mongo'); - } else { - api.imply('mongo-async'); - } + api.imply("mongo"); }); diff --git a/packages/mongo/collection.js b/packages/mongo/collection.js index 3dcc12dc96..bdc9333e70 100644 --- a/packages/mongo/collection.js +++ b/packages/mongo/collection.js @@ -13,6 +13,7 @@ import { normalizeProjection } from "./mongo_utils"; */ Mongo = {}; +console.log('Using package: mongo'); /** * @summary Constructor for a Collection * @locus Anywhere diff --git a/packages/mongo/mongo_driver.js b/packages/mongo/mongo_driver.js index 5d653636a8..f54e67361e 100644 --- a/packages/mongo/mongo_driver.js +++ b/packages/mongo/mongo_driver.js @@ -23,6 +23,9 @@ import { MongoInternals = {}; +// TODO remove after test +MongoInternals.__packageName = 'mongo' + MongoInternals.NpmModules = { mongodb: { version: NpmModuleMongodbVersion, diff --git a/packages/mongo/package.js b/packages/mongo/package.js index 0ec175d219..1718220dc2 100644 --- a/packages/mongo/package.js +++ b/packages/mongo/package.js @@ -21,6 +21,13 @@ Npm.strip({ }); Package.onUse(function (api) { + if (process.env.DISABLE_FIBERS) { + api.use('mongo-async', ['server', 'client']); + api.export("Mongo"); + api.export('MongoInternals', 'server'); + api.export('ObserveMultiplexer', 'server', {testOnly: true}); + return; + } api.use('npm-mongo', 'server'); api.use('allow-deny'); diff --git a/packages/non-core/mongo-decimal/package.js b/packages/non-core/mongo-decimal/package.js index 59478167e2..601d2cd0b7 100644 --- a/packages/non-core/mongo-decimal/package.js +++ b/packages/non-core/mongo-decimal/package.js @@ -15,11 +15,7 @@ Package.onUse(function (api) { }); Package.onTest(function (api) { - if (!process.env.DISABLE_FIBERS) { - api.use('mongo'); - } else { - api.use('mongo-async'); - } + api.use('mongo'); api.use('mongo-decimal'); api.use('insecure'); api.use(['tinytest']); diff --git a/packages/oauth/package.js b/packages/oauth/package.js index 5fc50df840..421be1d506 100644 --- a/packages/oauth/package.js +++ b/packages/oauth/package.js @@ -5,12 +5,8 @@ Package.describe({ Package.onUse(api => { api.use(['check', 'ecmascript', 'localstorage', 'url']); - if (!process.env.DISABLE_FIBERS) { - api.use('mongo', 'server'); - } else { - api.use('mongo-async', 'server'); - } - api.use(['routepolicy', 'webapp', 'service-configuration', 'logging'], 'server'); + + api.use(['routepolicy', 'webapp', 'mongo', 'service-configuration', 'logging'], 'server'); api.use(['reload', 'base64'], 'client'); diff --git a/packages/oauth1/package.js b/packages/oauth1/package.js index 8505da26ff..bb07c66774 100644 --- a/packages/oauth1/package.js +++ b/packages/oauth1/package.js @@ -10,11 +10,7 @@ Package.onUse(api => { api.use('oauth', ['client', 'server']); api.use('check', 'server'); - if (!process.env.DISABLE_FIBERS) { - api.use('mongo'); - } else { - api.use('mongo-async'); - } + api.use('mongo'); api.export('OAuth1Binding', 'server'); api.export('OAuth1Test', 'server', {testOnly: true}); diff --git a/packages/promise/package.js b/packages/promise/package.js index fad988b619..05f9c59eca 100644 --- a/packages/promise/package.js +++ b/packages/promise/package.js @@ -1,6 +1,6 @@ Package.describe({ name: "promise", - version: "0.12.0", + version: "0.12.1", summary: "ECMAScript 2015 Promise polyfill with Fiber support", git: "https://github.com/meteor/promise", documentation: "README.md" diff --git a/packages/promise/server.js b/packages/promise/server.js index 601473d628..2f5f59a3c0 100644 --- a/packages/promise/server.js +++ b/packages/promise/server.js @@ -1,6 +1,6 @@ require("./extensions.js"); -if (!!!process.env.DISABLE_FIBERS) { +if (!process.env.DISABLE_FIBERS) { require("meteor-promise").makeCompatible( Promise, // Allow every Promise callback to run in a Fiber drawn from a pool of @@ -9,7 +9,6 @@ if (!!!process.env.DISABLE_FIBERS) { ); } - // Reference: https://caniuse.com/#feat=promises require("meteor/modern-browsers").setMinimumBrowserVersions({ chrome: 32, diff --git a/packages/reactive-dict/package.js b/packages/reactive-dict/package.js index 0d856e6085..ee7d4e4e9f 100644 --- a/packages/reactive-dict/package.js +++ b/packages/reactive-dict/package.js @@ -6,12 +6,7 @@ Package.describe({ Package.onUse(function (api) { api.use(['tracker', 'ejson', 'ecmascript']); // If we are loading mongo-livedata, let you store ObjectIDs in it. - if (!process.env.DISABLE_FIBERS) { - api.use('mongo', { weak: true }); - } else { - api.use('mongo-async', { weak: true }); - } - api.use(['reload'], { weak: true }); + api.use(['mongo', 'reload'], { weak: true }); api.mainModule('migration.js'); api.export('ReactiveDict'); }); diff --git a/packages/service-configuration/package.js b/packages/service-configuration/package.js index 7313ce21de..ec496a1fff 100644 --- a/packages/service-configuration/package.js +++ b/packages/service-configuration/package.js @@ -5,11 +5,7 @@ Package.describe({ Package.onUse(function(api) { api.use('accounts-base', ['client', 'server']); - if (!process.env.DISABLE_FIBERS) { - api.use('mongo', ['client', 'server']); - } else { - api.use('mongo-async', ['client', 'server']); - } + api.use('mongo', ['client', 'server']); api.use('ecmascript', ['client', 'server']); api.export('ServiceConfiguration'); api.addFiles('service_configuration_common.js', ['client', 'server']); diff --git a/packages/session/package.js b/packages/session/package.js index 5a8bff9712..5515510f25 100644 --- a/packages/session/package.js +++ b/packages/session/package.js @@ -20,10 +20,6 @@ Package.onTest(function (api) { api.use('tinytest'); api.use('session', 'client'); api.use('tracker'); - if (!process.env.DISABLE_FIBERS) { - api.use('mongo'); - } else { - api.use('mongo-async'); - } + api.use('mongo'); api.addFiles('session_tests.js', 'client'); }); diff --git a/packages/tinytest/package.js b/packages/tinytest/package.js index a01ea79f71..862749494b 100644 --- a/packages/tinytest/package.js +++ b/packages/tinytest/package.js @@ -10,13 +10,9 @@ Package.onUse(function (api) { 'underscore', 'random', 'ddp', + 'mongo', 'check' ]); - if (!process.env.DISABLE_FIBERS) { - api.use('mongo'); - } else { - api.use('mongo-async'); - } api.mainModule('tinytest_client.js', 'client'); api.mainModule('tinytest_server.js', 'server'); diff --git a/scripts/dev-bundle-tool-package.js b/scripts/dev-bundle-tool-package.js index a2d440e238..e0f21db047 100644 --- a/scripts/dev-bundle-tool-package.js +++ b/scripts/dev-bundle-tool-package.js @@ -15,7 +15,7 @@ var packageJson = { "node-gyp": "8.0.0", "node-pre-gyp": "0.15.0", typescript: "4.5.4", - "@meteorjs/babel": "7.16.0-beta.1", + "@meteorjs/babel": "7.16.0-beta.7", // Keep the versions of these packages consistent with the versions // found in dev-bundle-server-package.js. "meteor-promise": "0.9.0",