├── .npm └── package │ ├── .gitignore │ ├── README │ └── npm-shrinkwrap.json ├── LICENSE.md ├── README.md ├── client ├── client.js ├── client_convenience.js ├── sockjs-0.3.4.js └── stream_client_sockjs.js ├── common ├── id_map.js ├── livedata_connection.js ├── namespace.js ├── stream_client_common.js └── urlHelpers.js ├── fusion ├── client │ ├── client.js │ ├── engager.js │ ├── fusion.js │ └── rpc.js └── server │ ├── index.js │ └── route.js ├── package.js ├── server ├── server.js └── stream_client_nodejs.js └── test ├── livedata_connection_tests.js ├── livedata_test_service.js ├── livedata_tests.js ├── random_stream_tests.js ├── stream_client_tests.js ├── stream_tests.js └── stub_stream.js /.npm/package/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.npm/package/README: -------------------------------------------------------------------------------- 1 | This directory and the files immediately inside it are automatically generated 2 | when you change this package's NPM dependencies. Commit the files in this 3 | directory (npm-shrinkwrap.json, .gitignore, and this README) to source control 4 | so that others run the same versions of sub-dependencies. 5 | 6 | You should NOT check in the node_modules directory that Meteor automatically 7 | creates; if you are using git, the .gitignore file tells git to ignore it. 8 | -------------------------------------------------------------------------------- /.npm/package/npm-shrinkwrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "dependencies": { 4 | "body-parser": { 5 | "version": "1.18.2", 6 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.2.tgz", 7 | "integrity": "sha1-h2eKGdhLR9hZuDGZvVm84iKxBFQ=" 8 | }, 9 | "bytes": { 10 | "version": "3.0.0", 11 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", 12 | "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" 13 | }, 14 | "content-type": { 15 | "version": "1.0.4", 16 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", 17 | "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" 18 | }, 19 | "debug": { 20 | "version": "2.6.9", 21 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 22 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==" 23 | }, 24 | "depd": { 25 | "version": "1.1.1", 26 | "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.1.tgz", 27 | "integrity": "sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k=" 28 | }, 29 | "ee-first": { 30 | "version": "1.1.1", 31 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 32 | "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" 33 | }, 34 | "faye-websocket": { 35 | "version": "0.11.1", 36 | "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.1.tgz", 37 | "integrity": "sha1-8O/hjE9W5PQK/H4Gxxn9XuYYjzg=" 38 | }, 39 | "http-errors": { 40 | "version": "1.6.2", 41 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.2.tgz", 42 | "integrity": "sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY=" 43 | }, 44 | "http-parser-js": { 45 | "version": "0.4.9", 46 | "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.4.9.tgz", 47 | "integrity": "sha1-6hoE+2St/wJC6ZdPKX3Uw8rSceE=" 48 | }, 49 | "iconv-lite": { 50 | "version": "0.4.19", 51 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", 52 | "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==" 53 | }, 54 | "inherits": { 55 | "version": "2.0.3", 56 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", 57 | "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" 58 | }, 59 | "lolex": { 60 | "version": "1.4.0", 61 | "resolved": "https://registry.npmjs.org/lolex/-/lolex-1.4.0.tgz", 62 | "integrity": "sha1-LycSsbwYDendzF06epbvPAuxYq0=" 63 | }, 64 | "media-typer": { 65 | "version": "0.3.0", 66 | "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", 67 | "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" 68 | }, 69 | "mime-db": { 70 | "version": "1.30.0", 71 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.30.0.tgz", 72 | "integrity": "sha1-dMZD2i3Z1qRTmZY0ZbJtXKfXHwE=" 73 | }, 74 | "mime-types": { 75 | "version": "2.1.17", 76 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.17.tgz", 77 | "integrity": "sha1-Cdejk/A+mVp5+K+Fe3Cp4KsWVXo=" 78 | }, 79 | "ms": { 80 | "version": "2.0.0", 81 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 82 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 83 | }, 84 | "on-finished": { 85 | "version": "2.3.0", 86 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", 87 | "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=" 88 | }, 89 | "permessage-deflate": { 90 | "version": "0.1.6", 91 | "resolved": "https://registry.npmjs.org/permessage-deflate/-/permessage-deflate-0.1.6.tgz", 92 | "integrity": "sha1-WB8c7fvUQPrEfQd3vohjM4a5kt4=" 93 | }, 94 | "qs": { 95 | "version": "6.5.1", 96 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", 97 | "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==" 98 | }, 99 | "raw-body": { 100 | "version": "2.3.2", 101 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.2.tgz", 102 | "integrity": "sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k=" 103 | }, 104 | "setprototypeof": { 105 | "version": "1.0.3", 106 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz", 107 | "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=" 108 | }, 109 | "statuses": { 110 | "version": "1.4.0", 111 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", 112 | "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" 113 | }, 114 | "type-is": { 115 | "version": "1.6.15", 116 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.15.tgz", 117 | "integrity": "sha1-yrEPtJCeRByChC6v4a1kbIGARBA=" 118 | }, 119 | "unpipe": { 120 | "version": "1.0.0", 121 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", 122 | "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" 123 | }, 124 | "websocket-driver": { 125 | "version": "0.7.0", 126 | "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.0.tgz", 127 | "integrity": "sha1-DK+dLXVdk67gSdS90NP+LMoqJOs=" 128 | }, 129 | "websocket-extensions": { 130 | "version": "0.1.3", 131 | "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.3.tgz", 132 | "integrity": "sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg==" 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2017 Theodor Diaconu 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DDP Client - Fusion Mode 2 | 3 | This package is an extension to `ddp-client`, originally from Meteor, with the ability to conditionally start the DDP connection. 4 | 5 | Original code: https://github.com/meteor/meteor/tree/devel/packages/ddp-client 6 | 7 | ## Why ? 8 | 9 | The reason is simple, websockets are expensive, and most applications won't need it. Or they need it in certain areas, this opens the path of doing so with ease. 10 | 11 | ## How to install 12 | 13 | ```bash 14 | # In your Meteor App 15 | mkdir packages 16 | cd packages 17 | git clone https://github.com/cult-of-coders/fusion.git 18 | ``` 19 | 20 | It's not on atmosphere because we needed to override the behavior of `ddp-client`. 21 | 22 | If you add this package to Meteor, the client no longer requires an websocket connection. This means that subscriptions will not work out of the box. 23 | However you can start/stop the websocket conditionally: 24 | 25 | ```js 26 | import {DDP} from 'meteor/ddp-client'; 27 | 28 | DDP.engage(); // establishes WS connection 29 | DDP.disengage(); // cuts the WS connection 30 | ``` 31 | 32 | And for your convenience we also export the `Fusion` class that lets components request an websocket connection, 33 | and they can release that connection when no longer needed, and if there is no active "requesters" the websocket connection stops. 34 | 35 | Sample: 36 | 37 | ```js 38 | import {Fusion} from 'meteor/ddp-client'; 39 | 40 | const handler = Fusion.engage(() => { 41 | Meteor.subscribe('xxx'); 42 | }) 43 | 44 | // When you no longer need it: 45 | handler.stop(); 46 | ``` 47 | 48 | When all registered handlers are stopped, the websocket connection also stops. 49 | 50 | `Meteor.call` will work as expected inside your client, because we create a server side route "/_meteor" that accepts RPC calls. 51 | When DDP is engaged, `Meteor.call` will communicate via DDP 52 | 53 | When sending HTTP RPC calls authorization is supported by default, meaning you can still use `this.userId` inside your methods. 54 | This is possible because we pass `Accounts._storedLoginToken()` to each request. 55 | 56 | This works with `accounts-password`. 57 | 58 | ## License: MIT -------------------------------------------------------------------------------- /client/client.js: -------------------------------------------------------------------------------- 1 | export { DDP, LivedataTest } from '../common/namespace'; 2 | 3 | import './stream_client_sockjs'; 4 | 5 | import '../common/livedata_connection'; 6 | 7 | import './client_convenience'; 8 | -------------------------------------------------------------------------------- /client/client_convenience.js: -------------------------------------------------------------------------------- 1 | import { DDP } from '../common/namespace.js'; 2 | import { Meteor } from 'meteor/meteor'; 3 | 4 | // Meteor.refresh can be called on the client (if you're in common code) but it 5 | // only has an effect on the server. 6 | Meteor.refresh = () => {}; 7 | 8 | // By default, try to connect back to the same endpoint as the page 9 | // was served from. 10 | // 11 | // XXX We should be doing this a different way. Right now we don't 12 | // include ROOT_URL_PATH_PREFIX when computing ddpUrl. (We don't 13 | // include it on the server when computing 14 | // DDP_DEFAULT_CONNECTION_URL, and we don't include it in our 15 | // default, '/'.) We get by with this because DDP.connect then 16 | // forces the URL passed to it to be interpreted relative to the 17 | // app's deploy path, even if it is absolute. Instead, we should 18 | // make DDP_DEFAULT_CONNECTION_URL, if set, include the path prefix; 19 | // make the default ddpUrl be '' rather that '/'; and make 20 | // _translateUrl in stream_client_common.js not force absolute paths 21 | // to be treated like relative paths. See also 22 | // stream_client_common.js #RationalizingRelativeDDPURLs 23 | var ddpUrl = '/'; 24 | if (typeof __meteor_runtime_config__ !== 'undefined') { 25 | if (__meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL) 26 | ddpUrl = __meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL; 27 | } 28 | 29 | var retry = new Retry(); 30 | 31 | function onDDPVersionNegotiationFailure(description) { 32 | Meteor._debug(description); 33 | if (Package.reload) { 34 | var migrationData = 35 | Package.reload.Reload._migrationData('livedata') || 36 | Object.create(null); 37 | var failures = migrationData.DDPVersionNegotiationFailures || 0; 38 | ++failures; 39 | Package.reload.Reload._onMigrate('livedata', () => { 40 | return [true, { DDPVersionNegotiationFailures: failures }]; 41 | }); 42 | retry.retryLater(failures, () => { 43 | Package.reload.Reload._reload(); 44 | }); 45 | } 46 | } 47 | 48 | Meteor.connection = DDP.connect(ddpUrl, { 49 | onDDPVersionNegotiationFailure: onDDPVersionNegotiationFailure 50 | }); 51 | 52 | // Proxy the public methods of Meteor.connection so they can 53 | // be called directly on Meteor. 54 | [ 55 | 'subscribe', 56 | 'methods', 57 | 'call', 58 | 'apply', 59 | 'status', 60 | 'reconnect', 61 | 'disconnect' 62 | ].forEach(name => { 63 | Meteor[name] = Meteor.connection[name].bind(Meteor.connection); 64 | }); 65 | 66 | // Meteor.connection used to be called 67 | // Meteor.default_connection. Provide backcompat as a courtesy even 68 | // though it was never documented. 69 | // XXX COMPAT WITH 0.6.4 70 | Meteor.default_connection = Meteor.connection; 71 | 72 | // We should transition from Meteor.connect to DDP.connect. 73 | // XXX COMPAT WITH 0.6.4 74 | Meteor.connect = DDP.connect; -------------------------------------------------------------------------------- /client/stream_client_sockjs.js: -------------------------------------------------------------------------------- 1 | import { _ } from 'meteor/underscore'; 2 | import { Meteor } from 'meteor/meteor'; 3 | 4 | // This populates a global variable 5 | import './sockjs-0.3.4'; 6 | 7 | import { DDP, LivedataTest } from '../common/namespace.js'; 8 | import { toSockjsUrl } from '../common/urlHelpers'; 9 | import { addCommonMethodsToPrototype } from '../common/stream_client_common'; 10 | 11 | // @param url {String} URL to Meteor app 12 | // "http://subdomain.meteor.com/" or "/" or 13 | // "ddp+sockjs://foo-**.meteor.com/sockjs" 14 | LivedataTest.ClientStream = function(url, options) { 15 | var self = this; 16 | self.options = _.extend( 17 | { 18 | retry: true 19 | }, 20 | options 21 | ); 22 | self._initCommon(self.options); 23 | 24 | //// Constants 25 | 26 | // how long between hearing heartbeat from the server until we declare 27 | // the connection dead. heartbeats come every 45s (stream_server.js) 28 | // 29 | // NOTE: this is a older timeout mechanism. We now send heartbeats at 30 | // the DDP level (https://github.com/meteor/meteor/pull/1865), and 31 | // expect those timeouts to kill a non-responsive connection before 32 | // this timeout fires. This is kept around for compatibility (when 33 | // talking to a server that doesn't support DDP heartbeats) and can be 34 | // removed later. 35 | self.HEARTBEAT_TIMEOUT = 100 * 1000; 36 | 37 | self.rawUrl = url; 38 | self.socket = null; 39 | 40 | self.heartbeatTimer = null; 41 | 42 | // Listen to global 'online' event if we are running in a browser. 43 | // (IE8 does not support addEventListener) 44 | if (typeof window !== 'undefined' && window.addEventListener) 45 | window.addEventListener( 46 | 'online', 47 | _.bind(self._online, self), 48 | false /* useCapture. make FF3.6 happy. */ 49 | ); 50 | 51 | //// Kickoff! 52 | self._launchConnection(); 53 | }; 54 | 55 | _.extend(LivedataTest.ClientStream.prototype, { 56 | // data is a utf8 string. Data sent while not connected is dropped on 57 | // the floor, and it is up the user of this API to retransmit lost 58 | // messages on 'reset' 59 | send: function(data) { 60 | var self = this; 61 | if (self.currentStatus.connected) { 62 | self.socket.send(data); 63 | } 64 | }, 65 | 66 | // Changes where this connection points 67 | _changeUrl: function(url) { 68 | var self = this; 69 | self.rawUrl = url; 70 | }, 71 | 72 | _connected: function() { 73 | var self = this; 74 | 75 | if (self.connectionTimer) { 76 | clearTimeout(self.connectionTimer); 77 | self.connectionTimer = null; 78 | } 79 | 80 | if (self.currentStatus.connected) { 81 | // already connected. do nothing. this probably shouldn't happen. 82 | return; 83 | } 84 | 85 | // update status 86 | self.currentStatus.status = 'connected'; 87 | self.currentStatus.connected = true; 88 | self.currentStatus.retryCount = 0; 89 | self.statusChanged(); 90 | 91 | // fire resets. This must come after status change so that clients 92 | // can call send from within a reset callback. 93 | _.each(self.eventCallbacks.reset, function(callback) { 94 | callback(); 95 | }); 96 | }, 97 | 98 | _cleanup: function(maybeError) { 99 | var self = this; 100 | 101 | self._clearConnectionAndHeartbeatTimers(); 102 | if (self.socket) { 103 | self.socket.onmessage = self.socket.onclose = () => {}; 104 | self.socket.onerror = self.socket.onheartbeat = () => {}; 105 | self.socket.close(); 106 | self.socket = null; 107 | } 108 | 109 | _.each(self.eventCallbacks.disconnect, function(callback) { 110 | callback(maybeError); 111 | }); 112 | }, 113 | 114 | _clearConnectionAndHeartbeatTimers: function() { 115 | var self = this; 116 | if (self.connectionTimer) { 117 | clearTimeout(self.connectionTimer); 118 | self.connectionTimer = null; 119 | } 120 | if (self.heartbeatTimer) { 121 | clearTimeout(self.heartbeatTimer); 122 | self.heartbeatTimer = null; 123 | } 124 | }, 125 | 126 | _heartbeat_timeout: function() { 127 | var self = this; 128 | Meteor._debug('Connection timeout. No sockjs heartbeat received.'); 129 | self._lostConnection(new DDP.ConnectionError('Heartbeat timed out')); 130 | }, 131 | 132 | _heartbeat_received: function() { 133 | var self = this; 134 | // If we've already permanently shut down this stream, the timeout is 135 | // already cleared, and we don't need to set it again. 136 | if (self._forcedToDisconnect) return; 137 | if (self.heartbeatTimer) clearTimeout(self.heartbeatTimer); 138 | self.heartbeatTimer = setTimeout( 139 | _.bind(self._heartbeat_timeout, self), 140 | self.HEARTBEAT_TIMEOUT 141 | ); 142 | }, 143 | 144 | _sockjsProtocolsWhitelist: function() { 145 | // only allow polling protocols. no streaming. streaming 146 | // makes safari spin. 147 | var protocolsWhitelist = [ 148 | 'xdr-polling', 149 | 'xhr-polling', 150 | 'iframe-xhr-polling', 151 | 'jsonp-polling' 152 | ]; 153 | 154 | // iOS 4 and 5 and below crash when using websockets over certain 155 | // proxies. this seems to be resolved with iOS 6. eg 156 | // https://github.com/LearnBoost/socket.io/issues/193#issuecomment-7308865. 157 | // 158 | // iOS <4 doesn't support websockets at all so sockjs will just 159 | // immediately fall back to http 160 | var noWebsockets = 161 | navigator && 162 | /iPhone|iPad|iPod/.test(navigator.userAgent) && 163 | /OS 4_|OS 5_/.test(navigator.userAgent); 164 | 165 | if (!noWebsockets) 166 | protocolsWhitelist = ['websocket'].concat(protocolsWhitelist); 167 | 168 | return protocolsWhitelist; 169 | }, 170 | 171 | _launchConnection: function() { 172 | var self = this; 173 | self._cleanup(); // cleanup the old socket, if there was one. 174 | 175 | var options = _.extend( 176 | { 177 | protocols_whitelist: self._sockjsProtocolsWhitelist() 178 | }, 179 | self.options._sockjsOptions 180 | ); 181 | 182 | // Convert raw URL to SockJS URL each time we open a connection, so that we 183 | // can connect to random hostnames and get around browser per-host 184 | // connection limits. 185 | self.socket = new SockJS(toSockjsUrl(self.rawUrl), undefined, options); 186 | self.socket.onopen = function(data) { 187 | self._connected(); 188 | }; 189 | self.socket.onmessage = function(data) { 190 | self._heartbeat_received(); 191 | 192 | if (self.currentStatus.connected) 193 | _.each(self.eventCallbacks.message, function(callback) { 194 | callback(data.data); 195 | }); 196 | }; 197 | self.socket.onclose = function() { 198 | self._lostConnection(); 199 | }; 200 | self.socket.onerror = function() { 201 | // XXX is this ever called? 202 | Meteor._debug( 203 | 'stream error', 204 | _.toArray(arguments), 205 | new Date().toDateString() 206 | ); 207 | }; 208 | 209 | self.socket.onheartbeat = function() { 210 | self._heartbeat_received(); 211 | }; 212 | 213 | if (self.connectionTimer) clearTimeout(self.connectionTimer); 214 | self.connectionTimer = setTimeout(function() { 215 | self._lostConnection(new DDP.ConnectionError('DDP connection timed out')); 216 | }, self.CONNECT_TIMEOUT); 217 | } 218 | }); 219 | 220 | addCommonMethodsToPrototype(LivedataTest.ClientStream.prototype); 221 | -------------------------------------------------------------------------------- /common/id_map.js: -------------------------------------------------------------------------------- 1 | export class MongoIDMap extends IdMap { 2 | constructor() { 3 | super(MongoID.idStringify, MongoID.idParse); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /common/livedata_connection.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { _ } from 'meteor/underscore'; 3 | import { DDPCommon } from 'meteor/ddp-common'; 4 | import { Tracker } from 'meteor/tracker'; 5 | import { EJSON } from 'meteor/ejson'; 6 | import { Random } from 'meteor/random'; 7 | import { Hook } from 'meteor/callback-hook'; 8 | 9 | if (Meteor.isServer) { 10 | var Fiber = Npm.require('fibers'); 11 | var Future = Npm.require('fibers/future'); 12 | } 13 | 14 | import { DDP, LivedataTest } from './namespace.js'; 15 | import { MongoIDMap } from './id_map.js'; 16 | 17 | // @param url {String|Object} URL to Meteor app, 18 | // or an object as a test hook (see code) 19 | // Options: 20 | // reloadWithOutstanding: is it OK to reload if there are outstanding methods? 21 | // headers: extra headers to send on the websockets connection, for 22 | // server-to-server DDP only 23 | // _sockjsOptions: Specifies options to pass through to the sockjs client 24 | // onDDPNegotiationVersionFailure: callback when version negotiation fails. 25 | // 26 | // XXX There should be a way to destroy a DDP connection, causing all 27 | // outstanding method calls to fail. 28 | // 29 | // XXX Our current way of handling failure and reconnection is great 30 | // for an app (where we want to tolerate being disconnected as an 31 | // expect state, and keep trying forever to reconnect) but cumbersome 32 | // for something like a command line tool that wants to make a 33 | // connection, call a method, and print an error if connection 34 | // fails. We should have better usability in the latter case (while 35 | // still transparently reconnecting if it's just a transient failure 36 | // or the server migrating us). 37 | var Connection = function(url, options) { 38 | var self = this; 39 | options = _.extend( 40 | { 41 | onConnected: function() {}, 42 | onDDPVersionNegotiationFailure: function(description) { 43 | Meteor._debug(description); 44 | }, 45 | heartbeatInterval: 17500, 46 | heartbeatTimeout: 15000, 47 | npmFayeOptions: {}, 48 | // These options are only for testing. 49 | reloadWithOutstanding: false, 50 | supportedDDPVersions: DDPCommon.SUPPORTED_DDP_VERSIONS, 51 | retry: true, 52 | lazy: true, 53 | respondToPings: true, 54 | // When updates are coming within this ms interval, batch them together. 55 | bufferedWritesInterval: 5, 56 | // Flush buffers immediately if writes are happening continuously for more than this many ms. 57 | bufferedWritesMaxAge: 500 58 | }, 59 | options 60 | ); 61 | 62 | // If set, called when we reconnect, queuing method calls _before_ the 63 | // existing outstanding ones. 64 | // NOTE: This feature has been preserved for backwards compatibility. The 65 | // preferred method of setting a callback on reconnect is to use 66 | // DDP.onReconnect. 67 | self.onReconnect = null; 68 | 69 | // as a test hook, allow passing a stream instead of a url. 70 | if (typeof url === 'object') { 71 | self._stream = url; 72 | } else { 73 | self._stream = new LivedataTest.ClientStream(url, { 74 | retry: options.retry, 75 | headers: options.headers, 76 | _sockjsOptions: options._sockjsOptions, 77 | // Used to keep some tests quiet, or for other cases in which 78 | // the right thing to do with connection errors is to silently 79 | // fail (e.g. sending package usage stats). At some point we 80 | // should have a real API for handling client-stream-level 81 | // errors. 82 | _dontPrintErrors: options._dontPrintErrors, 83 | connectTimeoutMs: options.connectTimeoutMs, 84 | npmFayeOptions: options.npmFayeOptions 85 | }); 86 | } 87 | 88 | self._lastSessionId = null; 89 | self._versionSuggestion = null; // The last proposed DDP version. 90 | self._version = null; // The DDP version agreed on by client and server. 91 | self._stores = {}; // name -> object with methods 92 | self._methodHandlers = {}; // name -> func 93 | self._nextMethodId = 1; 94 | self._supportedDDPVersions = options.supportedDDPVersions; 95 | 96 | self._heartbeatInterval = options.heartbeatInterval; 97 | self._heartbeatTimeout = options.heartbeatTimeout; 98 | 99 | // Tracks methods which the user has tried to call but which have not yet 100 | // called their user callback (ie, they are waiting on their result or for all 101 | // of their writes to be written to the local cache). Map from method ID to 102 | // MethodInvoker object. 103 | self._methodInvokers = {}; 104 | 105 | // Tracks methods which the user has called but whose result messages have not 106 | // arrived yet. 107 | // 108 | // _outstandingMethodBlocks is an array of blocks of methods. Each block 109 | // represents a set of methods that can run at the same time. The first block 110 | // represents the methods which are currently in flight; subsequent blocks 111 | // must wait for previous blocks to be fully finished before they can be sent 112 | // to the server. 113 | // 114 | // Each block is an object with the following fields: 115 | // - methods: a list of MethodInvoker objects 116 | // - wait: a boolean; if true, this block had a single method invoked with 117 | // the "wait" option 118 | // 119 | // There will never be adjacent blocks with wait=false, because the only thing 120 | // that makes methods need to be serialized is a wait method. 121 | // 122 | // Methods are removed from the first block when their "result" is 123 | // received. The entire first block is only removed when all of the in-flight 124 | // methods have received their results (so the "methods" list is empty) *AND* 125 | // all of the data written by those methods are visible in the local cache. So 126 | // it is possible for the first block's methods list to be empty, if we are 127 | // still waiting for some objects to quiesce. 128 | // 129 | // Example: 130 | // _outstandingMethodBlocks = [ 131 | // {wait: false, methods: []}, 132 | // {wait: true, methods: []}, 133 | // {wait: false, methods: [, 134 | // ]}] 135 | // This means that there were some methods which were sent to the server and 136 | // which have returned their results, but some of the data written by 137 | // the methods may not be visible in the local cache. Once all that data is 138 | // visible, we will send a 'login' method. Once the login method has returned 139 | // and all the data is visible (including re-running subs if userId changes), 140 | // we will send the 'foo' and 'bar' methods in parallel. 141 | self._outstandingMethodBlocks = []; 142 | 143 | // method ID -> array of objects with keys 'collection' and 'id', listing 144 | // documents written by a given method's stub. keys are associated with 145 | // methods whose stub wrote at least one document, and whose data-done message 146 | // has not yet been received. 147 | self._documentsWrittenByStub = {}; 148 | // collection -> IdMap of "server document" object. A "server document" has: 149 | // - "document": the version of the document according the 150 | // server (ie, the snapshot before a stub wrote it, amended by any changes 151 | // received from the server) 152 | // It is undefined if we think the document does not exist 153 | // - "writtenByStubs": a set of method IDs whose stubs wrote to the document 154 | // whose "data done" messages have not yet been processed 155 | self._serverDocuments = {}; 156 | 157 | // Array of callbacks to be called after the next update of the local 158 | // cache. Used for: 159 | // - Calling methodInvoker.dataVisible and sub ready callbacks after 160 | // the relevant data is flushed. 161 | // - Invoking the callbacks of "half-finished" methods after reconnect 162 | // quiescence. Specifically, methods whose result was received over the old 163 | // connection (so we don't re-send it) but whose data had not been made 164 | // visible. 165 | self._afterUpdateCallbacks = []; 166 | 167 | // In two contexts, we buffer all incoming data messages and then process them 168 | // all at once in a single update: 169 | // - During reconnect, we buffer all data messages until all subs that had 170 | // been ready before reconnect are ready again, and all methods that are 171 | // active have returned their "data done message"; then 172 | // - During the execution of a "wait" method, we buffer all data messages 173 | // until the wait method gets its "data done" message. (If the wait method 174 | // occurs during reconnect, it doesn't get any special handling.) 175 | // all data messages are processed in one update. 176 | // 177 | // The following fields are used for this "quiescence" process. 178 | 179 | // This buffers the messages that aren't being processed yet. 180 | self._messagesBufferedUntilQuiescence = []; 181 | // Map from method ID -> true. Methods are removed from this when their 182 | // "data done" message is received, and we will not quiesce until it is 183 | // empty. 184 | self._methodsBlockingQuiescence = {}; 185 | // map from sub ID -> true for subs that were ready (ie, called the sub 186 | // ready callback) before reconnect but haven't become ready again yet 187 | self._subsBeingRevived = {}; // map from sub._id -> true 188 | // if true, the next data update should reset all stores. (set during 189 | // reconnect.) 190 | self._resetStores = false; 191 | 192 | // name -> array of updates for (yet to be created) collections 193 | self._updatesForUnknownStores = {}; 194 | // if we're blocking a migration, the retry func 195 | self._retryMigrate = null; 196 | 197 | self.__flushBufferedWrites = Meteor.bindEnvironment( 198 | self._flushBufferedWrites, 199 | 'flushing DDP buffered writes', 200 | self 201 | ); 202 | // Collection name -> array of messages. 203 | self._bufferedWrites = {}; 204 | // When current buffer of updates must be flushed at, in ms timestamp. 205 | self._bufferedWritesFlushAt = null; 206 | // Timeout handle for the next processing of all pending writes 207 | self._bufferedWritesFlushHandle = null; 208 | 209 | self._bufferedWritesInterval = options.bufferedWritesInterval; 210 | self._bufferedWritesMaxAge = options.bufferedWritesMaxAge; 211 | 212 | // metadata for subscriptions. Map from sub ID to object with keys: 213 | // - id 214 | // - name 215 | // - params 216 | // - inactive (if true, will be cleaned up if not reused in re-run) 217 | // - ready (has the 'ready' message been received?) 218 | // - readyCallback (an optional callback to call when ready) 219 | // - errorCallback (an optional callback to call if the sub terminates with 220 | // an error, XXX COMPAT WITH 1.0.3.1) 221 | // - stopCallback (an optional callback to call when the sub terminates 222 | // for any reason, with an error argument if an error triggered the stop) 223 | self._subscriptions = {}; 224 | 225 | // Reactive userId. 226 | self._userId = null; 227 | self._userIdDeps = new Tracker.Dependency(); 228 | 229 | // Block auto-reload while we're waiting for method responses. 230 | if (Meteor.isClient && Package.reload && !options.reloadWithOutstanding) { 231 | Package.reload.Reload._onMigrate(function(retry) { 232 | if (!self._readyToMigrate()) { 233 | if (self._retryMigrate) throw new Error('Two migrations in progress?'); 234 | self._retryMigrate = retry; 235 | return false; 236 | } else { 237 | return [true]; 238 | } 239 | }); 240 | } 241 | 242 | var onMessage = function(raw_msg) { 243 | try { 244 | var msg = DDPCommon.parseDDP(raw_msg); 245 | } catch (e) { 246 | Meteor._debug('Exception while parsing DDP', e); 247 | return; 248 | } 249 | 250 | // Any message counts as receiving a pong, as it demonstrates that 251 | // the server is still alive. 252 | if (self._heartbeat) { 253 | self._heartbeat.messageReceived(); 254 | } 255 | 256 | if (msg === null || !msg.msg) { 257 | // XXX COMPAT WITH 0.6.6. ignore the old welcome message for back 258 | // compat. Remove this 'if' once the server stops sending welcome 259 | // messages (stream_server.js). 260 | if (!(msg && msg.server_id)) 261 | Meteor._debug('discarding invalid livedata message', msg); 262 | return; 263 | } 264 | 265 | if (msg.msg === 'connected') { 266 | self._version = self._versionSuggestion; 267 | self._livedata_connected(msg); 268 | options.onConnected(); 269 | } else if (msg.msg === 'failed') { 270 | if (_.contains(self._supportedDDPVersions, msg.version)) { 271 | self._versionSuggestion = msg.version; 272 | self._stream.reconnect({ _force: true }); 273 | } else { 274 | var description = 275 | 'DDP version negotiation failed; server requested version ' + 276 | msg.version; 277 | self._stream.disconnect({ _permanent: true, _error: description }); 278 | options.onDDPVersionNegotiationFailure(description); 279 | } 280 | } else if (msg.msg === 'ping' && options.respondToPings) { 281 | self._send({ msg: 'pong', id: msg.id }); 282 | } else if (msg.msg === 'pong') { 283 | // noop, as we assume everything's a pong 284 | } else if ( 285 | _.include(['added', 'changed', 'removed', 'ready', 'updated'], msg.msg) 286 | ) 287 | self._livedata_data(msg); 288 | else if (msg.msg === 'nosub') self._livedata_nosub(msg); 289 | else if (msg.msg === 'result') self._livedata_result(msg); 290 | else if (msg.msg === 'error') self._livedata_error(msg); 291 | else Meteor._debug('discarding unknown livedata message type', msg); 292 | }; 293 | 294 | var onReset = function() { 295 | // Send a connect message at the beginning of the stream. 296 | // NOTE: reset is called even on the first connection, so this is 297 | // the only place we send this message. 298 | var msg = { msg: 'connect' }; 299 | if (self._lastSessionId) msg.session = self._lastSessionId; 300 | msg.version = self._versionSuggestion || self._supportedDDPVersions[0]; 301 | self._versionSuggestion = msg.version; 302 | msg.support = self._supportedDDPVersions; 303 | self._send(msg); 304 | 305 | // Mark non-retry calls as failed. This has to be done early as getting these methods out of the 306 | // current block is pretty important to making sure that quiescence is properly calculated, as 307 | // well as possibly moving on to another useful block. 308 | 309 | // Only bother testing if there is an outstandingMethodBlock (there might not be, especially if 310 | // we are connecting for the first time. 311 | if (self._outstandingMethodBlocks.length > 0) { 312 | // If there is an outstanding method block, we only care about the first one as that is the 313 | // one that could have already sent messages with no response, that are not allowed to retry. 314 | const currentMethodBlock = self._outstandingMethodBlocks[0].methods; 315 | self._outstandingMethodBlocks[0].methods = currentMethodBlock.filter( 316 | methodInvoker => { 317 | // Methods with 'noRetry' option set are not allowed to re-send after 318 | // recovering dropped connection. 319 | if (methodInvoker.sentMessage && methodInvoker.noRetry) { 320 | // Make sure that the method is told that it failed. 321 | methodInvoker.receiveResult( 322 | new Meteor.Error( 323 | 'invocation-failed', 324 | 'Method invocation might have failed due to dropped connection. ' + 325 | 'Failing because `noRetry` option was passed to Meteor.apply.' 326 | ) 327 | ); 328 | } 329 | 330 | // Only keep a method if it wasn't sent or it's allowed to retry. 331 | // This may leave the block empty, but we don't move on to the next 332 | // block until the callback has been delivered, in _outstandingMethodFinished. 333 | return !(methodInvoker.sentMessage && methodInvoker.noRetry); 334 | } 335 | ); 336 | } 337 | 338 | // Now, to minimize setup latency, go ahead and blast out all of 339 | // our pending methods ands subscriptions before we've even taken 340 | // the necessary RTT to know if we successfully reconnected. (1) 341 | // They're supposed to be idempotent, and where they are not, 342 | // they can block retry in apply; (2) even if we did reconnect, 343 | // we're not sure what messages might have gotten lost 344 | // (in either direction) since we were disconnected (TCP being 345 | // sloppy about that.) 346 | 347 | // If the current block of methods all got their results (but didn't all get 348 | // their data visible), discard the empty block now. 349 | if ( 350 | !_.isEmpty(self._outstandingMethodBlocks) && 351 | _.isEmpty(self._outstandingMethodBlocks[0].methods) 352 | ) { 353 | self._outstandingMethodBlocks.shift(); 354 | } 355 | 356 | // Mark all messages as unsent, they have not yet been sent on this 357 | // connection. 358 | _.each(self._methodInvokers, function(m) { 359 | m.sentMessage = false; 360 | }); 361 | 362 | // If an `onReconnect` handler is set, call it first. Go through 363 | // some hoops to ensure that methods that are called from within 364 | // `onReconnect` get executed _before_ ones that were originally 365 | // outstanding (since `onReconnect` is used to re-establish auth 366 | // certificates) 367 | self._callOnReconnectAndSendAppropriateOutstandingMethods(); 368 | 369 | // add new subscriptions at the end. this way they take effect after 370 | // the handlers and we don't see flicker. 371 | _.each(self._subscriptions, function(sub, id) { 372 | self._send({ 373 | msg: 'sub', 374 | id: id, 375 | name: sub.name, 376 | params: sub.params 377 | }); 378 | }); 379 | }; 380 | 381 | var onDisconnect = function() { 382 | if (self._heartbeat) { 383 | self._heartbeat.stop(); 384 | self._heartbeat = null; 385 | } 386 | }; 387 | 388 | if (Meteor.isServer) { 389 | self._stream.on( 390 | 'message', 391 | Meteor.bindEnvironment(onMessage, 'handling DDP message') 392 | ); 393 | self._stream.on( 394 | 'reset', 395 | Meteor.bindEnvironment(onReset, 'handling DDP reset') 396 | ); 397 | self._stream.on( 398 | 'disconnect', 399 | Meteor.bindEnvironment(onDisconnect, 'handling DDP disconnect') 400 | ); 401 | } else { 402 | self._stream.on('message', onMessage); 403 | self._stream.on('reset', onReset); 404 | self._stream.on('disconnect', onDisconnect); 405 | } 406 | }; 407 | 408 | // A MethodInvoker manages sending a method to the server and calling the user's 409 | // callbacks. On construction, it registers itself in the connection's 410 | // _methodInvokers map; it removes itself once the method is fully finished and 411 | // the callback is invoked. This occurs when it has both received a result, 412 | // and the data written by it is fully visible. 413 | var MethodInvoker = function(options) { 414 | var self = this; 415 | 416 | // Public (within this file) fields. 417 | self.methodId = options.methodId; 418 | self.sentMessage = false; 419 | 420 | self._callback = options.callback; 421 | self._connection = options.connection; 422 | self._message = options.message; 423 | self._onResultReceived = options.onResultReceived || function() {}; 424 | self._wait = options.wait; 425 | self.noRetry = options.noRetry; 426 | self._methodResult = null; 427 | self._dataVisible = false; 428 | 429 | // Register with the connection. 430 | self._connection._methodInvokers[self.methodId] = self; 431 | }; 432 | _.extend(MethodInvoker.prototype, { 433 | // Sends the method message to the server. May be called additional times if 434 | // we lose the connection and reconnect before receiving a result. 435 | sendMessage: function() { 436 | var self = this; 437 | // This function is called before sending a method (including resending on 438 | // reconnect). We should only (re)send methods where we don't already have a 439 | // result! 440 | if (self.gotResult()) 441 | throw new Error('sendingMethod is called on method with result'); 442 | 443 | // If we're re-sending it, it doesn't matter if data was written the first 444 | // time. 445 | self._dataVisible = false; 446 | self.sentMessage = true; 447 | 448 | // If this is a wait method, make all data messages be buffered until it is 449 | // done. 450 | if (self._wait) 451 | self._connection._methodsBlockingQuiescence[self.methodId] = true; 452 | 453 | // Actually send the message. 454 | self._connection._send(self._message); 455 | }, 456 | // Invoke the callback, if we have both a result and know that all data has 457 | // been written to the local cache. 458 | _maybeInvokeCallback: function() { 459 | var self = this; 460 | if (self._methodResult && self._dataVisible) { 461 | // Call the callback. (This won't throw: the callback was wrapped with 462 | // bindEnvironment.) 463 | self._callback(self._methodResult[0], self._methodResult[1]); 464 | 465 | // Forget about this method. 466 | delete self._connection._methodInvokers[self.methodId]; 467 | 468 | // Let the connection know that this method is finished, so it can try to 469 | // move on to the next block of methods. 470 | self._connection._outstandingMethodFinished(); 471 | } 472 | }, 473 | // Call with the result of the method from the server. Only may be called 474 | // once; once it is called, you should not call sendMessage again. 475 | // If the user provided an onResultReceived callback, call it immediately. 476 | // Then invoke the main callback if data is also visible. 477 | receiveResult: function(err, result) { 478 | var self = this; 479 | if (self.gotResult()) 480 | throw new Error('Methods should only receive results once'); 481 | self._methodResult = [err, result]; 482 | self._onResultReceived(err, result); 483 | self._maybeInvokeCallback(); 484 | }, 485 | // Call this when all data written by the method is visible. This means that 486 | // the method has returns its "data is done" message *AND* all server 487 | // documents that are buffered at that time have been written to the local 488 | // cache. Invokes the main callback if the result has been received. 489 | dataVisible: function() { 490 | var self = this; 491 | self._dataVisible = true; 492 | self._maybeInvokeCallback(); 493 | }, 494 | // True if receiveResult has been called. 495 | gotResult: function() { 496 | var self = this; 497 | return !!self._methodResult; 498 | } 499 | }); 500 | 501 | _.extend(Connection.prototype, { 502 | // 'name' is the name of the data on the wire that should go in the 503 | // store. 'wrappedStore' should be an object with methods beginUpdate, update, 504 | // endUpdate, saveOriginals, retrieveOriginals. see Collection for an example. 505 | registerStore: function(name, wrappedStore) { 506 | var self = this; 507 | 508 | if (name in self._stores) return false; 509 | 510 | // Wrap the input object in an object which makes any store method not 511 | // implemented by 'store' into a no-op. 512 | var store = {}; 513 | _.each( 514 | [ 515 | 'update', 516 | 'beginUpdate', 517 | 'endUpdate', 518 | 'saveOriginals', 519 | 'retrieveOriginals', 520 | 'getDoc', 521 | '_getCollection' 522 | ], 523 | function(method) { 524 | store[method] = function() { 525 | return wrappedStore[method] 526 | ? wrappedStore[method].apply(wrappedStore, arguments) 527 | : undefined; 528 | }; 529 | } 530 | ); 531 | 532 | self._stores[name] = store; 533 | 534 | var queued = self._updatesForUnknownStores[name]; 535 | if (queued) { 536 | store.beginUpdate(queued.length, false); 537 | _.each(queued, function(msg) { 538 | store.update(msg); 539 | }); 540 | store.endUpdate(); 541 | delete self._updatesForUnknownStores[name]; 542 | } 543 | 544 | return true; 545 | }, 546 | 547 | /** 548 | * @memberOf Meteor 549 | * @importFromPackage meteor 550 | * @summary Subscribe to a record set. Returns a handle that provides 551 | * `stop()` and `ready()` methods. 552 | * @locus Client 553 | * @param {String} name Name of the subscription. Matches the name of the 554 | * server's `publish()` call. 555 | * @param {EJSONable} [arg1,arg2...] Optional arguments passed to publisher 556 | * function on server. 557 | * @param {Function|Object} [callbacks] Optional. May include `onStop` 558 | * and `onReady` callbacks. If there is an error, it is passed as an 559 | * argument to `onStop`. If a function is passed instead of an object, it 560 | * is interpreted as an `onReady` callback. 561 | */ 562 | subscribe: function(name /* .. [arguments] .. (callback|callbacks) */) { 563 | var self = this; 564 | 565 | var params = Array.prototype.slice.call(arguments, 1); 566 | var callbacks = {}; 567 | if (params.length) { 568 | var lastParam = params[params.length - 1]; 569 | if (_.isFunction(lastParam)) { 570 | callbacks.onReady = params.pop(); 571 | } else if ( 572 | lastParam && 573 | // XXX COMPAT WITH 1.0.3.1 onError used to exist, but now we use 574 | // onStop with an error callback instead. 575 | _.any( 576 | [lastParam.onReady, lastParam.onError, lastParam.onStop], 577 | _.isFunction 578 | ) 579 | ) { 580 | callbacks = params.pop(); 581 | } 582 | } 583 | 584 | // Is there an existing sub with the same name and param, run in an 585 | // invalidated Computation? This will happen if we are rerunning an 586 | // existing computation. 587 | // 588 | // For example, consider a rerun of: 589 | // 590 | // Tracker.autorun(function () { 591 | // Meteor.subscribe("foo", Session.get("foo")); 592 | // Meteor.subscribe("bar", Session.get("bar")); 593 | // }); 594 | // 595 | // If "foo" has changed but "bar" has not, we will match the "bar" 596 | // subcribe to an existing inactive subscription in order to not 597 | // unsub and resub the subscription unnecessarily. 598 | // 599 | // We only look for one such sub; if there are N apparently-identical subs 600 | // being invalidated, we will require N matching subscribe calls to keep 601 | // them all active. 602 | var existing = _.find(self._subscriptions, function(sub) { 603 | return ( 604 | sub.inactive && sub.name === name && EJSON.equals(sub.params, params) 605 | ); 606 | }); 607 | 608 | var id; 609 | if (existing) { 610 | id = existing.id; 611 | existing.inactive = false; // reactivate 612 | 613 | if (callbacks.onReady) { 614 | // If the sub is not already ready, replace any ready callback with the 615 | // one provided now. (It's not really clear what users would expect for 616 | // an onReady callback inside an autorun; the semantics we provide is 617 | // that at the time the sub first becomes ready, we call the last 618 | // onReady callback provided, if any.) 619 | // If the sub is already ready, run the ready callback right away. 620 | // It seems that users would expect an onReady callback inside an 621 | // autorun to trigger once the the sub first becomes ready and also 622 | // when re-subs happens. 623 | if (existing.ready) { 624 | callbacks.onReady(); 625 | } else { 626 | existing.readyCallback = callbacks.onReady; 627 | } 628 | } 629 | 630 | // XXX COMPAT WITH 1.0.3.1 we used to have onError but now we call 631 | // onStop with an optional error argument 632 | if (callbacks.onError) { 633 | // Replace existing callback if any, so that errors aren't 634 | // double-reported. 635 | existing.errorCallback = callbacks.onError; 636 | } 637 | 638 | if (callbacks.onStop) { 639 | existing.stopCallback = callbacks.onStop; 640 | } 641 | } else { 642 | // New sub! Generate an id, save it locally, and send message. 643 | id = Random.id(); 644 | self._subscriptions[id] = { 645 | id: id, 646 | name: name, 647 | params: EJSON.clone(params), 648 | inactive: false, 649 | ready: false, 650 | readyDeps: new Tracker.Dependency(), 651 | readyCallback: callbacks.onReady, 652 | // XXX COMPAT WITH 1.0.3.1 #errorCallback 653 | errorCallback: callbacks.onError, 654 | stopCallback: callbacks.onStop, 655 | connection: self, 656 | remove: function() { 657 | delete this.connection._subscriptions[this.id]; 658 | this.ready && this.readyDeps.changed(); 659 | }, 660 | stop: function() { 661 | this.connection._send({ msg: 'unsub', id: id }); 662 | this.remove(); 663 | 664 | if (callbacks.onStop) { 665 | callbacks.onStop(); 666 | } 667 | } 668 | }; 669 | self._send({ msg: 'sub', id: id, name: name, params: params }); 670 | } 671 | 672 | // return a handle to the application. 673 | var handle = { 674 | stop: function() { 675 | if (!_.has(self._subscriptions, id)) return; 676 | 677 | self._subscriptions[id].stop(); 678 | }, 679 | ready: function() { 680 | // return false if we've unsubscribed. 681 | if (!_.has(self._subscriptions, id)) return false; 682 | var record = self._subscriptions[id]; 683 | record.readyDeps.depend(); 684 | return record.ready; 685 | }, 686 | subscriptionId: id 687 | }; 688 | 689 | if (Tracker.active) { 690 | // We're in a reactive computation, so we'd like to unsubscribe when the 691 | // computation is invalidated... but not if the rerun just re-subscribes 692 | // to the same subscription! When a rerun happens, we use onInvalidate 693 | // as a change to mark the subscription "inactive" so that it can 694 | // be reused from the rerun. If it isn't reused, it's killed from 695 | // an afterFlush. 696 | Tracker.onInvalidate(function(c) { 697 | if (_.has(self._subscriptions, id)) 698 | self._subscriptions[id].inactive = true; 699 | 700 | Tracker.afterFlush(function() { 701 | if ( 702 | _.has(self._subscriptions, id) && 703 | self._subscriptions[id].inactive 704 | ) 705 | handle.stop(); 706 | }); 707 | }); 708 | } 709 | 710 | return handle; 711 | }, 712 | 713 | // options: 714 | // - onLateError {Function(error)} called if an error was received after the ready event. 715 | // (errors received before ready cause an error to be thrown) 716 | _subscribeAndWait: function(name, args, options) { 717 | var self = this; 718 | var f = new Future(); 719 | var ready = false; 720 | var handle; 721 | args = args || []; 722 | args.push({ 723 | onReady: function() { 724 | ready = true; 725 | f['return'](); 726 | }, 727 | onError: function(e) { 728 | if (!ready) f['throw'](e); 729 | else options && options.onLateError && options.onLateError(e); 730 | } 731 | }); 732 | 733 | handle = self.subscribe.apply(self, [name].concat(args)); 734 | f.wait(); 735 | return handle; 736 | }, 737 | 738 | methods: function(methods) { 739 | var self = this; 740 | _.each(methods, function(func, name) { 741 | if (typeof func !== 'function') 742 | throw new Error("Method '" + name + "' must be a function"); 743 | if (self._methodHandlers[name]) 744 | throw new Error("A method named '" + name + "' is already defined"); 745 | self._methodHandlers[name] = func; 746 | }); 747 | }, 748 | 749 | /** 750 | * @memberOf Meteor 751 | * @importFromPackage meteor 752 | * @summary Invokes a method passing any number of arguments. 753 | * @locus Anywhere 754 | * @param {String} name Name of method to invoke 755 | * @param {EJSONable} [arg1,arg2...] Optional method arguments 756 | * @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). 757 | */ 758 | call: function(name /* .. [arguments] .. callback */) { 759 | // if it's a function, the last argument is the result callback, 760 | // not a parameter to the remote method. 761 | var args = Array.prototype.slice.call(arguments, 1); 762 | if (args.length && typeof args[args.length - 1] === 'function') 763 | var callback = args.pop(); 764 | return this.apply(name, args, callback); 765 | }, 766 | 767 | // @param options {Optional Object} 768 | // wait: Boolean - Should we wait to call this until all current methods 769 | // are fully finished, and block subsequent method calls 770 | // until this method is fully finished? 771 | // (does not affect methods called from within this method) 772 | // onResultReceived: Function - a callback to call as soon as the method 773 | // result is received. the data written by 774 | // the method may not yet be in the cache! 775 | // returnStubValue: Boolean - If true then in cases where we would have 776 | // otherwise discarded the stub's return value 777 | // and returned undefined, instead we go ahead 778 | // and return it. Specifically, this is any 779 | // time other than when (a) we are already 780 | // inside a stub or (b) we are in Node and no 781 | // callback was provided. Currently we require 782 | // this flag to be explicitly passed to reduce 783 | // the likelihood that stub return values will 784 | // be confused with server return values; we 785 | // may improve this in future. 786 | // @param callback {Optional Function} 787 | 788 | /** 789 | * @memberOf Meteor 790 | * @importFromPackage meteor 791 | * @summary Invoke a method passing an array of arguments. 792 | * @locus Anywhere 793 | * @param {String} name Name of method to invoke 794 | * @param {EJSONable[]} args Method arguments 795 | * @param {Object} [options] 796 | * @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. 797 | * @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. 798 | * @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'. 799 | * @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. 800 | * @param {Function} [asyncCallback] Optional callback; same semantics as in [`Meteor.call`](#meteor_call). 801 | */ 802 | apply: function(name, args, options, callback) { 803 | var self = this; 804 | 805 | // We were passed 3 arguments. They may be either (name, args, options) 806 | // or (name, args, callback) 807 | if (!callback && typeof options === 'function') { 808 | callback = options; 809 | options = {}; 810 | } 811 | options = options || {}; 812 | 813 | if (callback) { 814 | // XXX would it be better form to do the binding in stream.on, 815 | // or caller, instead of here? 816 | // XXX improve error message (and how we report it) 817 | callback = Meteor.bindEnvironment( 818 | callback, 819 | "delivering result of invoking '" + name + "'" 820 | ); 821 | } 822 | 823 | // Keep our args safe from mutation (eg if we don't send the message for a 824 | // while because of a wait method). 825 | args = EJSON.clone(args); 826 | 827 | // Lazily allocate method ID once we know that it'll be needed. 828 | var methodId = (function() { 829 | var id; 830 | return function() { 831 | if (id === undefined) id = '' + self._nextMethodId++; 832 | return id; 833 | }; 834 | })(); 835 | 836 | var enclosing = DDP._CurrentMethodInvocation.get(); 837 | var alreadyInSimulation = enclosing && enclosing.isSimulation; 838 | 839 | // Lazily generate a randomSeed, only if it is requested by the stub. 840 | // The random streams only have utility if they're used on both the client 841 | // and the server; if the client doesn't generate any 'random' values 842 | // then we don't expect the server to generate any either. 843 | // Less commonly, the server may perform different actions from the client, 844 | // and may in fact generate values where the client did not, but we don't 845 | // have any client-side values to match, so even here we may as well just 846 | // use a random seed on the server. In that case, we don't pass the 847 | // randomSeed to save bandwidth, and we don't even generate it to save a 848 | // bit of CPU and to avoid consuming entropy. 849 | var randomSeed = null; 850 | var randomSeedGenerator = function() { 851 | if (randomSeed === null) { 852 | randomSeed = DDPCommon.makeRpcSeed(enclosing, name); 853 | } 854 | return randomSeed; 855 | }; 856 | 857 | // Run the stub, if we have one. The stub is supposed to make some 858 | // temporary writes to the database to give the user a smooth experience 859 | // until the actual result of executing the method comes back from the 860 | // server (whereupon the temporary writes to the database will be reversed 861 | // during the beginUpdate/endUpdate process.) 862 | // 863 | // Normally, we ignore the return value of the stub (even if it is an 864 | // exception), in favor of the real return value from the server. The 865 | // exception is if the *caller* is a stub. In that case, we're not going 866 | // to do a RPC, so we use the return value of the stub as our return 867 | // value. 868 | 869 | var stub = self._methodHandlers[name]; 870 | if (stub) { 871 | var setUserId = function(userId) { 872 | self.setUserId(userId); 873 | }; 874 | 875 | var invocation = new DDPCommon.MethodInvocation({ 876 | isSimulation: true, 877 | userId: self.userId(), 878 | setUserId: setUserId, 879 | randomSeed: function() { 880 | return randomSeedGenerator(); 881 | } 882 | }); 883 | 884 | if (!alreadyInSimulation) self._saveOriginals(); 885 | 886 | try { 887 | // Note that unlike in the corresponding server code, we never audit 888 | // that stubs check() their arguments. 889 | var stubReturnValue = DDP._CurrentMethodInvocation.withValue( 890 | invocation, 891 | function() { 892 | if (Meteor.isServer) { 893 | // Because saveOriginals and retrieveOriginals aren't reentrant, 894 | // don't allow stubs to yield. 895 | return Meteor._noYieldsAllowed(function() { 896 | // re-clone, so that the stub can't affect our caller's values 897 | return stub.apply(invocation, EJSON.clone(args)); 898 | }); 899 | } else { 900 | return stub.apply(invocation, EJSON.clone(args)); 901 | } 902 | } 903 | ); 904 | } catch (e) { 905 | var exception = e; 906 | } 907 | 908 | if (!alreadyInSimulation) self._retrieveAndStoreOriginals(methodId()); 909 | } 910 | 911 | // If we're in a simulation, stop and return the result we have, 912 | // rather than going on to do an RPC. If there was no stub, 913 | // we'll end up returning undefined. 914 | if (alreadyInSimulation) { 915 | if (callback) { 916 | callback(exception, stubReturnValue); 917 | return undefined; 918 | } 919 | if (exception) throw exception; 920 | return stubReturnValue; 921 | } 922 | 923 | // If an exception occurred in a stub, and we're ignoring it 924 | // because we're doing an RPC and want to use what the server 925 | // returns instead, log it so the developer knows 926 | // (unless they explicitly ask to see the error). 927 | // 928 | // Tests can set the 'expected' flag on an exception so it won't 929 | // go to log. 930 | if (exception) { 931 | if (options.throwStubExceptions) { 932 | throw exception; 933 | } else if (!exception.expected) { 934 | Meteor._debug( 935 | "Exception while simulating the effect of invoking '" + name + "'", 936 | exception, 937 | exception.stack 938 | ); 939 | } 940 | } 941 | 942 | // At this point we're definitely doing an RPC, and we're going to 943 | // return the value of the RPC to the caller. 944 | 945 | // If the caller didn't give a callback, decide what to do. 946 | if (!callback) { 947 | if (Meteor.isClient) { 948 | // On the client, we don't have fibers, so we can't block. The 949 | // only thing we can do is to return undefined and discard the 950 | // result of the RPC. If an error occurred then print the error 951 | // to the console. 952 | callback = function(err) { 953 | err && 954 | Meteor._debug("Error invoking Method '" + name + "':", err.message); 955 | }; 956 | } else { 957 | // On the server, make the function synchronous. Throw on 958 | // errors, return on success. 959 | var future = new Future(); 960 | callback = future.resolver(); 961 | } 962 | } 963 | // Send the RPC. Note that on the client, it is important that the 964 | // stub have finished before we send the RPC, so that we know we have 965 | // a complete list of which local documents the stub wrote. 966 | var message = { 967 | msg: 'method', 968 | method: name, 969 | params: args, 970 | id: methodId() 971 | }; 972 | 973 | // Send the randomSeed only if we used it 974 | if (randomSeed !== null) { 975 | message.randomSeed = randomSeed; 976 | } 977 | 978 | var methodInvoker = new MethodInvoker({ 979 | methodId: methodId(), 980 | callback: callback, 981 | connection: self, 982 | onResultReceived: options.onResultReceived, 983 | wait: !!options.wait, 984 | message: message, 985 | noRetry: !!options.noRetry 986 | }); 987 | 988 | if (options.wait) { 989 | // It's a wait method! Wait methods go in their own block. 990 | self._outstandingMethodBlocks.push({ 991 | wait: true, 992 | methods: [methodInvoker] 993 | }); 994 | } else { 995 | // Not a wait method. Start a new block if the previous block was a wait 996 | // block, and add it to the last block of methods. 997 | if ( 998 | _.isEmpty(self._outstandingMethodBlocks) || 999 | _.last(self._outstandingMethodBlocks).wait 1000 | ) 1001 | self._outstandingMethodBlocks.push({ wait: false, methods: [] }); 1002 | _.last(self._outstandingMethodBlocks).methods.push(methodInvoker); 1003 | } 1004 | 1005 | // If we added it to the first block, send it out now. 1006 | if (self._outstandingMethodBlocks.length === 1) methodInvoker.sendMessage(); 1007 | 1008 | // If we're using the default callback on the server, 1009 | // block waiting for the result. 1010 | if (future) { 1011 | return future.wait(); 1012 | } 1013 | return options.returnStubValue ? stubReturnValue : undefined; 1014 | }, 1015 | 1016 | // Before calling a method stub, prepare all stores to track changes and allow 1017 | // _retrieveAndStoreOriginals to get the original versions of changed 1018 | // documents. 1019 | _saveOriginals: function() { 1020 | var self = this; 1021 | if (!self._waitingForQuiescence()) self._flushBufferedWrites(); 1022 | _.each(self._stores, function(s) { 1023 | s.saveOriginals(); 1024 | }); 1025 | }, 1026 | // Retrieves the original versions of all documents modified by the stub for 1027 | // method 'methodId' from all stores and saves them to _serverDocuments (keyed 1028 | // by document) and _documentsWrittenByStub (keyed by method ID). 1029 | _retrieveAndStoreOriginals: function(methodId) { 1030 | var self = this; 1031 | if (self._documentsWrittenByStub[methodId]) 1032 | throw new Error('Duplicate methodId in _retrieveAndStoreOriginals'); 1033 | 1034 | var docsWritten = []; 1035 | _.each(self._stores, function(s, collection) { 1036 | var originals = s.retrieveOriginals(); 1037 | // not all stores define retrieveOriginals 1038 | if (!originals) return; 1039 | originals.forEach(function(doc, id) { 1040 | docsWritten.push({ collection: collection, id: id }); 1041 | if (!_.has(self._serverDocuments, collection)) 1042 | self._serverDocuments[collection] = new MongoIDMap(); 1043 | var serverDoc = self._serverDocuments[collection].setDefault(id, {}); 1044 | if (serverDoc.writtenByStubs) { 1045 | // We're not the first stub to write this doc. Just add our method ID 1046 | // to the record. 1047 | serverDoc.writtenByStubs[methodId] = true; 1048 | } else { 1049 | // First stub! Save the original value and our method ID. 1050 | serverDoc.document = doc; 1051 | serverDoc.flushCallbacks = []; 1052 | serverDoc.writtenByStubs = {}; 1053 | serverDoc.writtenByStubs[methodId] = true; 1054 | } 1055 | }); 1056 | }); 1057 | if (!_.isEmpty(docsWritten)) { 1058 | self._documentsWrittenByStub[methodId] = docsWritten; 1059 | } 1060 | }, 1061 | 1062 | // This is very much a private function we use to make the tests 1063 | // take up fewer server resources after they complete. 1064 | _unsubscribeAll: function() { 1065 | var self = this; 1066 | _.each(_.clone(self._subscriptions), function(sub, id) { 1067 | // Avoid killing the autoupdate subscription so that developers 1068 | // still get hot code pushes when writing tests. 1069 | // 1070 | // XXX it's a hack to encode knowledge about autoupdate here, 1071 | // but it doesn't seem worth it yet to have a special API for 1072 | // subscriptions to preserve after unit tests. 1073 | if (sub.name !== 'meteor_autoupdate_clientVersions') { 1074 | self._subscriptions[id].stop(); 1075 | } 1076 | }); 1077 | }, 1078 | 1079 | // Sends the DDP stringification of the given message object 1080 | _send: function(obj) { 1081 | var self = this; 1082 | self._stream.send(DDPCommon.stringifyDDP(obj)); 1083 | }, 1084 | 1085 | // We detected via DDP-level heartbeats that we've lost the 1086 | // connection. Unlike `disconnect` or `close`, a lost connection 1087 | // will be automatically retried. 1088 | _lostConnection: function(error) { 1089 | var self = this; 1090 | self._stream._lostConnection(error); 1091 | }, 1092 | 1093 | /** 1094 | * @summary Get the current connection status. A reactive data source. 1095 | * @locus Client 1096 | * @memberOf Meteor 1097 | * @importFromPackage meteor 1098 | */ 1099 | status: function(/*passthrough args*/) { 1100 | var self = this; 1101 | return self._stream.status.apply(self._stream, arguments); 1102 | }, 1103 | 1104 | /** 1105 | * @summary Force an immediate reconnection attempt if the client is not connected to the server. 1106 | 1107 | This method does nothing if the client is already connected. 1108 | * @locus Client 1109 | * @memberOf Meteor 1110 | * @importFromPackage meteor 1111 | */ 1112 | reconnect: function(/*passthrough args*/) { 1113 | var self = this; 1114 | return self._stream.reconnect.apply(self._stream, arguments); 1115 | }, 1116 | 1117 | /** 1118 | * @summary Disconnect the client from the server. 1119 | * @locus Client 1120 | * @memberOf Meteor 1121 | * @importFromPackage meteor 1122 | */ 1123 | disconnect: function(/*passthrough args*/) { 1124 | var self = this; 1125 | return self._stream.disconnect.apply(self._stream, arguments); 1126 | }, 1127 | 1128 | close: function() { 1129 | var self = this; 1130 | return self._stream.disconnect({ _permanent: true }); 1131 | }, 1132 | 1133 | /// 1134 | /// Reactive user system 1135 | /// 1136 | userId: function() { 1137 | var self = this; 1138 | if (self._userIdDeps) self._userIdDeps.depend(); 1139 | return self._userId; 1140 | }, 1141 | 1142 | setUserId: function(userId) { 1143 | var self = this; 1144 | // Avoid invalidating dependents if setUserId is called with current value. 1145 | if (self._userId === userId) return; 1146 | self._userId = userId; 1147 | if (self._userIdDeps) self._userIdDeps.changed(); 1148 | }, 1149 | 1150 | // Returns true if we are in a state after reconnect of waiting for subs to be 1151 | // revived or early methods to finish their data, or we are waiting for a 1152 | // "wait" method to finish. 1153 | _waitingForQuiescence: function() { 1154 | var self = this; 1155 | return ( 1156 | !_.isEmpty(self._subsBeingRevived) || 1157 | !_.isEmpty(self._methodsBlockingQuiescence) 1158 | ); 1159 | }, 1160 | 1161 | // Returns true if any method whose message has been sent to the server has 1162 | // not yet invoked its user callback. 1163 | _anyMethodsAreOutstanding: function() { 1164 | var self = this; 1165 | return _.any(_.pluck(self._methodInvokers, 'sentMessage')); 1166 | }, 1167 | 1168 | _livedata_connected: function(msg) { 1169 | var self = this; 1170 | 1171 | if (self._version !== 'pre1' && self._heartbeatInterval !== 0) { 1172 | self._heartbeat = new DDPCommon.Heartbeat({ 1173 | heartbeatInterval: self._heartbeatInterval, 1174 | heartbeatTimeout: self._heartbeatTimeout, 1175 | onTimeout: function() { 1176 | self._lostConnection( 1177 | new DDP.ConnectionError('DDP heartbeat timed out') 1178 | ); 1179 | }, 1180 | sendPing: function() { 1181 | self._send({ msg: 'ping' }); 1182 | } 1183 | }); 1184 | self._heartbeat.start(); 1185 | } 1186 | 1187 | // If this is a reconnect, we'll have to reset all stores. 1188 | if (self._lastSessionId) self._resetStores = true; 1189 | 1190 | if (typeof msg.session === 'string') { 1191 | var reconnectedToPreviousSession = self._lastSessionId === msg.session; 1192 | self._lastSessionId = msg.session; 1193 | } 1194 | 1195 | if (reconnectedToPreviousSession) { 1196 | // Successful reconnection -- pick up where we left off. Note that right 1197 | // now, this never happens: the server never connects us to a previous 1198 | // session, because DDP doesn't provide enough data for the server to know 1199 | // what messages the client has processed. We need to improve DDP to make 1200 | // this possible, at which point we'll probably need more code here. 1201 | return; 1202 | } 1203 | 1204 | // Server doesn't have our data any more. Re-sync a new session. 1205 | 1206 | // Forget about messages we were buffering for unknown collections. They'll 1207 | // be resent if still relevant. 1208 | self._updatesForUnknownStores = {}; 1209 | 1210 | if (self._resetStores) { 1211 | // Forget about the effects of stubs. We'll be resetting all collections 1212 | // anyway. 1213 | self._documentsWrittenByStub = {}; 1214 | self._serverDocuments = {}; 1215 | } 1216 | 1217 | // Clear _afterUpdateCallbacks. 1218 | self._afterUpdateCallbacks = []; 1219 | 1220 | // Mark all named subscriptions which are ready (ie, we already called the 1221 | // ready callback) as needing to be revived. 1222 | // XXX We should also block reconnect quiescence until unnamed subscriptions 1223 | // (eg, autopublish) are done re-publishing to avoid flicker! 1224 | self._subsBeingRevived = {}; 1225 | _.each(self._subscriptions, function(sub, id) { 1226 | if (sub.ready) self._subsBeingRevived[id] = true; 1227 | }); 1228 | 1229 | // Arrange for "half-finished" methods to have their callbacks run, and 1230 | // track methods that were sent on this connection so that we don't 1231 | // quiesce until they are all done. 1232 | // 1233 | // Start by clearing _methodsBlockingQuiescence: methods sent before 1234 | // reconnect don't matter, and any "wait" methods sent on the new connection 1235 | // that we drop here will be restored by the loop below. 1236 | self._methodsBlockingQuiescence = {}; 1237 | if (self._resetStores) { 1238 | _.each(self._methodInvokers, function(invoker) { 1239 | if (invoker.gotResult()) { 1240 | // This method already got its result, but it didn't call its callback 1241 | // because its data didn't become visible. We did not resend the 1242 | // method RPC. We'll call its callback when we get a full quiesce, 1243 | // since that's as close as we'll get to "data must be visible". 1244 | self._afterUpdateCallbacks.push(_.bind(invoker.dataVisible, invoker)); 1245 | } else if (invoker.sentMessage) { 1246 | // This method has been sent on this connection (maybe as a resend 1247 | // from the last connection, maybe from onReconnect, maybe just very 1248 | // quickly before processing the connected message). 1249 | // 1250 | // We don't need to do anything special to ensure its callbacks get 1251 | // called, but we'll count it as a method which is preventing 1252 | // reconnect quiescence. (eg, it might be a login method that was run 1253 | // from onReconnect, and we don't want to see flicker by seeing a 1254 | // logged-out state.) 1255 | self._methodsBlockingQuiescence[invoker.methodId] = true; 1256 | } 1257 | }); 1258 | } 1259 | 1260 | self._messagesBufferedUntilQuiescence = []; 1261 | 1262 | // If we're not waiting on any methods or subs, we can reset the stores and 1263 | // call the callbacks immediately. 1264 | if (!self._waitingForQuiescence()) { 1265 | if (self._resetStores) { 1266 | _.each(self._stores, function(s) { 1267 | s.beginUpdate(0, true); 1268 | s.endUpdate(); 1269 | }); 1270 | self._resetStores = false; 1271 | } 1272 | self._runAfterUpdateCallbacks(); 1273 | } 1274 | }, 1275 | 1276 | _processOneDataMessage: function(msg, updates) { 1277 | var self = this; 1278 | // Using underscore here so as not to need to capitalize. 1279 | self['_process_' + msg.msg](msg, updates); 1280 | }, 1281 | 1282 | _livedata_data: function(msg) { 1283 | var self = this; 1284 | 1285 | if (self._waitingForQuiescence()) { 1286 | self._messagesBufferedUntilQuiescence.push(msg); 1287 | 1288 | if (msg.msg === 'nosub') delete self._subsBeingRevived[msg.id]; 1289 | 1290 | _.each(msg.subs || [], function(subId) { 1291 | delete self._subsBeingRevived[subId]; 1292 | }); 1293 | _.each(msg.methods || [], function(methodId) { 1294 | delete self._methodsBlockingQuiescence[methodId]; 1295 | }); 1296 | 1297 | if (self._waitingForQuiescence()) return; 1298 | 1299 | // No methods or subs are blocking quiescence! 1300 | // We'll now process and all of our buffered messages, reset all stores, 1301 | // and apply them all at once. 1302 | _.each(self._messagesBufferedUntilQuiescence, function(bufferedMsg) { 1303 | self._processOneDataMessage(bufferedMsg, self._bufferedWrites); 1304 | }); 1305 | self._messagesBufferedUntilQuiescence = []; 1306 | } else { 1307 | self._processOneDataMessage(msg, self._bufferedWrites); 1308 | } 1309 | 1310 | // Immediately flush writes when: 1311 | // 1. Buffering is disabled. Or; 1312 | // 2. any non-(added/changed/removed) message arrives. 1313 | var standardWrite = _.include(['added', 'changed', 'removed'], msg.msg); 1314 | if (self._bufferedWritesInterval === 0 || !standardWrite) { 1315 | self._flushBufferedWrites(); 1316 | return; 1317 | } 1318 | 1319 | if (self._bufferedWritesFlushAt === null) { 1320 | self._bufferedWritesFlushAt = 1321 | new Date().valueOf() + self._bufferedWritesMaxAge; 1322 | } else if (self._bufferedWritesFlushAt < new Date().valueOf()) { 1323 | self._flushBufferedWrites(); 1324 | return; 1325 | } 1326 | 1327 | if (self._bufferedWritesFlushHandle) { 1328 | clearTimeout(self._bufferedWritesFlushHandle); 1329 | } 1330 | self._bufferedWritesFlushHandle = setTimeout( 1331 | self.__flushBufferedWrites, 1332 | self._bufferedWritesInterval 1333 | ); 1334 | }, 1335 | 1336 | _flushBufferedWrites: function() { 1337 | var self = this; 1338 | if (self._bufferedWritesFlushHandle) { 1339 | clearTimeout(self._bufferedWritesFlushHandle); 1340 | self._bufferedWritesFlushHandle = null; 1341 | } 1342 | 1343 | self._bufferedWritesFlushAt = null; 1344 | // We need to clear the buffer before passing it to 1345 | // performWrites. As there's no guarantee that it 1346 | // will exit cleanly. 1347 | var writes = self._bufferedWrites; 1348 | self._bufferedWrites = {}; 1349 | self._performWrites(writes); 1350 | }, 1351 | 1352 | _performWrites: function(updates) { 1353 | var self = this; 1354 | 1355 | if (self._resetStores || !_.isEmpty(updates)) { 1356 | // Begin a transactional update of each store. 1357 | _.each(self._stores, function(s, storeName) { 1358 | s.beginUpdate( 1359 | _.has(updates, storeName) ? updates[storeName].length : 0, 1360 | self._resetStores 1361 | ); 1362 | }); 1363 | self._resetStores = false; 1364 | 1365 | _.each(updates, function(updateMessages, storeName) { 1366 | var store = self._stores[storeName]; 1367 | if (store) { 1368 | _.each(updateMessages, function(updateMessage) { 1369 | store.update(updateMessage); 1370 | }); 1371 | } else { 1372 | // Nobody's listening for this data. Queue it up until 1373 | // someone wants it. 1374 | // XXX memory use will grow without bound if you forget to 1375 | // create a collection or just don't care about it... going 1376 | // to have to do something about that. 1377 | if (!_.has(self._updatesForUnknownStores, storeName)) 1378 | self._updatesForUnknownStores[storeName] = []; 1379 | Array.prototype.push.apply( 1380 | self._updatesForUnknownStores[storeName], 1381 | updateMessages 1382 | ); 1383 | } 1384 | }); 1385 | 1386 | // End update transaction. 1387 | _.each(self._stores, function(s) { 1388 | s.endUpdate(); 1389 | }); 1390 | } 1391 | 1392 | self._runAfterUpdateCallbacks(); 1393 | }, 1394 | 1395 | // Call any callbacks deferred with _runWhenAllServerDocsAreFlushed whose 1396 | // relevant docs have been flushed, as well as dataVisible callbacks at 1397 | // reconnect-quiescence time. 1398 | _runAfterUpdateCallbacks: function() { 1399 | var self = this; 1400 | var callbacks = self._afterUpdateCallbacks; 1401 | self._afterUpdateCallbacks = []; 1402 | _.each(callbacks, function(c) { 1403 | c(); 1404 | }); 1405 | }, 1406 | 1407 | _pushUpdate: function(updates, collection, msg) { 1408 | var self = this; 1409 | if (!_.has(updates, collection)) { 1410 | updates[collection] = []; 1411 | } 1412 | updates[collection].push(msg); 1413 | }, 1414 | 1415 | _getServerDoc: function(collection, id) { 1416 | var self = this; 1417 | if (!_.has(self._serverDocuments, collection)) return null; 1418 | var serverDocsForCollection = self._serverDocuments[collection]; 1419 | return serverDocsForCollection.get(id) || null; 1420 | }, 1421 | 1422 | _process_added: function(msg, updates) { 1423 | var self = this; 1424 | var id = MongoID.idParse(msg.id); 1425 | var serverDoc = self._getServerDoc(msg.collection, id); 1426 | if (serverDoc) { 1427 | // Some outstanding stub wrote here. 1428 | var isExisting = serverDoc.document !== undefined; 1429 | 1430 | serverDoc.document = msg.fields || {}; 1431 | serverDoc.document._id = id; 1432 | 1433 | if (self._resetStores) { 1434 | // During reconnect the server is sending adds for existing ids. 1435 | // Always push an update so that document stays in the store after 1436 | // reset. Use current version of the document for this update, so 1437 | // that stub-written values are preserved. 1438 | var currentDoc = self._stores[msg.collection].getDoc(msg.id); 1439 | if (currentDoc !== undefined) msg.fields = currentDoc; 1440 | 1441 | self._pushUpdate(updates, msg.collection, msg); 1442 | } else if (isExisting) { 1443 | throw new Error('Server sent add for existing id: ' + msg.id); 1444 | } 1445 | } else { 1446 | self._pushUpdate(updates, msg.collection, msg); 1447 | } 1448 | }, 1449 | 1450 | _process_changed: function(msg, updates) { 1451 | var self = this; 1452 | var serverDoc = self._getServerDoc(msg.collection, MongoID.idParse(msg.id)); 1453 | if (serverDoc) { 1454 | if (serverDoc.document === undefined) 1455 | throw new Error('Server sent changed for nonexisting id: ' + msg.id); 1456 | DiffSequence.applyChanges(serverDoc.document, msg.fields); 1457 | } else { 1458 | self._pushUpdate(updates, msg.collection, msg); 1459 | } 1460 | }, 1461 | 1462 | _process_removed: function(msg, updates) { 1463 | var self = this; 1464 | var serverDoc = self._getServerDoc(msg.collection, MongoID.idParse(msg.id)); 1465 | if (serverDoc) { 1466 | // Some outstanding stub wrote here. 1467 | if (serverDoc.document === undefined) 1468 | throw new Error('Server sent removed for nonexisting id:' + msg.id); 1469 | serverDoc.document = undefined; 1470 | } else { 1471 | self._pushUpdate(updates, msg.collection, { 1472 | msg: 'removed', 1473 | collection: msg.collection, 1474 | id: msg.id 1475 | }); 1476 | } 1477 | }, 1478 | 1479 | _process_updated: function(msg, updates) { 1480 | var self = this; 1481 | // Process "method done" messages. 1482 | _.each(msg.methods, function(methodId) { 1483 | _.each(self._documentsWrittenByStub[methodId], function(written) { 1484 | var serverDoc = self._getServerDoc(written.collection, written.id); 1485 | if (!serverDoc) 1486 | throw new Error('Lost serverDoc for ' + JSON.stringify(written)); 1487 | if (!serverDoc.writtenByStubs[methodId]) 1488 | throw new Error( 1489 | 'Doc ' + 1490 | JSON.stringify(written) + 1491 | ' not written by method ' + 1492 | methodId 1493 | ); 1494 | delete serverDoc.writtenByStubs[methodId]; 1495 | if (_.isEmpty(serverDoc.writtenByStubs)) { 1496 | // All methods whose stubs wrote this method have completed! We can 1497 | // now copy the saved document to the database (reverting the stub's 1498 | // change if the server did not write to this object, or applying the 1499 | // server's writes if it did). 1500 | 1501 | // This is a fake ddp 'replace' message. It's just for talking 1502 | // between livedata connections and minimongo. (We have to stringify 1503 | // the ID because it's supposed to look like a wire message.) 1504 | self._pushUpdate(updates, written.collection, { 1505 | msg: 'replace', 1506 | id: MongoID.idStringify(written.id), 1507 | replace: serverDoc.document 1508 | }); 1509 | // Call all flush callbacks. 1510 | _.each(serverDoc.flushCallbacks, function(c) { 1511 | c(); 1512 | }); 1513 | 1514 | // Delete this completed serverDocument. Don't bother to GC empty 1515 | // IdMaps inside self._serverDocuments, since there probably aren't 1516 | // many collections and they'll be written repeatedly. 1517 | self._serverDocuments[written.collection].remove(written.id); 1518 | } 1519 | }); 1520 | delete self._documentsWrittenByStub[methodId]; 1521 | 1522 | // We want to call the data-written callback, but we can't do so until all 1523 | // currently buffered messages are flushed. 1524 | var callbackInvoker = self._methodInvokers[methodId]; 1525 | if (!callbackInvoker) 1526 | throw new Error('No callback invoker for method ' + methodId); 1527 | self._runWhenAllServerDocsAreFlushed( 1528 | _.bind(callbackInvoker.dataVisible, callbackInvoker) 1529 | ); 1530 | }); 1531 | }, 1532 | 1533 | _process_ready: function(msg, updates) { 1534 | var self = this; 1535 | // Process "sub ready" messages. "sub ready" messages don't take effect 1536 | // until all current server documents have been flushed to the local 1537 | // database. We can use a write fence to implement this. 1538 | _.each(msg.subs, function(subId) { 1539 | self._runWhenAllServerDocsAreFlushed(function() { 1540 | var subRecord = self._subscriptions[subId]; 1541 | // Did we already unsubscribe? 1542 | if (!subRecord) return; 1543 | // Did we already receive a ready message? (Oops!) 1544 | if (subRecord.ready) return; 1545 | subRecord.ready = true; 1546 | subRecord.readyCallback && subRecord.readyCallback(); 1547 | subRecord.readyDeps.changed(); 1548 | }); 1549 | }); 1550 | }, 1551 | 1552 | // Ensures that "f" will be called after all documents currently in 1553 | // _serverDocuments have been written to the local cache. f will not be called 1554 | // if the connection is lost before then! 1555 | _runWhenAllServerDocsAreFlushed: function(f) { 1556 | var self = this; 1557 | var runFAfterUpdates = function() { 1558 | self._afterUpdateCallbacks.push(f); 1559 | }; 1560 | var unflushedServerDocCount = 0; 1561 | var onServerDocFlush = function() { 1562 | --unflushedServerDocCount; 1563 | if (unflushedServerDocCount === 0) { 1564 | // This was the last doc to flush! Arrange to run f after the updates 1565 | // have been applied. 1566 | runFAfterUpdates(); 1567 | } 1568 | }; 1569 | _.each(self._serverDocuments, function(collectionDocs) { 1570 | collectionDocs.forEach(function(serverDoc) { 1571 | var writtenByStubForAMethodWithSentMessage = _.any( 1572 | serverDoc.writtenByStubs, 1573 | function(dummy, methodId) { 1574 | var invoker = self._methodInvokers[methodId]; 1575 | return invoker && invoker.sentMessage; 1576 | } 1577 | ); 1578 | if (writtenByStubForAMethodWithSentMessage) { 1579 | ++unflushedServerDocCount; 1580 | serverDoc.flushCallbacks.push(onServerDocFlush); 1581 | } 1582 | }); 1583 | }); 1584 | if (unflushedServerDocCount === 0) { 1585 | // There aren't any buffered docs --- we can call f as soon as the current 1586 | // round of updates is applied! 1587 | runFAfterUpdates(); 1588 | } 1589 | }, 1590 | 1591 | _livedata_nosub: function(msg) { 1592 | var self = this; 1593 | 1594 | // First pass it through _livedata_data, which only uses it to help get 1595 | // towards quiescence. 1596 | self._livedata_data(msg); 1597 | 1598 | // Do the rest of our processing immediately, with no 1599 | // buffering-until-quiescence. 1600 | 1601 | // we weren't subbed anyway, or we initiated the unsub. 1602 | if (!_.has(self._subscriptions, msg.id)) return; 1603 | 1604 | // XXX COMPAT WITH 1.0.3.1 #errorCallback 1605 | var errorCallback = self._subscriptions[msg.id].errorCallback; 1606 | var stopCallback = self._subscriptions[msg.id].stopCallback; 1607 | 1608 | self._subscriptions[msg.id].remove(); 1609 | 1610 | var meteorErrorFromMsg = function(msgArg) { 1611 | return ( 1612 | msgArg && 1613 | msgArg.error && 1614 | new Meteor.Error( 1615 | msgArg.error.error, 1616 | msgArg.error.reason, 1617 | msgArg.error.details 1618 | ) 1619 | ); 1620 | }; 1621 | 1622 | // XXX COMPAT WITH 1.0.3.1 #errorCallback 1623 | if (errorCallback && msg.error) { 1624 | errorCallback(meteorErrorFromMsg(msg)); 1625 | } 1626 | 1627 | if (stopCallback) { 1628 | stopCallback(meteorErrorFromMsg(msg)); 1629 | } 1630 | }, 1631 | 1632 | _process_nosub: function() { 1633 | // This is called as part of the "buffer until quiescence" process, but 1634 | // nosub's effect is always immediate. It only goes in the buffer at all 1635 | // because it's possible for a nosub to be the thing that triggers 1636 | // quiescence, if we were waiting for a sub to be revived and it dies 1637 | // instead. 1638 | }, 1639 | 1640 | _livedata_result: function(msg) { 1641 | // id, result or error. error has error (code), reason, details 1642 | 1643 | var self = this; 1644 | 1645 | // Lets make sure there are no buffered writes before returning result. 1646 | if (!_.isEmpty(self._bufferedWrites)) { 1647 | self._flushBufferedWrites(); 1648 | } 1649 | 1650 | // find the outstanding request 1651 | // should be O(1) in nearly all realistic use cases 1652 | if (_.isEmpty(self._outstandingMethodBlocks)) { 1653 | Meteor._debug('Received method result but no methods outstanding'); 1654 | return; 1655 | } 1656 | var currentMethodBlock = self._outstandingMethodBlocks[0].methods; 1657 | var m; 1658 | for (var i = 0; i < currentMethodBlock.length; i++) { 1659 | m = currentMethodBlock[i]; 1660 | if (m.methodId === msg.id) break; 1661 | } 1662 | 1663 | if (!m) { 1664 | Meteor._debug("Can't match method response to original method call", msg); 1665 | return; 1666 | } 1667 | 1668 | // Remove from current method block. This may leave the block empty, but we 1669 | // don't move on to the next block until the callback has been delivered, in 1670 | // _outstandingMethodFinished. 1671 | currentMethodBlock.splice(i, 1); 1672 | 1673 | if (_.has(msg, 'error')) { 1674 | m.receiveResult( 1675 | new Meteor.Error(msg.error.error, msg.error.reason, msg.error.details) 1676 | ); 1677 | } else { 1678 | // msg.result may be undefined if the method didn't return a 1679 | // value 1680 | m.receiveResult(undefined, msg.result); 1681 | } 1682 | }, 1683 | 1684 | // Called by MethodInvoker after a method's callback is invoked. If this was 1685 | // the last outstanding method in the current block, runs the next block. If 1686 | // there are no more methods, consider accepting a hot code push. 1687 | _outstandingMethodFinished: function() { 1688 | var self = this; 1689 | if (self._anyMethodsAreOutstanding()) return; 1690 | 1691 | // No methods are outstanding. This should mean that the first block of 1692 | // methods is empty. (Or it might not exist, if this was a method that 1693 | // half-finished before disconnect/reconnect.) 1694 | if (!_.isEmpty(self._outstandingMethodBlocks)) { 1695 | var firstBlock = self._outstandingMethodBlocks.shift(); 1696 | if (!_.isEmpty(firstBlock.methods)) 1697 | throw new Error( 1698 | 'No methods outstanding but nonempty block: ' + 1699 | JSON.stringify(firstBlock) 1700 | ); 1701 | 1702 | // Send the outstanding methods now in the first block. 1703 | if (!_.isEmpty(self._outstandingMethodBlocks)) 1704 | self._sendOutstandingMethods(); 1705 | } 1706 | 1707 | // Maybe accept a hot code push. 1708 | self._maybeMigrate(); 1709 | }, 1710 | 1711 | // Sends messages for all the methods in the first block in 1712 | // _outstandingMethodBlocks. 1713 | _sendOutstandingMethods: function() { 1714 | var self = this; 1715 | if (_.isEmpty(self._outstandingMethodBlocks)) return; 1716 | _.each(self._outstandingMethodBlocks[0].methods, function(m) { 1717 | m.sendMessage(); 1718 | }); 1719 | }, 1720 | 1721 | _livedata_error: function(msg) { 1722 | Meteor._debug('Received error from server: ', msg.reason); 1723 | if (msg.offendingMessage) Meteor._debug('For: ', msg.offendingMessage); 1724 | }, 1725 | 1726 | _callOnReconnectAndSendAppropriateOutstandingMethods: function() { 1727 | var self = this; 1728 | var oldOutstandingMethodBlocks = self._outstandingMethodBlocks; 1729 | self._outstandingMethodBlocks = []; 1730 | 1731 | self.onReconnect && self.onReconnect(); 1732 | DDP._reconnectHook.each(function(callback) { 1733 | callback(self); 1734 | return true; 1735 | }); 1736 | 1737 | if (_.isEmpty(oldOutstandingMethodBlocks)) return; 1738 | 1739 | // We have at least one block worth of old outstanding methods to try 1740 | // again. First: did onReconnect actually send anything? If not, we just 1741 | // restore all outstanding methods and run the first block. 1742 | if (_.isEmpty(self._outstandingMethodBlocks)) { 1743 | self._outstandingMethodBlocks = oldOutstandingMethodBlocks; 1744 | self._sendOutstandingMethods(); 1745 | return; 1746 | } 1747 | 1748 | // OK, there are blocks on both sides. Special case: merge the last block of 1749 | // the reconnect methods with the first block of the original methods, if 1750 | // neither of them are "wait" blocks. 1751 | if ( 1752 | !_.last(self._outstandingMethodBlocks).wait && 1753 | !oldOutstandingMethodBlocks[0].wait 1754 | ) { 1755 | _.each(oldOutstandingMethodBlocks[0].methods, function(m) { 1756 | _.last(self._outstandingMethodBlocks).methods.push(m); 1757 | 1758 | // If this "last block" is also the first block, send the message. 1759 | if (self._outstandingMethodBlocks.length === 1) m.sendMessage(); 1760 | }); 1761 | 1762 | oldOutstandingMethodBlocks.shift(); 1763 | } 1764 | 1765 | // Now add the rest of the original blocks on. 1766 | _.each(oldOutstandingMethodBlocks, function(block) { 1767 | self._outstandingMethodBlocks.push(block); 1768 | }); 1769 | }, 1770 | 1771 | // We can accept a hot code push if there are no methods in flight. 1772 | _readyToMigrate: function() { 1773 | var self = this; 1774 | return _.isEmpty(self._methodInvokers); 1775 | }, 1776 | 1777 | // If we were blocking a migration, see if it's now possible to continue. 1778 | // Call whenever the set of outstanding/blocked methods shrinks. 1779 | _maybeMigrate: function() { 1780 | var self = this; 1781 | if (self._retryMigrate && self._readyToMigrate()) { 1782 | self._retryMigrate(); 1783 | self._retryMigrate = null; 1784 | } 1785 | } 1786 | }); 1787 | 1788 | LivedataTest.Connection = Connection; 1789 | 1790 | // @param url {String} URL to Meteor app, 1791 | // e.g.: 1792 | // "subdomain.meteor.com", 1793 | // "http://subdomain.meteor.com", 1794 | // "/", 1795 | // "ddp+sockjs://ddp--****-foo.meteor.com/sockjs" 1796 | 1797 | /** 1798 | * @summary Connect to the server of a different Meteor application to subscribe to its document sets and invoke its remote methods. 1799 | * @locus Anywhere 1800 | * @param {String} url The URL of another Meteor application. 1801 | */ 1802 | DDP.connect = function(url, options) { 1803 | var ret = new Connection(url, options); 1804 | allConnections.push(ret); // hack. see below. 1805 | return ret; 1806 | }; 1807 | 1808 | DDP._reconnectHook = new Hook({ bindEnvironment: false }); 1809 | 1810 | /** 1811 | * @summary Register a function to call as the first step of 1812 | * reconnecting. This function can call methods which will be executed before 1813 | * any other outstanding methods. For example, this can be used to re-establish 1814 | * the appropriate authentication context on the connection. 1815 | * @locus Anywhere 1816 | * @param {Function} callback The function to call. It will be called with a 1817 | * single argument, the [connection object](#ddp_connect) that is reconnecting. 1818 | */ 1819 | DDP.onReconnect = function(callback) { 1820 | return DDP._reconnectHook.register(callback); 1821 | }; 1822 | 1823 | // Hack for `spiderable` package: a way to see if the page is done 1824 | // loading all the data it needs. 1825 | // 1826 | allConnections = []; 1827 | DDP._allSubscriptionsReady = function() { 1828 | return _.all(allConnections, function(conn) { 1829 | return _.all(conn._subscriptions, function(sub) { 1830 | return sub.ready; 1831 | }); 1832 | }); 1833 | }; 1834 | -------------------------------------------------------------------------------- /common/namespace.js: -------------------------------------------------------------------------------- 1 | import { DDPCommon } from 'meteor/ddp-common'; 2 | import { Meteor } from 'meteor/meteor'; 3 | 4 | /** 5 | * @namespace DDP 6 | * @summary Namespace for DDP-related methods/classes. 7 | */ 8 | export const DDP = {}; 9 | export const LivedataTest = {}; 10 | 11 | LivedataTest.SUPPORTED_DDP_VERSIONS = DDPCommon.SUPPORTED_DDP_VERSIONS; 12 | 13 | // This is private but it's used in a few places. accounts-base uses 14 | // it to get the current user. Meteor.setTimeout and friends clear 15 | // it. We can probably find a better way to factor this. 16 | DDP._CurrentMethodInvocation = new Meteor.EnvironmentVariable(); 17 | DDP._CurrentPublicationInvocation = new Meteor.EnvironmentVariable(); 18 | 19 | // XXX: Keep DDP._CurrentInvocation for backwards-compatibility. 20 | DDP._CurrentInvocation = DDP._CurrentMethodInvocation; 21 | 22 | DDP.ConnectionError = Meteor.makeErrorType('DDP.ConnectionError', function( 23 | message 24 | ) { 25 | var self = this; 26 | self.message = message; 27 | }); 28 | 29 | DDP.ForcedReconnectError = Meteor.makeErrorType( 30 | 'DDP.ForcedReconnectError', 31 | function() {} 32 | ); 33 | 34 | // Returns the named sequence of pseudo-random values. 35 | // The scope will be DDP._CurrentMethodInvocation.get(), so the stream will produce 36 | // consistent values for method calls on the client and server. 37 | DDP.randomStream = function(name) { 38 | var scope = DDP._CurrentMethodInvocation.get(); 39 | return DDPCommon.RandomStream.get(scope, name); 40 | }; 41 | -------------------------------------------------------------------------------- /common/stream_client_common.js: -------------------------------------------------------------------------------- 1 | import { Random } from 'meteor/random'; 2 | import { Meteor } from 'meteor/meteor'; 3 | import { _ } from 'meteor/underscore'; 4 | import { Tracker } from 'meteor/tracker'; 5 | import { Retry } from 'meteor/retry'; 6 | 7 | import { DDP, LivedataTest } from './namespace.js'; 8 | 9 | export function addCommonMethodsToPrototype(proto) { 10 | _.extend(proto, { 11 | // Register for callbacks. 12 | on: function(name, callback) { 13 | var self = this; 14 | 15 | if (name !== 'message' && name !== 'reset' && name !== 'disconnect') 16 | throw new Error('unknown event type: ' + name); 17 | 18 | if (!self.eventCallbacks[name]) self.eventCallbacks[name] = []; 19 | self.eventCallbacks[name].push(callback); 20 | }, 21 | 22 | _initCommon: function(options) { 23 | var self = this; 24 | options = options || {}; 25 | 26 | //// Constants 27 | 28 | // how long to wait until we declare the connection attempt 29 | // failed. 30 | self.CONNECT_TIMEOUT = options.connectTimeoutMs || 10000; 31 | 32 | self.eventCallbacks = {}; // name -> [callback] 33 | 34 | self._forcedToDisconnect = false; 35 | 36 | //// Reactive status 37 | self.currentStatus = { 38 | status: 'connecting', 39 | connected: false, 40 | retryCount: 0 41 | }; 42 | 43 | self.statusListeners = 44 | typeof Tracker !== 'undefined' && new Tracker.Dependency(); 45 | self.statusChanged = function() { 46 | if (self.statusListeners) self.statusListeners.changed(); 47 | }; 48 | 49 | //// Retry logic 50 | self._retry = new Retry(); 51 | self.connectionTimer = null; 52 | }, 53 | 54 | // Trigger a reconnect. 55 | reconnect: function(options) { 56 | var self = this; 57 | options = options || {}; 58 | 59 | if (options.url) { 60 | self._changeUrl(options.url); 61 | } 62 | 63 | if (options._sockjsOptions) { 64 | self.options._sockjsOptions = options._sockjsOptions; 65 | } 66 | 67 | if (self.currentStatus.connected) { 68 | if (options._force || options.url) { 69 | // force reconnect. 70 | self._lostConnection(new DDP.ForcedReconnectError()); 71 | } // else, noop. 72 | return; 73 | } 74 | 75 | // if we're mid-connection, stop it. 76 | if (self.currentStatus.status === 'connecting') { 77 | // Pretend it's a clean close. 78 | self._lostConnection(); 79 | } 80 | 81 | self._retry.clear(); 82 | self.currentStatus.retryCount -= 1; // don't count manual retries 83 | self._retryNow(); 84 | }, 85 | 86 | disconnect: function(options) { 87 | var self = this; 88 | options = options || {}; 89 | 90 | // Failed is permanent. If we're failed, don't let people go back 91 | // online by calling 'disconnect' then 'reconnect'. 92 | if (self._forcedToDisconnect) return; 93 | 94 | // If _permanent is set, permanently disconnect a stream. Once a stream 95 | // is forced to disconnect, it can never reconnect. This is for 96 | // error cases such as ddp version mismatch, where trying again 97 | // won't fix the problem. 98 | if (options._permanent) { 99 | self._forcedToDisconnect = true; 100 | } 101 | 102 | self._cleanup(); 103 | self._retry.clear(); 104 | 105 | self.currentStatus = { 106 | status: options._permanent ? 'failed' : 'offline', 107 | connected: false, 108 | retryCount: 0 109 | }; 110 | 111 | if (options._permanent && options._error) 112 | self.currentStatus.reason = options._error; 113 | 114 | self.statusChanged(); 115 | }, 116 | 117 | // maybeError is set unless it's a clean protocol-level close. 118 | _lostConnection: function(maybeError) { 119 | var self = this; 120 | 121 | self._cleanup(maybeError); 122 | self._retryLater(maybeError); // sets status. no need to do it here. 123 | }, 124 | 125 | // fired when we detect that we've gone online. try to reconnect 126 | // immediately. 127 | _online: function() { 128 | // if we've requested to be offline by disconnecting, don't reconnect. 129 | if (this.currentStatus.status != 'offline') this.reconnect(); 130 | }, 131 | 132 | _retryLater: function(maybeError) { 133 | var self = this; 134 | 135 | var timeout = 0; 136 | if ( 137 | self.options.retry || 138 | (maybeError && maybeError.errorType === 'DDP.ForcedReconnectError') 139 | ) { 140 | timeout = self._retry.retryLater( 141 | self.currentStatus.retryCount, 142 | _.bind(self._retryNow, self) 143 | ); 144 | self.currentStatus.status = 'waiting'; 145 | self.currentStatus.retryTime = new Date().getTime() + timeout; 146 | } else { 147 | self.currentStatus.status = 'failed'; 148 | delete self.currentStatus.retryTime; 149 | } 150 | 151 | self.currentStatus.connected = false; 152 | self.statusChanged(); 153 | }, 154 | 155 | _retryNow: function() { 156 | var self = this; 157 | 158 | if (self._forcedToDisconnect) return; 159 | 160 | self.currentStatus.retryCount += 1; 161 | self.currentStatus.status = 'connecting'; 162 | self.currentStatus.connected = false; 163 | delete self.currentStatus.retryTime; 164 | self.statusChanged(); 165 | 166 | self._launchConnection(); 167 | }, 168 | 169 | // Get current status. Reactive. 170 | status: function() { 171 | var self = this; 172 | if (self.statusListeners) self.statusListeners.depend(); 173 | return self.currentStatus; 174 | } 175 | }); 176 | } 177 | -------------------------------------------------------------------------------- /common/urlHelpers.js: -------------------------------------------------------------------------------- 1 | import { LivedataTest } from './namespace'; 2 | import { Random } from 'meteor/random'; 3 | 4 | // XXX from Underscore.String (http://epeli.github.com/underscore.string/) 5 | var startsWith = function(str, starts) { 6 | return ( 7 | str.length >= starts.length && str.substring(0, starts.length) === starts 8 | ); 9 | }; 10 | var endsWith = function(str, ends) { 11 | return ( 12 | str.length >= ends.length && 13 | str.substring(str.length - ends.length) === ends 14 | ); 15 | }; 16 | 17 | // @param url {String} URL to Meteor app, eg: 18 | // "/" or "madewith.meteor.com" or "https://foo.meteor.com" 19 | // or "ddp+sockjs://ddp--****-foo.meteor.com/sockjs" 20 | // @returns {String} URL to the endpoint with the specific scheme and subPath, e.g. 21 | // for scheme "http" and subPath "sockjs" 22 | // "http://subdomain.meteor.com/sockjs" or "/sockjs" 23 | // or "https://ddp--1234-foo.meteor.com/sockjs" 24 | var translateUrl = function(url, newSchemeBase, subPath) { 25 | if (!newSchemeBase) { 26 | newSchemeBase = 'http'; 27 | } 28 | 29 | var ddpUrlMatch = url.match(/^ddp(i?)\+sockjs:\/\//); 30 | var httpUrlMatch = url.match(/^http(s?):\/\//); 31 | var newScheme; 32 | if (ddpUrlMatch) { 33 | // Remove scheme and split off the host. 34 | var urlAfterDDP = url.substr(ddpUrlMatch[0].length); 35 | newScheme = ddpUrlMatch[1] === 'i' ? newSchemeBase : newSchemeBase + 's'; 36 | var slashPos = urlAfterDDP.indexOf('/'); 37 | var host = slashPos === -1 ? urlAfterDDP : urlAfterDDP.substr(0, slashPos); 38 | var rest = slashPos === -1 ? '' : urlAfterDDP.substr(slashPos); 39 | 40 | // In the host (ONLY!), change '*' characters into random digits. This 41 | // allows different stream connections to connect to different hostnames 42 | // and avoid browser per-hostname connection limits. 43 | host = host.replace(/\*/g, function() { 44 | return Math.floor(Random.fraction() * 10); 45 | }); 46 | 47 | return newScheme + '://' + host + rest; 48 | } else if (httpUrlMatch) { 49 | newScheme = !httpUrlMatch[1] ? newSchemeBase : newSchemeBase + 's'; 50 | var urlAfterHttp = url.substr(httpUrlMatch[0].length); 51 | url = newScheme + '://' + urlAfterHttp; 52 | } 53 | 54 | // Prefix FQDNs but not relative URLs 55 | if (url.indexOf('://') === -1 && !startsWith(url, '/')) { 56 | url = newSchemeBase + '://' + url; 57 | } 58 | 59 | // XXX This is not what we should be doing: if I have a site 60 | // deployed at "/foo", then DDP.connect("/") should actually connect 61 | // to "/", not to "/foo". "/" is an absolute path. (Contrast: if 62 | // deployed at "/foo", it would be reasonable for DDP.connect("bar") 63 | // to connect to "/foo/bar"). 64 | // 65 | // We should make this properly honor absolute paths rather than 66 | // forcing the path to be relative to the site root. Simultaneously, 67 | // we should set DDP_DEFAULT_CONNECTION_URL to include the site 68 | // root. See also client_convenience.js #RationalizingRelativeDDPURLs 69 | url = Meteor._relativeToSiteRootUrl(url); 70 | 71 | if (endsWith(url, '/')) return url + subPath; 72 | else return url + '/' + subPath; 73 | }; 74 | 75 | export function toSockjsUrl(url) { 76 | return translateUrl(url, 'http', 'sockjs'); 77 | } 78 | 79 | export function toWebsocketUrl(url) { 80 | var ret = translateUrl(url, 'ws', 'websocket'); 81 | return ret; 82 | } 83 | 84 | LivedataTest.toSockjsUrl = toSockjsUrl; 85 | -------------------------------------------------------------------------------- /fusion/client/client.js: -------------------------------------------------------------------------------- 1 | import { DDP, LivedataTest } from '../../common/namespace'; 2 | import FusionModel from './fusion'; 3 | 4 | import '../../client/stream_client_sockjs'; 5 | import '../../common/livedata_connection'; 6 | 7 | import { engage, disengage } from './engager'; 8 | disengage(); 9 | 10 | DDP.engage = engage; 11 | DDP.disengage = disengage; 12 | 13 | const Fusion = new FusionModel(); 14 | 15 | export { DDP, LivedataTest, Fusion } 16 | -------------------------------------------------------------------------------- /fusion/client/engager.js: -------------------------------------------------------------------------------- 1 | import { DDP, LivedataTest } from '../../common/namespace'; 2 | import { call, apply } from './rpc'; 3 | 4 | let _methods = []; 5 | 6 | export function engage() { 7 | if (Meteor.connection._isDummy) { 8 | createActualConnection(); 9 | } 10 | } 11 | 12 | export function disengage() { 13 | if (!Meteor.connection || !Meteor.connection._isDummy) { 14 | createDummyConnection(); 15 | } 16 | } 17 | 18 | function createActualConnection() { 19 | let ddpUrl = '/'; 20 | if (typeof __meteor_runtime_config__ !== 'undefined') { 21 | if (__meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL) 22 | ddpUrl = __meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL; 23 | } 24 | 25 | let retry = new Retry(); 26 | 27 | let onDDPVersionNegotiationFailure = function(description) { 28 | Meteor._debug(description); 29 | if (Package.reload) { 30 | let migrationData = 31 | Package.reload.Reload._migrationData('livedata') || {}; 32 | let failures = migrationData.DDPVersionNegotiationFailures || 0; 33 | ++failures; 34 | Package.reload.Reload._onMigrate('livedata', function() { 35 | return [true, { DDPVersionNegotiationFailures: failures }]; 36 | }); 37 | retry.retryLater(failures, function() { 38 | Package.reload.Reload._reload(); 39 | }); 40 | } 41 | }; 42 | 43 | Meteor.connection = DDP.connect(ddpUrl, { 44 | onDDPVersionNegotiationFailure: onDDPVersionNegotiationFailure 45 | }); 46 | 47 | _mirrorMeteorObject(); 48 | 49 | _methods.forEach(config => { 50 | Meteor.methods(config); 51 | }) 52 | } 53 | 54 | export function createDummyConnection() { 55 | if (Meteor.connection) { 56 | Meteor.connection.disconnect(); 57 | } 58 | 59 | Meteor.connection = { 60 | _isDummy: true, 61 | _userId: null, 62 | subscribe() { 63 | Meteor.isDevelopment && console.warn('You cannot subscribe, the connection is not engaged.'); 64 | }, 65 | methods(config) { 66 | _methods.push(config); 67 | Meteor.isDevelopment && console.warn('Does not work with .methods() client-side'); 68 | }, 69 | status() { 70 | return 'offline'; 71 | }, 72 | reconnect() {}, 73 | disconnect() {}, 74 | call, 75 | apply, 76 | setUserId(userId) { 77 | this._userId = userId; 78 | }, 79 | userId() { 80 | return this._userId; 81 | } 82 | }; 83 | 84 | _mirrorMeteorObject(); 85 | } 86 | 87 | function _mirrorMeteorObject() { 88 | [ 89 | 'subscribe', 90 | 'methods', 91 | 'call', 92 | 'apply', 93 | 'status', 94 | 'reconnect', 95 | 'disconnect' 96 | ].forEach(name => { 97 | Meteor[name] = Meteor.connection[name].bind(Meteor.connection); 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /fusion/client/fusion.js: -------------------------------------------------------------------------------- 1 | import { engage, disengage } from './engager'; 2 | 3 | export default class Fusion { 4 | constructor() { 5 | this._keepers = []; 6 | } 7 | 8 | checkStatusAndUpdate() { 9 | if (this._keepers.length === 0) { 10 | engage(); 11 | } else { 12 | disengage(); 13 | } 14 | } 15 | 16 | engage(callback) { 17 | this._keepers.add(callback); 18 | this.checkStatusAndUpdate(); 19 | 20 | callback(); 21 | 22 | const self = this; 23 | return { 24 | stop() { 25 | self._keepers = self._keepers.filter(c => { 26 | return c !== callback; 27 | }); 28 | 29 | self.checkStatusAndUpdate(); 30 | } 31 | }; 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /fusion/client/rpc.js: -------------------------------------------------------------------------------- 1 | import {HTTP} from 'meteor/http'; 2 | import {Meteor} from 'meteor/meteor'; 3 | 4 | export function getToken() { 5 | return Accounts._storedLoginToken(); 6 | } 7 | 8 | function apply(method, args, options, callback) { 9 | let headers = {'Content-Type': 'application/ejson'}; 10 | try { 11 | headers['Meteor-Authorization'] = getToken(); 12 | } catch (e) { 13 | // Accounts may not be defined at this stage 14 | } 15 | 16 | HTTP.post(Meteor.absoluteUrl('/__meteor'), { 17 | content: EJSON.stringify({method, args}), 18 | headers 19 | }, function (err, res) { 20 | if (callback) { 21 | if (err) { 22 | callback && callback(err); 23 | } else { 24 | const data = EJSON.parse(res.content); 25 | callback && callback(undefined, data.result); 26 | } 27 | } 28 | }) 29 | } 30 | 31 | function call(method, ...args) { 32 | let callback; 33 | if (_.isFunction(_.last(args))) { 34 | callback = _.last(args); 35 | args = args.slice(0, args.length - 1); 36 | } 37 | 38 | return apply(method, args, {}, callback); 39 | } 40 | 41 | 42 | export { apply, call }; -------------------------------------------------------------------------------- /fusion/server/index.js: -------------------------------------------------------------------------------- 1 | import './route'; -------------------------------------------------------------------------------- /fusion/server/route.js: -------------------------------------------------------------------------------- 1 | import {Picker} from 'meteor/meteorhacks:picker'; 2 | import {EJSON} from 'meteor/ejson'; 3 | import {Meteor} from 'meteor/meteor'; 4 | import bodyParser from 'body-parser'; 5 | 6 | const rpcRoutes = Picker.filter(function () { 7 | return true; 8 | }); 9 | rpcRoutes.middleware(bodyParser.raw({ 10 | 'type': 'application/ejson', 11 | })); 12 | 13 | function getUserIdByToken(token) { 14 | const user = Meteor.users.findOne({ 15 | 'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(token) 16 | }, {fields: {_id: 1}}); 17 | 18 | return user && user._id; 19 | } 20 | 21 | rpcRoutes.route('/__meteor', function(params, req, res, next) { 22 | const body = req.body.toString(); 23 | const data = EJSON.parse(body); 24 | 25 | const handler = Meteor.server.method_handlers[data.method]; 26 | if (!handler) { 27 | res.statusCode = 404; 28 | res.end(EJSON.stringify({ 29 | reason: 'Method not found', 30 | })); 31 | 32 | return; 33 | } 34 | 35 | try { 36 | let context = { 37 | userId: null, 38 | connection: {}, 39 | unblock() {}, 40 | setUserId(userId) { this.userId = userId } 41 | }; 42 | if (req.headers['meteor-authorization']) { 43 | context.userId = getUserIdByToken(req.headers['meteor-authorization']) 44 | } 45 | 46 | const result = handler.apply(context, data.args); 47 | 48 | res.end( 49 | EJSON.stringify({ 50 | result 51 | }) 52 | ) 53 | } catch (e) { 54 | console.error(e); 55 | 56 | res.statusCode = 500; 57 | res.end(EJSON.stringify({ 58 | reason: e.reason || e.toString(), 59 | })); 60 | 61 | return; 62 | } 63 | }); -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | summary: "Meteor's latency-compensated distributed data client", 3 | version: '2.3.1', 4 | documentation: null, 5 | name: 'ddp-client', 6 | }); 7 | 8 | Npm.depends({ 9 | 'faye-websocket': '0.11.1', 10 | lolex: '1.4.0', 11 | 'permessage-deflate': '0.1.6', 12 | 'body-parser': '1.18.2', 13 | }); 14 | 15 | Package.onUse(function(api) { 16 | api.use( 17 | [ 18 | 'http', 19 | 'check', 20 | 'random', 21 | 'ejson', 22 | 'underscore', 23 | 'tracker', 24 | 'retry', 25 | 'id-map', 26 | 'ecmascript', 27 | ], 28 | ['client', 'server'] 29 | ); 30 | 31 | api.use(['meteorhacks:picker@1.0.3'], 'server'); 32 | 33 | api.use('callback-hook', ['client', 'server']); 34 | 35 | // common functionality 36 | api.use('ddp-common', ['client', 'server']); 37 | 38 | api.use('reload', 'client', { weak: true }); 39 | 40 | // we depend on _diffObjects, _applyChanges, 41 | api.use('diff-sequence', ['client', 'server']); 42 | // _idParse, _idStringify. 43 | api.use('mongo-id', ['client', 'server']); 44 | 45 | // For backcompat where things use Package.ddp.DDP, etc 46 | api.export('DDP'); 47 | api.mainModule('fusion/client/client.js', 'client'); 48 | 49 | api.addFiles(['fusion/server/index.js'], 'server'); 50 | 51 | api.mainModule('server/server.js', 'server'); 52 | }); 53 | 54 | Package.onTest(function(api) { 55 | api.use('livedata', ['client', 'server']); 56 | api.use('mongo', ['client', 'server']); 57 | api.use('test-helpers', ['client', 'server']); 58 | api.use([ 59 | 'ecmascript', 60 | 'underscore', 61 | 'tinytest', 62 | 'random', 63 | 'tracker', 64 | 'reactive-var', 65 | 'mongo-id', 66 | 'diff-sequence', 67 | 'ejson', 68 | ]); 69 | 70 | api.addFiles('test/stub_stream.js'); 71 | api.addFiles('test/livedata_connection_tests.js', ['client', 'server']); 72 | api.addFiles('test/livedata_tests.js', ['client', 'server']); 73 | api.addFiles('test/livedata_test_service.js', ['client', 'server']); 74 | api.addFiles('test/random_stream_tests.js', ['client', 'server']); 75 | 76 | api.use('http', 'client'); 77 | api.addFiles('test/stream_tests.js', 'client'); 78 | api.addFiles('test/stream_client_tests.js', 'server'); 79 | api.use('check', ['client', 'server']); 80 | }); 81 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | export { DDP, LivedataTest } from '../common/namespace'; 2 | 3 | import './stream_client_nodejs'; 4 | 5 | import '../common/livedata_connection'; 6 | -------------------------------------------------------------------------------- /server/stream_client_nodejs.js: -------------------------------------------------------------------------------- 1 | import { _ } from 'meteor/underscore'; 2 | import { Meteor } from 'meteor/meteor'; 3 | 4 | import { DDP, LivedataTest } from '../common/namespace'; 5 | import { toWebsocketUrl } from '../common/urlHelpers'; 6 | import { addCommonMethodsToPrototype } from '../common/stream_client_common'; 7 | 8 | // @param endpoint {String} URL to Meteor app 9 | // "http://subdomain.meteor.com/" or "/" or 10 | // "ddp+sockjs://foo-**.meteor.com/sockjs" 11 | // 12 | // We do some rewriting of the URL to eventually make it "ws://" or "wss://", 13 | // whatever was passed in. At the very least, what Meteor.absoluteUrl() returns 14 | // us should work. 15 | // 16 | // We don't do any heartbeating. (The logic that did this in sockjs was removed, 17 | // because it used a built-in sockjs mechanism. We could do it with WebSocket 18 | // ping frames or with DDP-level messages.) 19 | LivedataTest.ClientStream = class ClientStream { 20 | constructor(endpoint, options) { 21 | const self = this; 22 | options = options || {}; 23 | 24 | self.options = Object.assign( 25 | { 26 | retry: true 27 | }, 28 | options 29 | ); 30 | 31 | self.client = null; // created in _launchConnection 32 | self.endpoint = endpoint; 33 | 34 | self.headers = self.options.headers || {}; 35 | self.npmFayeOptions = self.options.npmFayeOptions || {}; 36 | 37 | self._initCommon(self.options); 38 | 39 | //// Kickoff! 40 | self._launchConnection(); 41 | } 42 | 43 | // data is a utf8 string. Data sent while not connected is dropped on 44 | // the floor, and it is up the user of this API to retransmit lost 45 | // messages on 'reset' 46 | send(data) { 47 | var self = this; 48 | if (self.currentStatus.connected) { 49 | self.client.send(data); 50 | } 51 | } 52 | 53 | // Changes where this connection points 54 | _changeUrl(url) { 55 | var self = this; 56 | self.endpoint = url; 57 | } 58 | 59 | _onConnect(client) { 60 | var self = this; 61 | 62 | if (client !== self.client) { 63 | // This connection is not from the last call to _launchConnection. 64 | // But _launchConnection calls _cleanup which closes previous connections. 65 | // It's our belief that this stifles future 'open' events, but maybe 66 | // we are wrong? 67 | throw new Error('Got open from inactive client ' + !!self.client); 68 | } 69 | 70 | if (self._forcedToDisconnect) { 71 | // We were asked to disconnect between trying to open the connection and 72 | // actually opening it. Let's just pretend this never happened. 73 | self.client.close(); 74 | self.client = null; 75 | return; 76 | } 77 | 78 | if (self.currentStatus.connected) { 79 | // We already have a connection. It must have been the case that we 80 | // started two parallel connection attempts (because we wanted to 81 | // 'reconnect now' on a hanging connection and we had no way to cancel the 82 | // connection attempt.) But this shouldn't happen (similarly to the client 83 | // !== self.client check above). 84 | throw new Error('Two parallel connections?'); 85 | } 86 | 87 | self._clearConnectionTimer(); 88 | 89 | // update status 90 | self.currentStatus.status = 'connected'; 91 | self.currentStatus.connected = true; 92 | self.currentStatus.retryCount = 0; 93 | self.statusChanged(); 94 | 95 | // fire resets. This must come after status change so that clients 96 | // can call send from within a reset callback. 97 | _.each(self.eventCallbacks.reset, function(callback) { 98 | callback(); 99 | }); 100 | } 101 | 102 | _cleanup(maybeError) { 103 | var self = this; 104 | 105 | self._clearConnectionTimer(); 106 | if (self.client) { 107 | var client = self.client; 108 | self.client = null; 109 | client.close(); 110 | 111 | _.each(self.eventCallbacks.disconnect, function(callback) { 112 | callback(maybeError); 113 | }); 114 | } 115 | } 116 | 117 | _clearConnectionTimer() { 118 | var self = this; 119 | 120 | if (self.connectionTimer) { 121 | clearTimeout(self.connectionTimer); 122 | self.connectionTimer = null; 123 | } 124 | } 125 | 126 | _getProxyUrl(targetUrl) { 127 | var self = this; 128 | // Similar to code in tools/http-helpers.js. 129 | var proxy = process.env.HTTP_PROXY || process.env.http_proxy || null; 130 | // if we're going to a secure url, try the https_proxy env variable first. 131 | if (targetUrl.match(/^wss:/)) { 132 | proxy = process.env.HTTPS_PROXY || process.env.https_proxy || proxy; 133 | } 134 | return proxy; 135 | } 136 | 137 | _launchConnection() { 138 | var self = this; 139 | self._cleanup(); // cleanup the old socket, if there was one. 140 | 141 | // Since server-to-server DDP is still an experimental feature, we only 142 | // require the module if we actually create a server-to-server 143 | // connection. 144 | var FayeWebSocket = Npm.require('faye-websocket'); 145 | var deflate = Npm.require('permessage-deflate'); 146 | 147 | var targetUrl = toWebsocketUrl(self.endpoint); 148 | var fayeOptions = { 149 | headers: self.headers, 150 | extensions: [deflate] 151 | }; 152 | fayeOptions = _.extend(fayeOptions, self.npmFayeOptions); 153 | var proxyUrl = self._getProxyUrl(targetUrl); 154 | if (proxyUrl) { 155 | fayeOptions.proxy = { origin: proxyUrl }; 156 | } 157 | 158 | // We would like to specify 'ddp' as the subprotocol here. The npm module we 159 | // used to use as a client would fail the handshake if we ask for a 160 | // subprotocol and the server doesn't send one back (and sockjs doesn't). 161 | // Faye doesn't have that behavior; it's unclear from reading RFC 6455 if 162 | // Faye is erroneous or not. So for now, we don't specify protocols. 163 | var subprotocols = []; 164 | 165 | var client = (self.client = new FayeWebSocket.Client( 166 | targetUrl, 167 | subprotocols, 168 | fayeOptions 169 | )); 170 | 171 | self._clearConnectionTimer(); 172 | self.connectionTimer = Meteor.setTimeout(function() { 173 | self._lostConnection(new DDP.ConnectionError('DDP connection timed out')); 174 | }, self.CONNECT_TIMEOUT); 175 | 176 | self.client.on( 177 | 'open', 178 | Meteor.bindEnvironment(function() { 179 | return self._onConnect(client); 180 | }, 'stream connect callback') 181 | ); 182 | 183 | var clientOnIfCurrent = function(event, description, f) { 184 | self.client.on( 185 | event, 186 | Meteor.bindEnvironment(function() { 187 | // Ignore events from any connection we've already cleaned up. 188 | if (client !== self.client) return; 189 | f.apply(this, arguments); 190 | }, description) 191 | ); 192 | }; 193 | 194 | clientOnIfCurrent('error', 'stream error callback', function(error) { 195 | if (!self.options._dontPrintErrors) 196 | Meteor._debug('stream error', error.message); 197 | 198 | // Faye's 'error' object is not a JS error (and among other things, 199 | // doesn't stringify well). Convert it to one. 200 | self._lostConnection(new DDP.ConnectionError(error.message)); 201 | }); 202 | 203 | clientOnIfCurrent('close', 'stream close callback', function() { 204 | self._lostConnection(); 205 | }); 206 | 207 | clientOnIfCurrent('message', 'stream message callback', function(message) { 208 | // Ignore binary frames, where message.data is a Buffer 209 | if (typeof message.data !== 'string') return; 210 | 211 | _.each(self.eventCallbacks.message, function(callback) { 212 | callback(message.data); 213 | }); 214 | }); 215 | } 216 | }; 217 | 218 | addCommonMethodsToPrototype(LivedataTest.ClientStream.prototype); 219 | -------------------------------------------------------------------------------- /test/livedata_test_service.js: -------------------------------------------------------------------------------- 1 | Meteor.methods({ 2 | nothing: function() { 3 | // No need to check if there are no arguments. 4 | }, 5 | echo: function(/* arguments */) { 6 | check(arguments, [Match.Any]); 7 | return _.toArray(arguments); 8 | }, 9 | echoOne: function(/*arguments*/) { 10 | check(arguments, [Match.Any]); 11 | return arguments[0]; 12 | }, 13 | exception: function(where, options) { 14 | check(where, String); 15 | check( 16 | options, 17 | Match.Optional({ 18 | intended: Match.Optional(Boolean), 19 | throwThroughFuture: Match.Optional(Boolean) 20 | }) 21 | ); 22 | options = options || {}; 23 | var shouldThrow = 24 | (Meteor.isServer && where === 'server') || 25 | (Meteor.isClient && where === 'client') || 26 | where === 'both'; 27 | 28 | if (shouldThrow) { 29 | var e; 30 | if (options.intended) 31 | e = new Meteor.Error(999, 'Client-visible test exception'); 32 | else e = new Error('Test method throwing an exception'); 33 | e.expected = true; 34 | 35 | // We used to improperly serialize errors that were thrown through a 36 | // future first. 37 | if (Meteor.isServer && options.throwThroughFuture) { 38 | var Future = Npm.require('fibers/future'); 39 | var f = new Future(); 40 | f['throw'](e); 41 | e = f.wait(); 42 | } 43 | throw e; 44 | } 45 | }, 46 | setUserId: function(userId) { 47 | check(userId, Match.OneOf(String, null)); 48 | this.setUserId(userId); 49 | } 50 | }); 51 | 52 | // Methods to help test applying methods with `wait: true`: delayedTrue returns 53 | // true 1s after being run unless makeDelayedTrueImmediatelyReturnFalse was run 54 | // in the meanwhile. Increasing the timeout makes the "wait: true" test slower; 55 | // decreasing the timeout makes the "wait: false" test flakier (ie, the timeout 56 | // could fire before processing the second method). 57 | if (Meteor.isServer) { 58 | // Keys are random tokens, used to isolate multiple test invocations from each 59 | // other. 60 | var waiters = {}; 61 | 62 | var Future = Npm.require('fibers/future'); 63 | 64 | var returnThroughFuture = function(token, returnValue) { 65 | // Make sure that when we call return, the fields are already cleared. 66 | var record = waiters[token]; 67 | if (!record) return; 68 | delete waiters[token]; 69 | record.future['return'](returnValue); 70 | }; 71 | 72 | Meteor.methods({ 73 | delayedTrue: function(token) { 74 | check(token, String); 75 | var record = (waiters[token] = { 76 | future: new Future(), 77 | timer: Meteor.setTimeout(function() { 78 | returnThroughFuture(token, true); 79 | }, 1000) 80 | }); 81 | 82 | this.unblock(); 83 | return record.future.wait(); 84 | }, 85 | makeDelayedTrueImmediatelyReturnFalse: function(token) { 86 | check(token, String); 87 | var record = waiters[token]; 88 | if (!record) return; // since delayedTrue's timeout had already run 89 | clearTimeout(record.timer); 90 | returnThroughFuture(token, false); 91 | } 92 | }); 93 | } 94 | 95 | /*****/ 96 | 97 | Ledger = new Mongo.Collection('ledger'); 98 | Ledger.allow({ 99 | insert: function() { 100 | return true; 101 | }, 102 | update: function() { 103 | return true; 104 | }, 105 | remove: function() { 106 | return true; 107 | }, 108 | fetch: [] 109 | }); 110 | 111 | Meteor.startup(function() { 112 | if (Meteor.isServer) Ledger.remove({}); // XXX can this please be Ledger.remove()? 113 | }); 114 | 115 | if (Meteor.isServer) 116 | Meteor.publish('ledger', function(world) { 117 | check(world, String); 118 | return Ledger.find({ world: world }); 119 | }); 120 | 121 | Meteor.methods({ 122 | 'ledger/transfer': function(world, from_name, to_name, amount, cheat) { 123 | check(world, String); 124 | check(from_name, String); 125 | check(to_name, String); 126 | check(amount, Number); 127 | check(cheat, Match.Optional(Boolean)); 128 | var from = Ledger.findOne({ name: from_name, world: world }); 129 | var to = Ledger.findOne({ name: to_name, world: world }); 130 | 131 | if (Meteor.isServer) cheat = false; 132 | 133 | if (!from) 134 | throw new Meteor.Error( 135 | 404, 136 | 'No such account ' + from_name + ' in ' + world 137 | ); 138 | 139 | if (!to) 140 | throw new Meteor.Error( 141 | 404, 142 | 'No such account ' + to_name + ' in ' + world 143 | ); 144 | 145 | if (from.balance < amount && !cheat) 146 | throw new Meteor.Error(409, 'Insufficient funds'); 147 | 148 | Ledger.update(from._id, { $inc: { balance: -amount } }); 149 | Ledger.update(to._id, { $inc: { balance: amount } }); 150 | } 151 | }); 152 | 153 | /*****/ 154 | 155 | /// Helpers for "livedata - changing userid reruns subscriptions..." 156 | 157 | objectsWithUsers = new Mongo.Collection('objectsWithUsers'); 158 | 159 | if (Meteor.isServer) { 160 | objectsWithUsers.remove({}); 161 | objectsWithUsers.insert({ name: 'owned by none', ownerUserIds: [null] }); 162 | objectsWithUsers.insert({ name: 'owned by one - a', ownerUserIds: ['1'] }); 163 | objectsWithUsers.insert({ 164 | name: 'owned by one/two - a', 165 | ownerUserIds: ['1', '2'] 166 | }); 167 | objectsWithUsers.insert({ 168 | name: 'owned by one/two - b', 169 | ownerUserIds: ['1', '2'] 170 | }); 171 | objectsWithUsers.insert({ name: 'owned by two - a', ownerUserIds: ['2'] }); 172 | objectsWithUsers.insert({ name: 'owned by two - b', ownerUserIds: ['2'] }); 173 | 174 | Meteor.publish('objectsWithUsers', function() { 175 | return objectsWithUsers.find( 176 | { ownerUserIds: this.userId }, 177 | { fields: { ownerUserIds: 0 } } 178 | ); 179 | }); 180 | 181 | (function() { 182 | var userIdWhenStopped = {}; 183 | Meteor.publish('recordUserIdOnStop', function(key) { 184 | check(key, String); 185 | var self = this; 186 | self.onStop(function() { 187 | userIdWhenStopped[key] = self.userId; 188 | }); 189 | }); 190 | 191 | Meteor.methods({ 192 | userIdWhenStopped: function(key) { 193 | check(key, String); 194 | return userIdWhenStopped[key]; 195 | } 196 | }); 197 | })(); 198 | } 199 | 200 | /*****/ 201 | 202 | /// Helper for "livedata - setUserId fails when called on server" 203 | 204 | if (Meteor.isServer) { 205 | Meteor.startup(function() { 206 | errorThrownWhenCallingSetUserIdDirectlyOnServer = null; 207 | try { 208 | Meteor.call('setUserId', '1000'); 209 | } catch (e) { 210 | errorThrownWhenCallingSetUserIdDirectlyOnServer = e; 211 | } 212 | }); 213 | } 214 | 215 | /// Helper for "livedata - no setUserId after unblock" 216 | 217 | if (Meteor.isServer) { 218 | Meteor.methods({ 219 | setUserIdAfterUnblock: function() { 220 | this.unblock(); 221 | var threw = false; 222 | var originalUserId = this.userId; 223 | try { 224 | // Calling setUserId after unblock should throw an error (and not mutate 225 | // userId). 226 | this.setUserId(originalUserId + 'bla'); 227 | } catch (e) { 228 | threw = true; 229 | } 230 | return threw && this.userId === originalUserId; 231 | } 232 | }); 233 | } 234 | 235 | /*****/ 236 | 237 | /// Helper for "livedata - overlapping universal subs" 238 | 239 | if (Meteor.isServer) { 240 | (function() { 241 | var collName = 'overlappingUniversalSubs'; 242 | var universalSubscribers = [[], []]; 243 | 244 | _.each([0, 1], function(index) { 245 | Meteor.publish(null, function() { 246 | var sub = this; 247 | universalSubscribers[index].push(sub); 248 | sub.onStop(function() { 249 | universalSubscribers[index] = _.without( 250 | universalSubscribers[index], 251 | sub 252 | ); 253 | }); 254 | }); 255 | }); 256 | 257 | Meteor.methods({ 258 | testOverlappingSubs: function(token) { 259 | check(token, String); 260 | _.each(universalSubscribers[0], function(sub) { 261 | sub.added(collName, token, {}); 262 | }); 263 | _.each(universalSubscribers[1], function(sub) { 264 | sub.added(collName, token, {}); 265 | }); 266 | _.each(universalSubscribers[0], function(sub) { 267 | sub.removed(collName, token); 268 | }); 269 | } 270 | }); 271 | })(); 272 | } 273 | 274 | /// Helper for "livedata - runtime universal sub creation" 275 | 276 | if (Meteor.isServer) { 277 | Meteor.methods({ 278 | runtimeUniversalSubCreation: function(token) { 279 | check(token, String); 280 | Meteor.publish(null, function() { 281 | this.added('runtimeSubCreation', token, {}); 282 | }); 283 | } 284 | }); 285 | } 286 | 287 | /// Helper for "livedata - publisher errors" 288 | 289 | if (Meteor.isServer) { 290 | Meteor.publish('publisherErrors', function(collName, options) { 291 | check(collName, String); 292 | // See below to see what options are accepted. 293 | check(options, Object); 294 | var sub = this; 295 | 296 | // First add a random item, which should be cleaned up. We use ready/onReady 297 | // to make sure that the second test block is only called after the added is 298 | // processed, so that there's any chance of the coll.find().count() failing. 299 | sub.added(collName, Random.id(), { foo: 42 }); 300 | sub.ready(); 301 | 302 | if (options.stopInHandler) { 303 | sub.stop(); 304 | return; 305 | } 306 | 307 | var error; 308 | if (options.internalError) { 309 | error = new Error('Egads!'); 310 | error.expected = true; // don't log 311 | } else { 312 | error = new Meteor.Error(412, 'Explicit error'); 313 | } 314 | if (options.throwInHandler) { 315 | throw error; 316 | } else if (options.errorInHandler) { 317 | sub.error(error); 318 | } else if (options.throwWhenUserIdSet) { 319 | if (sub.userId) throw error; 320 | } else if (options.errorLater) { 321 | Meteor.defer(function() { 322 | sub.error(error); 323 | }); 324 | } 325 | }); 326 | } 327 | 328 | /*****/ 329 | 330 | /// Helpers for "livedata - publish multiple cursors" 331 | One = new Mongo.Collection('collectionOne'); 332 | Two = new Mongo.Collection('collectionTwo'); 333 | 334 | if (Meteor.isServer) { 335 | One.remove({}); 336 | One.insert({ name: 'value1' }); 337 | One.insert({ name: 'value2' }); 338 | 339 | Two.remove({}); 340 | Two.insert({ name: 'value3' }); 341 | Two.insert({ name: 'value4' }); 342 | Two.insert({ name: 'value5' }); 343 | 344 | Meteor.publish('multiPublish', function(options) { 345 | // See below to see what options are accepted. 346 | check(options, Object); 347 | if (options.normal) { 348 | return [One.find(), Two.find()]; 349 | } else if (options.dup) { 350 | // Suppress the log of the expected internal error. 351 | Meteor._suppress_log(1); 352 | return [ 353 | One.find(), 354 | One.find({ name: 'value2' }), // multiple cursors for one collection - error 355 | Two.find() 356 | ]; 357 | } else if (options.notCursor) { 358 | // Suppress the log of the expected internal error. 359 | Meteor._suppress_log(1); 360 | return [One.find(), 'not a cursor', Two.find()]; 361 | } else throw 'unexpected options'; 362 | }); 363 | } 364 | 365 | /// Helper for "livedata - result by value" 366 | var resultByValueArrays = {}; 367 | Meteor.methods({ 368 | getArray: function(testId) { 369 | if (!_.has(resultByValueArrays, testId)) resultByValueArrays[testId] = []; 370 | return resultByValueArrays[testId]; 371 | }, 372 | pushToArray: function(testId, value) { 373 | if (!_.has(resultByValueArrays, testId)) resultByValueArrays[testId] = []; 374 | resultByValueArrays[testId].push(value); 375 | } 376 | }); 377 | -------------------------------------------------------------------------------- /test/livedata_tests.js: -------------------------------------------------------------------------------- 1 | import { DDP, LivedataTest } from '../common/namespace.js'; 2 | 3 | // XXX should check error codes 4 | var failure = function(test, code, reason) { 5 | return function(error, result) { 6 | test.equal(result, undefined); 7 | test.isTrue(error && typeof error === 'object'); 8 | if (error && typeof error === 'object') { 9 | if (typeof code === 'number') { 10 | test.instanceOf(error, Meteor.Error); 11 | code && test.equal(error.error, code); 12 | reason && test.equal(error.reason, reason); 13 | // XXX should check that other keys aren't present.. should 14 | // probably use something like the Matcher we used to have 15 | } else { 16 | // for normal Javascript errors 17 | test.instanceOf(error, Error); 18 | test.equal(error.message, code); 19 | } 20 | } 21 | }; 22 | }; 23 | 24 | var failureOnStopped = function(test, code, reason) { 25 | var f = failure(test, code, reason); 26 | 27 | return function(error) { 28 | if (error) { 29 | f(error); 30 | } 31 | }; 32 | }; 33 | 34 | Tinytest.add('livedata - Meteor.Error', function(test) { 35 | var error = new Meteor.Error(123, 'kittens', 'puppies'); 36 | test.instanceOf(error, Meteor.Error); 37 | test.instanceOf(error, Error); 38 | test.equal(error.error, 123); 39 | test.equal(error.reason, 'kittens'); 40 | test.equal(error.details, 'puppies'); 41 | }); 42 | 43 | if (Meteor.isServer) { 44 | Tinytest.add('livedata - version negotiation', function(test) { 45 | var versionCheck = function(clientVersions, serverVersions, expected) { 46 | test.equal( 47 | DDPServer._calculateVersion(clientVersions, serverVersions), 48 | expected 49 | ); 50 | }; 51 | 52 | versionCheck(['A', 'B', 'C'], ['A', 'B', 'C'], 'A'); 53 | versionCheck(['B', 'C'], ['A', 'B', 'C'], 'B'); 54 | versionCheck(['A', 'B', 'C'], ['B', 'C'], 'B'); 55 | versionCheck(['foo', 'bar', 'baz'], ['A', 'B', 'C'], 'A'); 56 | }); 57 | } 58 | 59 | Tinytest.add('livedata - methods with colliding names', function(test) { 60 | var x = Random.id(); 61 | var m = {}; 62 | m[x] = function() {}; 63 | Meteor.methods(m); 64 | 65 | test.throws(function() { 66 | Meteor.methods(m); 67 | }); 68 | }); 69 | 70 | Tinytest.add('livedata - non-function method', function(test) { 71 | var x = Random.id(); 72 | var m = {}; 73 | m[x] = 'kitten'; 74 | 75 | test.throws(function() { 76 | Meteor.methods(m); 77 | }); 78 | }); 79 | 80 | var echoTest = function(item) { 81 | return function(test, expect) { 82 | if (Meteor.isServer) { 83 | test.equal(Meteor.call('echo', item), [item]); 84 | test.equal(Meteor.call('echoOne', item), item); 85 | } 86 | if (Meteor.isClient) test.equal(Meteor.call('echo', item), undefined); 87 | 88 | test.equal(Meteor.call('echo', item, expect(undefined, [item])), undefined); 89 | test.equal( 90 | Meteor.call('echoOne', item, expect(undefined, item)), 91 | undefined 92 | ); 93 | }; 94 | }; 95 | 96 | testAsyncMulti('livedata - basic method invocation', [ 97 | // Unknown methods 98 | function(test, expect) { 99 | if (Meteor.isServer) { 100 | // On server, with no callback, throws exception 101 | try { 102 | var ret = Meteor.call('unknown method'); 103 | } catch (e) { 104 | test.equal(e.error, 404); 105 | var threw = true; 106 | } 107 | test.isTrue(threw); 108 | test.equal(ret, undefined); 109 | } 110 | 111 | if (Meteor.isClient) { 112 | // On client, with no callback, just returns undefined 113 | var ret = Meteor.call('unknown method'); 114 | test.equal(ret, undefined); 115 | } 116 | 117 | // On either, with a callback, calls the callback and does not throw 118 | var ret = Meteor.call( 119 | 'unknown method', 120 | expect(failure(test, 404, "Method 'unknown method' not found")) 121 | ); 122 | test.equal(ret, undefined); 123 | }, 124 | 125 | function(test, expect) { 126 | // make sure 'undefined' is preserved as such, instead of turning 127 | // into null (JSON does not have 'undefined' so there is special 128 | // code for this) 129 | if (Meteor.isServer) test.equal(Meteor.call('nothing'), undefined); 130 | if (Meteor.isClient) test.equal(Meteor.call('nothing'), undefined); 131 | 132 | test.equal(Meteor.call('nothing', expect(undefined, undefined)), undefined); 133 | }, 134 | 135 | function(test, expect) { 136 | if (Meteor.isServer) test.equal(Meteor.call('echo'), []); 137 | if (Meteor.isClient) test.equal(Meteor.call('echo'), undefined); 138 | 139 | test.equal(Meteor.call('echo', expect(undefined, [])), undefined); 140 | }, 141 | 142 | echoTest(new Date()), 143 | echoTest({ d: new Date(), s: 'foobarbaz' }), 144 | echoTest([new Date(), 'foobarbaz']), 145 | echoTest(new Mongo.ObjectID()), 146 | echoTest({ o: new Mongo.ObjectID() }), 147 | echoTest({ $date: 30 }), // literal 148 | echoTest({ $literal: { $date: 30 } }), 149 | echoTest(12), 150 | echoTest(Infinity), 151 | echoTest(-Infinity), 152 | 153 | function(test, expect) { 154 | if (Meteor.isServer) 155 | test.equal(Meteor.call('echo', 12, { x: 13 }), [12, { x: 13 }]); 156 | if (Meteor.isClient) 157 | test.equal(Meteor.call('echo', 12, { x: 13 }), undefined); 158 | 159 | test.equal( 160 | Meteor.call('echo', 12, { x: 13 }, expect(undefined, [12, { x: 13 }])), 161 | undefined 162 | ); 163 | }, 164 | 165 | // test that `wait: false` is respected 166 | function(test, expect) { 167 | if (Meteor.isClient) { 168 | // For test isolation 169 | var token = Random.id(); 170 | Meteor.apply( 171 | 'delayedTrue', 172 | [token], 173 | { wait: false }, 174 | expect(function(err, res) { 175 | test.equal(res, false); 176 | }) 177 | ); 178 | Meteor.apply('makeDelayedTrueImmediatelyReturnFalse', [token]); 179 | } 180 | }, 181 | 182 | // test that `wait: true` is respected 183 | function(test, expect) { 184 | if (Meteor.isClient) { 185 | var token = Random.id(); 186 | Meteor.apply( 187 | 'delayedTrue', 188 | [token], 189 | { wait: true }, 190 | expect(function(err, res) { 191 | test.equal(res, true); 192 | }) 193 | ); 194 | Meteor.apply('makeDelayedTrueImmediatelyReturnFalse', [token]); 195 | } 196 | }, 197 | 198 | function(test, expect) { 199 | // No callback 200 | 201 | if (Meteor.isServer) { 202 | test.throws(function() { 203 | Meteor.call('exception', 'both'); 204 | }); 205 | test.throws(function() { 206 | Meteor.call('exception', 'server'); 207 | }); 208 | // No exception, because no code will run on the client 209 | test.equal(Meteor.call('exception', 'client'), undefined); 210 | } 211 | 212 | if (Meteor.isClient) { 213 | // The client exception is thrown away because it's in the 214 | // stub. The server exception is throw away because we didn't 215 | // give a callback. 216 | test.equal(Meteor.call('exception', 'both'), undefined); 217 | test.equal(Meteor.call('exception', 'server'), undefined); 218 | test.equal(Meteor.call('exception', 'client'), undefined); 219 | 220 | // If we pass throwStubExceptions then we *should* see thrown exceptions 221 | // on the client 222 | test.throws(function() { 223 | Meteor.apply('exception', ['both'], { throwStubExceptions: true }); 224 | }); 225 | test.equal( 226 | Meteor.apply('exception', ['server'], { throwStubExceptions: true }), 227 | undefined 228 | ); 229 | test.throws(function() { 230 | Meteor.apply('exception', ['client'], { throwStubExceptions: true }); 231 | }); 232 | } 233 | 234 | // With callback 235 | 236 | if (Meteor.isClient) { 237 | test.equal( 238 | Meteor.call( 239 | 'exception', 240 | 'both', 241 | expect(failure(test, 500, 'Internal server error')) 242 | ), 243 | undefined 244 | ); 245 | test.equal( 246 | Meteor.call( 247 | 'exception', 248 | 'server', 249 | expect(failure(test, 500, 'Internal server error')) 250 | ), 251 | undefined 252 | ); 253 | test.equal(Meteor.call('exception', 'client'), undefined); 254 | } 255 | 256 | if (Meteor.isServer) { 257 | test.equal( 258 | Meteor.call( 259 | 'exception', 260 | 'both', 261 | expect(failure(test, 'Test method throwing an exception')) 262 | ), 263 | undefined 264 | ); 265 | test.equal( 266 | Meteor.call( 267 | 'exception', 268 | 'server', 269 | expect(failure(test, 'Test method throwing an exception')) 270 | ), 271 | undefined 272 | ); 273 | test.equal(Meteor.call('exception', 'client'), undefined); 274 | } 275 | }, 276 | 277 | function(test, expect) { 278 | if (Meteor.isServer) { 279 | var threw = false; 280 | try { 281 | Meteor.call('exception', 'both', { intended: true }); 282 | } catch (e) { 283 | threw = true; 284 | test.equal(e.error, 999); 285 | test.equal(e.reason, 'Client-visible test exception'); 286 | } 287 | test.isTrue(threw); 288 | threw = false; 289 | try { 290 | Meteor.call('exception', 'both', { 291 | intended: true, 292 | throwThroughFuture: true 293 | }); 294 | } catch (e) { 295 | threw = true; 296 | test.equal(e.error, 999); 297 | test.equal(e.reason, 'Client-visible test exception'); 298 | } 299 | test.isTrue(threw); 300 | } 301 | 302 | if (Meteor.isClient) { 303 | test.equal( 304 | Meteor.call( 305 | 'exception', 306 | 'both', 307 | { intended: true }, 308 | expect(failure(test, 999, 'Client-visible test exception')) 309 | ), 310 | undefined 311 | ); 312 | test.equal( 313 | Meteor.call( 314 | 'exception', 315 | 'server', 316 | { intended: true }, 317 | expect(failure(test, 999, 'Client-visible test exception')) 318 | ), 319 | undefined 320 | ); 321 | test.equal( 322 | Meteor.call( 323 | 'exception', 324 | 'server', 325 | { 326 | intended: true, 327 | throwThroughFuture: true 328 | }, 329 | expect(failure(test, 999, 'Client-visible test exception')) 330 | ), 331 | undefined 332 | ); 333 | } 334 | } 335 | ]); 336 | 337 | var checkBalances = function(test, a, b) { 338 | var alice = Ledger.findOne({ name: 'alice', world: test.runId() }); 339 | var bob = Ledger.findOne({ name: 'bob', world: test.runId() }); 340 | test.equal(alice.balance, a); 341 | test.equal(bob.balance, b); 342 | }; 343 | 344 | // would be nice to have a database-aware test harness of some kind -- 345 | // this is a big hack (and XXX pollutes the global test namespace) 346 | testAsyncMulti('livedata - compound methods', [ 347 | function(test, expect) { 348 | if (Meteor.isClient) Meteor.subscribe('ledger', test.runId(), expect()); 349 | 350 | Ledger.insert( 351 | { name: 'alice', balance: 100, world: test.runId() }, 352 | expect(function() {}) 353 | ); 354 | Ledger.insert( 355 | { name: 'bob', balance: 50, world: test.runId() }, 356 | expect(function() {}) 357 | ); 358 | }, 359 | function(test, expect) { 360 | Meteor.call( 361 | 'ledger/transfer', 362 | test.runId(), 363 | 'alice', 364 | 'bob', 365 | 10, 366 | expect(function(err, result) { 367 | test.equal(err, undefined); 368 | test.equal(result, undefined); 369 | checkBalances(test, 90, 60); 370 | }) 371 | ); 372 | checkBalances(test, 90, 60); 373 | }, 374 | function(test, expect) { 375 | Meteor.call( 376 | 'ledger/transfer', 377 | test.runId(), 378 | 'alice', 379 | 'bob', 380 | 100, 381 | true, 382 | expect(function(err, result) { 383 | failure(test, 409)(err, result); 384 | // Balances are reverted back to pre-stub values. 385 | checkBalances(test, 90, 60); 386 | }) 387 | ); 388 | 389 | if (Meteor.isClient) 390 | // client can fool itself by cheating, but only until the sync 391 | // finishes 392 | checkBalances(test, -10, 160); 393 | else checkBalances(test, 90, 60); 394 | } 395 | ]); 396 | 397 | // Replaces the Connection's `_livedata_data` method to push incoming 398 | // messages on a given collection to an array. This can be used to 399 | // verify that the right data is sent on the wire 400 | // 401 | // @param messages {Array} The array to which to append the messages 402 | // @return {Function} A function to call to undo the eavesdropping 403 | var eavesdropOnCollection = function( 404 | livedata_connection, 405 | collection_name, 406 | messages 407 | ) { 408 | var old_livedata_data = _.bind( 409 | livedata_connection._livedata_data, 410 | livedata_connection 411 | ); 412 | 413 | // Kind of gross since all tests past this one will run with this 414 | // hook set up. That's probably fine since we only check a specific 415 | // collection but still... 416 | // 417 | // Should we consider having a separate connection per Tinytest or 418 | // some similar scheme? 419 | livedata_connection._livedata_data = function(msg) { 420 | if (msg.collection && msg.collection === collection_name) { 421 | messages.push(msg); 422 | } 423 | old_livedata_data(msg); 424 | }; 425 | 426 | return function() { 427 | livedata_connection._livedata_data = old_livedata_data; 428 | }; 429 | }; 430 | 431 | if (Meteor.isClient) { 432 | testAsyncMulti( 433 | 'livedata - changing userid reruns subscriptions without flapping data on the wire', 434 | [ 435 | function(test, expect) { 436 | var messages = []; 437 | var undoEavesdrop = eavesdropOnCollection( 438 | Meteor.connection, 439 | 'objectsWithUsers', 440 | messages 441 | ); 442 | 443 | // A helper for testing incoming set and unset messages 444 | // XXX should this be extracted as a general helper together with 445 | // eavesdropOnCollection? 446 | var expectMessages = function( 447 | expectedAddedMessageCount, 448 | expectedRemovedMessageCount, 449 | expectedNamesInCollection 450 | ) { 451 | var actualAddedMessageCount = 0; 452 | var actualRemovedMessageCount = 0; 453 | _.each(messages, function(msg) { 454 | if (msg.msg === 'added') ++actualAddedMessageCount; 455 | else if (msg.msg === 'removed') ++actualRemovedMessageCount; 456 | else test.fail({ unexpected: JSON.stringify(msg) }); 457 | }); 458 | test.equal(actualAddedMessageCount, expectedAddedMessageCount); 459 | test.equal(actualRemovedMessageCount, expectedRemovedMessageCount); 460 | expectedNamesInCollection.sort(); 461 | test.equal( 462 | _.pluck( 463 | objectsWithUsers.find({}, { sort: ['name'] }).fetch(), 464 | 'name' 465 | ), 466 | expectedNamesInCollection 467 | ); 468 | messages.length = 0; // clear messages without creating a new object 469 | }; 470 | 471 | // make sure we're not already logged in. can happen if accounts 472 | // tests fail oddly. 473 | Meteor.apply( 474 | 'setUserId', 475 | [null], 476 | { wait: true }, 477 | expect(function() {}) 478 | ); 479 | 480 | Meteor.subscribe( 481 | 'objectsWithUsers', 482 | expect(function() { 483 | expectMessages(1, 0, ['owned by none']); 484 | Meteor.apply( 485 | 'setUserId', 486 | ['1'], 487 | { wait: true }, 488 | afterFirstSetUserId 489 | ); 490 | }) 491 | ); 492 | 493 | var afterFirstSetUserId = expect(function() { 494 | expectMessages(3, 1, [ 495 | 'owned by one - a', 496 | 'owned by one/two - a', 497 | 'owned by one/two - b' 498 | ]); 499 | Meteor.apply( 500 | 'setUserId', 501 | ['2'], 502 | { wait: true }, 503 | afterSecondSetUserId 504 | ); 505 | }); 506 | 507 | var afterSecondSetUserId = expect(function() { 508 | expectMessages(2, 1, [ 509 | 'owned by one/two - a', 510 | 'owned by one/two - b', 511 | 'owned by two - a', 512 | 'owned by two - b' 513 | ]); 514 | Meteor.apply('setUserId', ['2'], { wait: true }, afterThirdSetUserId); 515 | }); 516 | 517 | var afterThirdSetUserId = expect(function() { 518 | // Nothing should have been sent since the results of the 519 | // query are the same ("don't flap data on the wire") 520 | expectMessages(0, 0, [ 521 | 'owned by one/two - a', 522 | 'owned by one/two - b', 523 | 'owned by two - a', 524 | 'owned by two - b' 525 | ]); 526 | undoEavesdrop(); 527 | }); 528 | }, 529 | function(test, expect) { 530 | var key = Random.id(); 531 | Meteor.subscribe('recordUserIdOnStop', key); 532 | Meteor.apply( 533 | 'setUserId', 534 | ['100'], 535 | { wait: true }, 536 | expect(function() {}) 537 | ); 538 | Meteor.apply( 539 | 'setUserId', 540 | ['101'], 541 | { wait: true }, 542 | expect(function() {}) 543 | ); 544 | Meteor.call( 545 | 'userIdWhenStopped', 546 | key, 547 | expect(function(err, result) { 548 | test.isFalse(err); 549 | test.equal(result, '100'); 550 | }) 551 | ); 552 | // clean up 553 | Meteor.apply( 554 | 'setUserId', 555 | [null], 556 | { wait: true }, 557 | expect(function() {}) 558 | ); 559 | } 560 | ] 561 | ); 562 | } 563 | 564 | Tinytest.add('livedata - setUserId error when called from server', function( 565 | test 566 | ) { 567 | if (Meteor.isServer) { 568 | test.equal( 569 | errorThrownWhenCallingSetUserIdDirectlyOnServer.message, 570 | "Can't call setUserId on a server initiated method call" 571 | ); 572 | } 573 | }); 574 | 575 | if (Meteor.isServer) { 576 | var pubHandles = {}; 577 | } 578 | Meteor.methods({ 579 | 'livedata/setup': function(id) { 580 | check(id, String); 581 | if (Meteor.isServer) { 582 | pubHandles[id] = {}; 583 | Meteor.publish('pub1' + id, function() { 584 | pubHandles[id].pub1 = this; 585 | this.ready(); 586 | }); 587 | Meteor.publish('pub2' + id, function() { 588 | pubHandles[id].pub2 = this; 589 | this.ready(); 590 | }); 591 | } 592 | }, 593 | 'livedata/pub1go': function(id) { 594 | check(id, String); 595 | if (Meteor.isServer) { 596 | pubHandles[id].pub1.added('MultiPubCollection' + id, 'foo', { a: 'aa' }); 597 | return 1; 598 | } 599 | return 0; 600 | }, 601 | 'livedata/pub2go': function(id) { 602 | check(id, String); 603 | if (Meteor.isServer) { 604 | pubHandles[id].pub2.added('MultiPubCollection' + id, 'foo', { b: 'bb' }); 605 | return 2; 606 | } 607 | return 0; 608 | } 609 | }); 610 | 611 | if (Meteor.isClient) { 612 | (function() { 613 | var MultiPub; 614 | var id = Random.id(); 615 | testAsyncMulti('livedata - added from two different subs', [ 616 | function(test, expect) { 617 | Meteor.call('livedata/setup', id, expect(function() {})); 618 | }, 619 | function(test, expect) { 620 | MultiPub = new Mongo.Collection('MultiPubCollection' + id); 621 | var sub1 = Meteor.subscribe('pub1' + id, expect(function() {})); 622 | var sub2 = Meteor.subscribe('pub2' + id, expect(function() {})); 623 | }, 624 | function(test, expect) { 625 | Meteor.call( 626 | 'livedata/pub1go', 627 | id, 628 | expect(function(err, res) { 629 | test.equal(res, 1); 630 | }) 631 | ); 632 | }, 633 | function(test, expect) { 634 | test.equal(MultiPub.findOne('foo'), { _id: 'foo', a: 'aa' }); 635 | }, 636 | function(test, expect) { 637 | Meteor.call( 638 | 'livedata/pub2go', 639 | id, 640 | expect(function(err, res) { 641 | test.equal(res, 2); 642 | }) 643 | ); 644 | }, 645 | function(test, expect) { 646 | test.equal(MultiPub.findOne('foo'), { _id: 'foo', a: 'aa', b: 'bb' }); 647 | } 648 | ]); 649 | })(); 650 | } 651 | 652 | if (Meteor.isClient) { 653 | testAsyncMulti('livedata - overlapping universal subs', [ 654 | function(test, expect) { 655 | var coll = new Mongo.Collection('overlappingUniversalSubs'); 656 | var token = Random.id(); 657 | test.isFalse(coll.findOne(token)); 658 | Meteor.call( 659 | 'testOverlappingSubs', 660 | token, 661 | expect(function(err) { 662 | test.isFalse(err); 663 | test.isTrue(coll.findOne(token)); 664 | }) 665 | ); 666 | } 667 | ]); 668 | 669 | testAsyncMulti('livedata - runtime universal sub creation', [ 670 | function(test, expect) { 671 | var coll = new Mongo.Collection('runtimeSubCreation'); 672 | var token = Random.id(); 673 | test.isFalse(coll.findOne(token)); 674 | Meteor.call( 675 | 'runtimeUniversalSubCreation', 676 | token, 677 | expect(function(err) { 678 | test.isFalse(err); 679 | test.isTrue(coll.findOne(token)); 680 | }) 681 | ); 682 | } 683 | ]); 684 | 685 | testAsyncMulti('livedata - no setUserId after unblock', [ 686 | function(test, expect) { 687 | Meteor.call( 688 | 'setUserIdAfterUnblock', 689 | expect(function(err, result) { 690 | test.isFalse(err); 691 | test.isTrue(result); 692 | }) 693 | ); 694 | } 695 | ]); 696 | 697 | testAsyncMulti( 698 | 'livedata - publisher errors with onError callback', 699 | (function() { 700 | var conn, collName, coll; 701 | var errorFromRerun; 702 | var gotErrorFromStopper = false; 703 | return [ 704 | function(test, expect) { 705 | // Use a separate connection so that we can safely check to see if 706 | // conn._subscriptions is empty. 707 | conn = new LivedataTest.Connection('/', { 708 | reloadWithOutstanding: true 709 | }); 710 | collName = Random.id(); 711 | coll = new Mongo.Collection(collName, { connection: conn }); 712 | 713 | var testSubError = function(options) { 714 | conn.subscribe('publisherErrors', collName, options, { 715 | onReady: expect(), 716 | onError: expect( 717 | failure( 718 | test, 719 | options.internalError ? 500 : 412, 720 | options.internalError 721 | ? 'Internal server error' 722 | : 'Explicit error' 723 | ) 724 | ) 725 | }); 726 | }; 727 | testSubError({ throwInHandler: true }); 728 | testSubError({ throwInHandler: true, internalError: true }); 729 | testSubError({ errorInHandler: true }); 730 | testSubError({ errorInHandler: true, internalError: true }); 731 | testSubError({ errorLater: true }); 732 | testSubError({ errorLater: true, internalError: true }); 733 | }, 734 | function(test, expect) { 735 | test.equal(coll.find().count(), 0); 736 | test.equal(_.size(conn._subscriptions), 0); // white-box test 737 | 738 | conn.subscribe( 739 | 'publisherErrors', 740 | collName, 741 | { throwWhenUserIdSet: true }, 742 | { 743 | onReady: expect(), 744 | onError: function(error) { 745 | errorFromRerun = error; 746 | } 747 | } 748 | ); 749 | }, 750 | function(test, expect) { 751 | // Because the last subscription is ready, we should have a document. 752 | test.equal(coll.find().count(), 1); 753 | test.isFalse(errorFromRerun); 754 | test.equal(_.size(conn._subscriptions), 1); // white-box test 755 | conn.call('setUserId', 'bla', expect(function() {})); 756 | }, 757 | function(test, expect) { 758 | // Now that we've re-run, we should have stopped the subscription, 759 | // gotten a error, and lost the document. 760 | test.equal(coll.find().count(), 0); 761 | test.isTrue(errorFromRerun); 762 | test.instanceOf(errorFromRerun, Meteor.Error); 763 | test.equal(errorFromRerun.error, 412); 764 | test.equal(errorFromRerun.reason, 'Explicit error'); 765 | test.equal(_.size(conn._subscriptions), 0); // white-box test 766 | 767 | conn.subscribe( 768 | 'publisherErrors', 769 | collName, 770 | { stopInHandler: true }, 771 | { 772 | onError: function() { 773 | gotErrorFromStopper = true; 774 | } 775 | } 776 | ); 777 | // Call a method. This method won't be processed until the publisher's 778 | // function returns, so blocking on it being done ensures that we've 779 | // gotten the removed/nosub/etc. 780 | conn.call('nothing', expect(function() {})); 781 | }, 782 | function(test, expect) { 783 | test.equal(coll.find().count(), 0); 784 | // sub.stop does NOT call onError. 785 | test.isFalse(gotErrorFromStopper); 786 | test.equal(_.size(conn._subscriptions), 0); // white-box test 787 | conn._stream.disconnect({ _permanent: true }); 788 | } 789 | ]; 790 | })() 791 | ); 792 | 793 | testAsyncMulti( 794 | 'livedata - publisher errors with onStop callback', 795 | (function() { 796 | var conn, collName, coll; 797 | var errorFromRerun; 798 | var gotErrorFromStopper = false; 799 | return [ 800 | function(test, expect) { 801 | // Use a separate connection so that we can safely check to see if 802 | // conn._subscriptions is empty. 803 | conn = new LivedataTest.Connection('/', { 804 | reloadWithOutstanding: true 805 | }); 806 | collName = Random.id(); 807 | coll = new Mongo.Collection(collName, { connection: conn }); 808 | 809 | var testSubError = function(options) { 810 | conn.subscribe('publisherErrors', collName, options, { 811 | onReady: expect(), 812 | onStop: expect( 813 | failureOnStopped( 814 | test, 815 | options.internalError ? 500 : 412, 816 | options.internalError 817 | ? 'Internal server error' 818 | : 'Explicit error' 819 | ) 820 | ) 821 | }); 822 | }; 823 | testSubError({ throwInHandler: true }); 824 | testSubError({ throwInHandler: true, internalError: true }); 825 | testSubError({ errorInHandler: true }); 826 | testSubError({ errorInHandler: true, internalError: true }); 827 | testSubError({ errorLater: true }); 828 | testSubError({ errorLater: true, internalError: true }); 829 | }, 830 | function(test, expect) { 831 | test.equal(coll.find().count(), 0); 832 | test.equal(_.size(conn._subscriptions), 0); // white-box test 833 | 834 | conn.subscribe( 835 | 'publisherErrors', 836 | collName, 837 | { throwWhenUserIdSet: true }, 838 | { 839 | onReady: expect(), 840 | onStop: function(error) { 841 | errorFromRerun = error; 842 | } 843 | } 844 | ); 845 | }, 846 | function(test, expect) { 847 | // Because the last subscription is ready, we should have a document. 848 | test.equal(coll.find().count(), 1); 849 | test.isFalse(errorFromRerun); 850 | test.equal(_.size(conn._subscriptions), 1); // white-box test 851 | conn.call('setUserId', 'bla', expect(function() {})); 852 | }, 853 | function(test, expect) { 854 | // Now that we've re-run, we should have stopped the subscription, 855 | // gotten a error, and lost the document. 856 | test.equal(coll.find().count(), 0); 857 | test.isTrue(errorFromRerun); 858 | test.instanceOf(errorFromRerun, Meteor.Error); 859 | test.equal(errorFromRerun.error, 412); 860 | test.equal(errorFromRerun.reason, 'Explicit error'); 861 | test.equal(_.size(conn._subscriptions), 0); // white-box test 862 | 863 | conn.subscribe( 864 | 'publisherErrors', 865 | collName, 866 | { stopInHandler: true }, 867 | { 868 | onStop: function(error) { 869 | if (error) { 870 | gotErrorFromStopper = true; 871 | } 872 | } 873 | } 874 | ); 875 | // Call a method. This method won't be processed until the publisher's 876 | // function returns, so blocking on it being done ensures that we've 877 | // gotten the removed/nosub/etc. 878 | conn.call('nothing', expect(function() {})); 879 | }, 880 | function(test, expect) { 881 | test.equal(coll.find().count(), 0); 882 | // sub.stop does NOT call onError. 883 | test.isFalse(gotErrorFromStopper); 884 | test.equal(_.size(conn._subscriptions), 0); // white-box test 885 | conn._stream.disconnect({ _permanent: true }); 886 | } 887 | ]; 888 | })() 889 | ); 890 | 891 | testAsyncMulti('livedata - publish multiple cursors', [ 892 | function(test, expect) { 893 | var sub = Meteor.subscribe( 894 | 'multiPublish', 895 | { normal: 1 }, 896 | { 897 | onReady: expect(function() { 898 | test.isTrue(sub.ready()); 899 | test.equal(One.find().count(), 2); 900 | test.equal(Two.find().count(), 3); 901 | }), 902 | onError: failure() 903 | } 904 | ); 905 | }, 906 | function(test, expect) { 907 | Meteor.subscribe( 908 | 'multiPublish', 909 | { dup: 1 }, 910 | { 911 | onReady: failure(), 912 | onError: expect(failure(test, 500, 'Internal server error')) 913 | } 914 | ); 915 | }, 916 | function(test, expect) { 917 | Meteor.subscribe( 918 | 'multiPublish', 919 | { notCursor: 1 }, 920 | { 921 | onReady: failure(), 922 | onError: expect(failure(test, 500, 'Internal server error')) 923 | } 924 | ); 925 | } 926 | ]); 927 | } 928 | 929 | var selfUrl = Meteor.isServer 930 | ? Meteor.absoluteUrl() 931 | : Meteor._relativeToSiteRootUrl('/'); 932 | 933 | if (Meteor.isServer) { 934 | Meteor.methods({ 935 | s2s: function(arg) { 936 | check(arg, String); 937 | return 's2s ' + arg; 938 | } 939 | }); 940 | } 941 | (function() { 942 | testAsyncMulti('livedata - connect works from both client and server', [ 943 | function(test, expect) { 944 | var self = this; 945 | self.conn = DDP.connect(selfUrl); 946 | pollUntil( 947 | expect, 948 | function() { 949 | return self.conn.status().connected; 950 | }, 951 | 10000 952 | ); 953 | }, 954 | 955 | function(test, expect) { 956 | var self = this; 957 | if (self.conn.status().connected) { 958 | self.conn.call( 959 | 's2s', 960 | 'foo', 961 | expect(function(err, res) { 962 | if (err) throw err; 963 | test.equal(res, 's2s foo'); 964 | }) 965 | ); 966 | } 967 | } 968 | ]); 969 | })(); 970 | 971 | if (Meteor.isServer) { 972 | (function() { 973 | testAsyncMulti('livedata - method call on server blocks in a fiber way', [ 974 | function(test, expect) { 975 | var self = this; 976 | self.conn = DDP.connect(selfUrl); 977 | pollUntil( 978 | expect, 979 | function() { 980 | return self.conn.status().connected; 981 | }, 982 | 10000 983 | ); 984 | }, 985 | 986 | function(test, expect) { 987 | var self = this; 988 | if (self.conn.status().connected) { 989 | test.equal(self.conn.call('s2s', 'foo'), 's2s foo'); 990 | } 991 | } 992 | ]); 993 | })(); 994 | } 995 | 996 | (function() { 997 | testAsyncMulti('livedata - connect fails to unknown place', [ 998 | function(test, expect) { 999 | var self = this; 1000 | self.conn = DDP.connect('example.com', { _dontPrintErrors: true }); 1001 | Meteor.setTimeout( 1002 | expect(function() { 1003 | test.isFalse(self.conn.status().connected, 'Not connected'); 1004 | self.conn.close(); 1005 | }), 1006 | 500 1007 | ); 1008 | } 1009 | ]); 1010 | })(); 1011 | 1012 | if (Meteor.isServer) { 1013 | Meteor.publish('publisherCloning', function() { 1014 | var self = this; 1015 | var fields = { x: { y: 42 } }; 1016 | self.added('publisherCloning', 'a', fields); 1017 | fields.x.y = 43; 1018 | self.changed('publisherCloning', 'a', fields); 1019 | self.ready(); 1020 | }); 1021 | } else { 1022 | var PublisherCloningCollection = new Mongo.Collection('publisherCloning'); 1023 | testAsyncMulti('livedata - publish callbacks clone', [ 1024 | function(test, expect) { 1025 | Meteor.subscribe( 1026 | 'publisherCloning', 1027 | { normal: 1 }, 1028 | { 1029 | onReady: expect(function() { 1030 | test.equal(PublisherCloningCollection.findOne(), { 1031 | _id: 'a', 1032 | x: { y: 43 } 1033 | }); 1034 | }), 1035 | onError: failure() 1036 | } 1037 | ); 1038 | } 1039 | ]); 1040 | } 1041 | 1042 | testAsyncMulti('livedata - result by value', [ 1043 | function(test, expect) { 1044 | var self = this; 1045 | self.testId = Random.id(); 1046 | Meteor.call( 1047 | 'getArray', 1048 | self.testId, 1049 | expect(function(error, firstResult) { 1050 | test.isFalse(error); 1051 | test.isTrue(firstResult); 1052 | self.firstResult = firstResult; 1053 | }) 1054 | ); 1055 | }, 1056 | function(test, expect) { 1057 | var self = this; 1058 | Meteor.call( 1059 | 'pushToArray', 1060 | self.testId, 1061 | 'xxx', 1062 | expect(function(error) { 1063 | test.isFalse(error); 1064 | }) 1065 | ); 1066 | }, 1067 | function(test, expect) { 1068 | var self = this; 1069 | Meteor.call( 1070 | 'getArray', 1071 | self.testId, 1072 | expect(function(error, secondResult) { 1073 | test.isFalse(error); 1074 | test.equal(self.firstResult.length + 1, secondResult.length); 1075 | }) 1076 | ); 1077 | } 1078 | ]); 1079 | 1080 | // XXX some things to test in greater detail: 1081 | // staying in simulation mode 1082 | // time warp 1083 | // serialization / beginAsync(true) / beginAsync(false) 1084 | // malformed messages (need raw wire access) 1085 | // method completion/satisfaction 1086 | // subscriptions (multiple APIs, including autorun?) 1087 | // subscription completion 1088 | // subscription attribute shadowing 1089 | // server method calling methods on other server (eg, should simulate) 1090 | // subscriptions and methods being idempotent 1091 | // reconnection 1092 | // reconnection not resulting in method re-execution 1093 | // reconnection tolerating all kinds of lost messages (including data) 1094 | // [probably lots more] 1095 | -------------------------------------------------------------------------------- /test/random_stream_tests.js: -------------------------------------------------------------------------------- 1 | Tinytest.add('livedata - DDP.randomStream', function(test) { 2 | var randomSeed = Random.id(); 3 | var context = { randomSeed: randomSeed }; 4 | 5 | var sequence = DDP._CurrentMethodInvocation.withValue(context, function() { 6 | return DDP.randomStream('1'); 7 | }); 8 | 9 | var seeds = sequence.alea.args; 10 | 11 | test.equal(seeds.length, 2); 12 | test.equal(seeds[0], randomSeed); 13 | test.equal(seeds[1], '1'); 14 | 15 | var id1 = sequence.id(); 16 | 17 | // Clone the sequence by building it the same way RandomStream.get does 18 | var sequenceClone = Random.createWithSeeds.apply(null, seeds); 19 | var id1Cloned = sequenceClone.id(); 20 | var id2Cloned = sequenceClone.id(); 21 | test.equal(id1, id1Cloned); 22 | 23 | // We should get the same sequence when we use the same key 24 | sequence = DDP._CurrentMethodInvocation.withValue(context, function() { 25 | return DDP.randomStream('1'); 26 | }); 27 | seeds = sequence.alea.args; 28 | test.equal(seeds.length, 2); 29 | test.equal(seeds[0], randomSeed); 30 | test.equal(seeds[1], '1'); 31 | 32 | // But we should be at the 'next' position in the stream 33 | var id2 = sequence.id(); 34 | 35 | // Technically these could be equal, but likely to be a bug if hit 36 | // http://search.dilbert.com/comic/Random%20Number%20Generator 37 | test.notEqual(id1, id2); 38 | 39 | test.equal(id2, id2Cloned); 40 | }); 41 | 42 | Tinytest.add('livedata - DDP.randomStream with no-args', function(test) { 43 | DDP.randomStream().id(); 44 | }); 45 | -------------------------------------------------------------------------------- /test/stream_client_tests.js: -------------------------------------------------------------------------------- 1 | import { LivedataTest } from '../common/namespace.js'; 2 | 3 | var Fiber = Npm.require('fibers'); 4 | 5 | testAsyncMulti('stream client - callbacks run in a fiber', [ 6 | function(test, expect) { 7 | var stream = new LivedataTest.ClientStream(Meteor.absoluteUrl()); 8 | 9 | var messageFired = false; 10 | var resetFired = false; 11 | 12 | stream.on( 13 | 'message', 14 | expect(function() { 15 | test.isTrue(Fiber.current); 16 | if (resetFired) stream.disconnect(); 17 | messageFired = true; 18 | }) 19 | ); 20 | 21 | stream.on( 22 | 'reset', 23 | expect(function() { 24 | test.isTrue(Fiber.current); 25 | if (messageFired) stream.disconnect(); 26 | resetFired = true; 27 | }) 28 | ); 29 | } 30 | ]); 31 | -------------------------------------------------------------------------------- /test/stream_tests.js: -------------------------------------------------------------------------------- 1 | import { LivedataTest } from '../common/namespace.js'; 2 | 3 | Tinytest.add('stream - status', function(test) { 4 | // Very basic test. Just see that it runs and returns something. Not a 5 | // lot of coverage, but enough that it would have caught a recent bug. 6 | var status = Meteor.status(); 7 | test.equal(typeof status, 'object'); 8 | test.isTrue(status.status); 9 | // Make sure backward-compatiblity names are defined. 10 | test.equal(status.retryCount, status.retryCount); 11 | test.equal(status.retryTime, status.retryTime); 12 | }); 13 | 14 | testAsyncMulti('stream - reconnect', [ 15 | function(test, expect) { 16 | var callback = _.once( 17 | expect(function() { 18 | var status; 19 | status = Meteor.status(); 20 | test.equal(status.status, 'connected'); 21 | 22 | Meteor.reconnect(); 23 | status = Meteor.status(); 24 | test.equal(status.status, 'connected'); 25 | 26 | Meteor.reconnect({ _force: true }); 27 | status = Meteor.status(); 28 | test.equal(status.status, 'waiting'); 29 | }) 30 | ); 31 | 32 | if (Meteor.status().status !== 'connected') 33 | Meteor.connection._stream.on('reset', callback); 34 | else callback(); 35 | } 36 | ]); 37 | 38 | // Disconnecting and reconnecting transitions through the correct statuses. 39 | testAsyncMulti('stream - basic disconnect', [ 40 | function(test, expect) { 41 | var history = []; 42 | var stream = new LivedataTest.ClientStream('/'); 43 | var onTestComplete = expect(function(unexpectedHistory) { 44 | stream.disconnect(); 45 | if (unexpectedHistory) { 46 | test.fail( 47 | 'Unexpected status history: ' + JSON.stringify(unexpectedHistory) 48 | ); 49 | } 50 | }); 51 | 52 | Tracker.autorun(function() { 53 | var status = stream.status(); 54 | 55 | if (_.last(history) !== status.status) { 56 | history.push(status.status); 57 | 58 | if (_.isEqual(history, ['connecting'])) { 59 | // do nothing; wait for the next state 60 | } else if (_.isEqual(history, ['connecting', 'connected'])) { 61 | stream.disconnect(); 62 | } else if (_.isEqual(history, ['connecting', 'connected', 'offline'])) { 63 | stream.reconnect(); 64 | } else if ( 65 | _.isEqual(history, [ 66 | 'connecting', 67 | 'connected', 68 | 'offline', 69 | 'connecting' 70 | ]) 71 | ) { 72 | // do nothing; wait for the next state 73 | } else if ( 74 | _.isEqual(history, [ 75 | 'connecting', 76 | 'connected', 77 | 'offline', 78 | 'connecting', 79 | 'connected' 80 | ]) 81 | ) { 82 | onTestComplete(); 83 | } else { 84 | onTestComplete(history); 85 | } 86 | } 87 | }); 88 | } 89 | ]); 90 | 91 | // Remain offline if the online event is received while offline. 92 | testAsyncMulti('stream - disconnect remains offline', [ 93 | function(test, expect) { 94 | var history = []; 95 | var stream = new LivedataTest.ClientStream('/'); 96 | var onTestComplete = expect(function(unexpectedHistory) { 97 | stream.disconnect(); 98 | if (unexpectedHistory) { 99 | test.fail( 100 | 'Unexpected status history: ' + JSON.stringify(unexpectedHistory) 101 | ); 102 | } 103 | }); 104 | 105 | Tracker.autorun(function() { 106 | var status = stream.status(); 107 | 108 | if (_.last(history) !== status.status) { 109 | history.push(status.status); 110 | 111 | if (_.isEqual(history, ['connecting'])) { 112 | // do nothing; wait for the next status 113 | } else if (_.isEqual(history, ['connecting', 'connected'])) { 114 | stream.disconnect(); 115 | } else if (_.isEqual(history, ['connecting', 'connected', 'offline'])) { 116 | stream._online(); 117 | test.isTrue(status.status === 'offline'); 118 | onTestComplete(); 119 | } else { 120 | onTestComplete(history); 121 | } 122 | } 123 | }); 124 | } 125 | ]); 126 | 127 | Tinytest.add('stream - sockjs urls are computed correctly', function(test) { 128 | var testHasSockjsUrl = function(raw, expectedSockjsUrl) { 129 | var actual = LivedataTest.toSockjsUrl(raw); 130 | if (expectedSockjsUrl instanceof RegExp) 131 | test.isTrue(actual.match(expectedSockjsUrl), actual); 132 | else test.equal(actual, expectedSockjsUrl); 133 | }; 134 | 135 | testHasSockjsUrl( 136 | 'http://subdomain.meteor.com/', 137 | 'http://subdomain.meteor.com/sockjs' 138 | ); 139 | testHasSockjsUrl( 140 | 'http://subdomain.meteor.com', 141 | 'http://subdomain.meteor.com/sockjs' 142 | ); 143 | testHasSockjsUrl( 144 | 'subdomain.meteor.com/', 145 | 'http://subdomain.meteor.com/sockjs' 146 | ); 147 | testHasSockjsUrl( 148 | 'subdomain.meteor.com', 149 | 'http://subdomain.meteor.com/sockjs' 150 | ); 151 | testHasSockjsUrl('/', Meteor._relativeToSiteRootUrl('/sockjs')); 152 | 153 | testHasSockjsUrl('http://localhost:3000/', 'http://localhost:3000/sockjs'); 154 | testHasSockjsUrl('http://localhost:3000', 'http://localhost:3000/sockjs'); 155 | testHasSockjsUrl('localhost:3000', 'http://localhost:3000/sockjs'); 156 | 157 | testHasSockjsUrl( 158 | 'https://subdomain.meteor.com/', 159 | 'https://subdomain.meteor.com/sockjs' 160 | ); 161 | testHasSockjsUrl( 162 | 'https://subdomain.meteor.com', 163 | 'https://subdomain.meteor.com/sockjs' 164 | ); 165 | 166 | testHasSockjsUrl( 167 | 'ddp+sockjs://ddp--****-foo.meteor.com/sockjs', 168 | /^https:\/\/ddp--\d\d\d\d-foo\.meteor\.com\/sockjs$/ 169 | ); 170 | testHasSockjsUrl( 171 | 'ddpi+sockjs://ddp--****-foo.meteor.com/sockjs', 172 | /^http:\/\/ddp--\d\d\d\d-foo\.meteor\.com\/sockjs$/ 173 | ); 174 | }); 175 | 176 | testAsyncMulti('stream - /websocket is a websocket endpoint', [ 177 | function(test, expect) { 178 | // 179 | // Verify that /websocket and /websocket/ don't return the main page 180 | // 181 | _.each(['/websocket', '/websocket/'], function(path) { 182 | HTTP.get( 183 | Meteor._relativeToSiteRootUrl(path), 184 | expect(function(error, result) { 185 | test.isNotNull(error); 186 | test.equal('Not a valid websocket request', result.content); 187 | }) 188 | ); 189 | }); 190 | 191 | // 192 | // For sanity, also verify that /websockets and /websockets/ return 193 | // the main page 194 | // 195 | 196 | // Somewhat contorted but we can't call nested expects (XXX why?) 197 | var pageContent; 198 | var wrappedCallback = expect(function(error, result) { 199 | test.isNull(error); 200 | test.equal(pageContent, result.content); 201 | }); 202 | 203 | HTTP.get( 204 | Meteor._relativeToSiteRootUrl('/'), 205 | expect(function(error, result) { 206 | test.isNull(error); 207 | pageContent = result.content; 208 | 209 | _.each(['/websockets', '/websockets/'], function(path) { 210 | HTTP.get(Meteor._relativeToSiteRootUrl(path), wrappedCallback); 211 | }); 212 | }) 213 | ); 214 | } 215 | ]); 216 | -------------------------------------------------------------------------------- /test/stub_stream.js: -------------------------------------------------------------------------------- 1 | StubStream = function() { 2 | var self = this; 3 | 4 | self.sent = []; 5 | self.callbacks = {}; 6 | }; 7 | 8 | _.extend(StubStream.prototype, { 9 | // Methods from Stream 10 | on: function(name, callback) { 11 | var self = this; 12 | 13 | if (!self.callbacks[name]) self.callbacks[name] = [callback]; 14 | else self.callbacks[name].push(callback); 15 | }, 16 | 17 | send: function(data) { 18 | var self = this; 19 | self.sent.push(data); 20 | }, 21 | 22 | status: function() { 23 | return { status: 'connected', fake: true }; 24 | }, 25 | 26 | reconnect: function() { 27 | // no-op 28 | }, 29 | 30 | _lostConnection: function() { 31 | // no-op 32 | }, 33 | 34 | // Methods for tests 35 | receive: function(data) { 36 | var self = this; 37 | 38 | if (typeof data === 'object') { 39 | data = EJSON.stringify(data); 40 | } 41 | 42 | _.each(self.callbacks['message'], function(cb) { 43 | cb(data); 44 | }); 45 | }, 46 | 47 | reset: function() { 48 | var self = this; 49 | _.each(self.callbacks['reset'], function(cb) { 50 | cb(); 51 | }); 52 | }, 53 | 54 | // Provide a tag to detect stub streams. 55 | // We don't log heartbeat failures on stub streams, for example. 56 | _isStub: true 57 | }); 58 | --------------------------------------------------------------------------------