├── .gitignore ├── examples ├── example-meteor-server │ ├── .meteor │ │ ├── .gitignore │ │ ├── release │ │ ├── platforms │ │ ├── packages │ │ ├── .finished-upgraders │ │ ├── .id │ │ └── versions │ ├── ddp-test.css │ ├── ddp-test.html │ └── ddp-test.js └── example.js ├── LICENSE ├── package.json ├── CHANGELOG.md ├── README.markdown ├── lib └── ddp-client.js └── test └── ddp-client.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /examples/example-meteor-server/.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /examples/example-meteor-server/.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.0.4.2 2 | -------------------------------------------------------------------------------- /examples/example-meteor-server/.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /examples/example-meteor-server/ddp-test.css: -------------------------------------------------------------------------------- 1 | /* CSS declarations go here */ 2 | -------------------------------------------------------------------------------- /examples/example-meteor-server/.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # 3 | # 'meteor add' and 'meteor remove' will edit this file for you, 4 | # but you can also edit it by hand. 5 | 6 | standard-app-packages 7 | autopublish 8 | insecure 9 | -------------------------------------------------------------------------------- /examples/example-meteor-server/.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | -------------------------------------------------------------------------------- /examples/example-meteor-server/ddp-test.html: -------------------------------------------------------------------------------- 1 | 2 | ddp-test 3 | 4 | 5 | 6 | {{> posts}} 7 | 8 | 9 | 17 | -------------------------------------------------------------------------------- /examples/example-meteor-server/.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | 16h678x1r5dkr01i9xj33 8 | -------------------------------------------------------------------------------- /examples/example-meteor-server/ddp-test.js: -------------------------------------------------------------------------------- 1 | Posts = new Meteor.Collection('posts'); 2 | 3 | if (Meteor.isClient) { 4 | Template.posts.posts = function () { 5 | return Posts.find(); 6 | }; 7 | } 8 | 9 | if (Meteor.isServer) { 10 | Meteor.startup(function () { 11 | var postCount = Posts.find().count(); 12 | if ( postCount < 10) { 13 | for ( ; postCount < 10; postCount++) { 14 | Posts.insert({ 15 | body : Random.secret() 16 | }); 17 | } 18 | } 19 | }); 20 | } 21 | 22 | Meteor.methods({ 23 | deletePosts : function () { 24 | var cursor = Posts.find({}, { limit : 5 }); 25 | cursor.forEach(function (post) { 26 | Posts.remove(post._id); 27 | }); 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /examples/example-meteor-server/.meteor/versions: -------------------------------------------------------------------------------- 1 | autopublish@1.0.3 2 | autoupdate@1.2.0 3 | base64@1.0.3 4 | binary-heap@1.0.3 5 | blaze@2.1.0 6 | blaze-tools@1.0.3 7 | boilerplate-generator@1.0.3 8 | callback-hook@1.0.3 9 | check@1.0.5 10 | ddp@1.1.0 11 | deps@1.0.7 12 | ejson@1.0.6 13 | fastclick@1.0.3 14 | geojson-utils@1.0.3 15 | html-tools@1.0.4 16 | htmljs@1.0.4 17 | http@1.1.0 18 | id-map@1.0.3 19 | insecure@1.0.3 20 | jquery@1.11.3_2 21 | json@1.0.3 22 | launch-screen@1.0.2 23 | livedata@1.0.13 24 | logging@1.0.7 25 | meteor@1.1.5 26 | meteor-platform@1.2.2 27 | minifiers@1.1.4 28 | minimongo@1.0.7 29 | mobile-status-bar@1.0.3 30 | mongo@1.1.0 31 | observe-sequence@1.0.5 32 | ordered-dict@1.0.3 33 | random@1.0.3 34 | reactive-dict@1.1.0 35 | reactive-var@1.0.5 36 | reload@1.1.3 37 | retry@1.0.3 38 | routepolicy@1.0.5 39 | session@1.1.0 40 | spacebars@1.0.6 41 | spacebars-compiler@1.0.5 42 | standard-app-packages@1.0.5 43 | templating@1.1.0 44 | tracker@1.0.6 45 | ui@1.0.6 46 | underscore@1.0.3 47 | url@1.0.4 48 | webapp@1.2.0 49 | webapp-hashing@1.0.3 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2013-2016 by Tom Coleman 2 | 3 | node-ddp-client is free software released under the MIT/X11 license: 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ddp", 3 | "version": "0.12.1", 4 | "description": "Node.js module to connect to servers using DDP protocol.", 5 | "author": "Tom Coleman (http://tom.thesnail.org)", 6 | "contributors": [ 7 | "Thomas Sarlandie (http://www.sarfata.org)", 8 | "Mason Gravitt ", 9 | "Mike Bannister (http://po.ssibiliti.es)", 10 | "Chris Mather (http://eventedmind.com)", 11 | "Tarang Patel", 12 | "Vaughn Iverson ", 13 | "Rony Kubat " 14 | ], 15 | "license": "MIT", 16 | "main": "lib/ddp-client", 17 | "keywords": [ 18 | "ddp", 19 | "meteor", 20 | "protocol" 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/oortcloud/node-ddp-client.git" 25 | }, 26 | "dependencies": { 27 | "ddp-underscore-patched": "0.8.1-2", 28 | "ddp-ejson": "0.8.1-3", 29 | "faye-websocket": "0.11.0", 30 | "request": "2.74.x" 31 | }, 32 | "devDependencies": { 33 | "mocha": "~3.0.2", 34 | "sinon": "~1.17.5", 35 | "rewire": "~2.5.2" 36 | }, 37 | "scripts": { 38 | "test": "./node_modules/mocha/bin/mocha test" 39 | }, 40 | "engines": { 41 | "node": "*" 42 | }, 43 | "bugs": "https://github.com/oortcloud/node-ddp-client/issues" 44 | } 45 | -------------------------------------------------------------------------------- /examples/example.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var DDPClient = require("../lib/ddp-client"); 4 | 5 | var ddpclient = new DDPClient({ 6 | // All properties optional, defaults shown 7 | host : "localhost", 8 | port : 3000, 9 | ssl : false, 10 | autoReconnect : true, 11 | autoReconnectTimer : 500, 12 | maintainCollections : true, 13 | ddpVersion : "1", // ["1", "pre2", "pre1"] available, 14 | // uses the sockJs protocol to create the connection 15 | // this still uses websockets, but allows to get the benefits 16 | // from projects like meteorhacks:cluster 17 | // (load balancing and service discovery) 18 | // do not use `path` option when you are using useSockJs 19 | useSockJs: true, 20 | // Use a full url instead of a set of `host`, `port` and `ssl` 21 | // do not set `useSockJs` option if `url` is used 22 | url: 'wss://example.com/websocket' 23 | }); 24 | 25 | /* 26 | * Connect to the Meteor Server 27 | */ 28 | ddpclient.connect(function(error, wasReconnect) { 29 | // If autoReconnect is true, this callback will be invoked each time 30 | // a server connection is re-established 31 | if (error) { 32 | console.log("DDP connection error!"); 33 | return; 34 | } 35 | 36 | if (wasReconnect) { 37 | console.log("Reestablishment of a connection."); 38 | } 39 | 40 | console.log("connected!"); 41 | 42 | setTimeout(function () { 43 | /* 44 | * Call a Meteor Method 45 | */ 46 | ddpclient.call( 47 | "deletePosts", // name of Meteor Method being called 48 | ["foo", "bar"], // parameters to send to Meteor Method 49 | function (err, result) { // callback which returns the method call results 50 | console.log("called function, result: " + result); 51 | }, 52 | function () { // callback which fires when server has finished 53 | console.log("updated"); // sending any updated documents as a result of 54 | console.log(ddpclient.collections.posts); // calling this method 55 | } 56 | ); 57 | }, 3000); 58 | 59 | /* 60 | * Call a Meteor Method while passing in a random seed. 61 | * Added in DDP pre2, the random seed will be used on the server to generate 62 | * repeatable IDs. This allows the same id to be generated on the client and server 63 | */ 64 | var Random = require("ddp-random"), 65 | random = Random.createWithSeeds("randomSeed"); // seed an id generator 66 | 67 | ddpclient.callWithRandomSeed( 68 | "createPost", // name of Meteor Method being called 69 | [{ _id : random.id(), // generate the id on the client 70 | body : "asdf" }], 71 | "randomSeed", // pass the same seed to the server 72 | function (err, result) { // callback which returns the method call results 73 | console.log("called function, result: " + result); 74 | }, 75 | function () { // callback which fires when server has finished 76 | console.log("updated"); // sending any updated documents as a result of 77 | console.log(ddpclient.collections.posts); // calling this method 78 | } 79 | ); 80 | 81 | /* 82 | * Subscribe to a Meteor Collection 83 | */ 84 | ddpclient.subscribe( 85 | "posts", // name of Meteor Publish function to subscribe to 86 | [], // any parameters used by the Publish function 87 | function () { // callback when the subscription is complete 88 | console.log("posts complete:"); 89 | console.log(ddpclient.collections.posts); 90 | } 91 | ); 92 | 93 | /* 94 | * Observe a collection. 95 | */ 96 | var observer = ddpclient.observe("posts"); 97 | observer.added = function(id) { 98 | console.log("[ADDED] to " + observer.name + ": " + id); 99 | }; 100 | observer.changed = function(id, oldFields, clearedFields) { 101 | console.log("[CHANGED] in " + observer.name + ": " + id); 102 | console.log("[CHANGED] old field values: ", oldFields); 103 | console.log("[CHANGED] cleared fields: ", clearedFields); 104 | }; 105 | observer.removed = function(id, oldValue) { 106 | console.log("[REMOVED] in " + observer.name + ": " + id); 107 | console.log("[REMOVED] previous value: ", oldValue); 108 | }; 109 | 110 | setTimeout(function() { observer.stop(); }, 6000); 111 | }); 112 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 0.12.1 — 2016-09-15 2 | 3 | - Update npm dependencies, including request, to fix vulnerability (#89) 4 | 5 | 0.12.0 — 2016-03-31 6 | 7 | - Update npm dependencies, including faye-websocket (#78) 8 | - Catch JSON parsing exceptions (#77) 9 | - Fix adding changed handler on observer creator (#72) 10 | 11 | 0.11.0 — 2015-03-23 12 | 13 | - Allow passing url to websocket connection string (#52) 14 | - Allow passing TLS options to websocket connection (#53) 15 | - Track method calls so it incomplete calls can be handled (#54) 16 | 17 | 0.10.0 - 2015-02-03 18 | 19 | - Add optional SockJS support 20 | 21 | 0.9.4 - 2014-12-22 22 | 23 | - Handle socket errors occurring before connection is established 24 | 25 | 0.9.3 - 2014-12-20 26 | 27 | - Remove event listeners on close 28 | 29 | 0.9.2 - 2014-11-17 30 | 31 | - Observer callback emits changed fields. 32 | 33 | 0.9.1 - 2014-11-11 34 | 35 | - Collection elements now have _id field. 36 | 37 | 0.9.0 - 2014-11-05 38 | 39 | - Added collection observation 40 | 41 | 0.8.2 - 2014-10-11 42 | 43 | - Fixed variable name typo 44 | 45 | 0.8.1 - 2014-09-16 46 | 47 | - Remove debug console.logs 48 | 49 | 0.8.0 - 2014-09-16 50 | 51 | - Bump DDP version to 1 52 | - Fix connect callback handler (#41) 53 | - Change underscored_variable_names to camelCase 54 | 55 | 0.7.0 - 2014-06-23 56 | 57 | - Built-in support for authenticating to Meteor's Accounts system in has 58 | been removed, due to changes in Meteor's Accounts system in 0.8.2 59 | (https://github.com/meteor/meteor/blob/devel/History.md#meteor-accounts). 60 | If you need login support, try https://github.com/vsivsi/ddp-login 61 | - EJSON support now mandatory 62 | 63 | 0.6.0 - 2014-06-08 64 | 65 | - Update collection before emitting `message`. 66 | 67 | 0.5.2 - 2014-06-01 68 | 69 | - Added MIT License 70 | 71 | 0.5.1 - 2014-05-23 72 | 73 | - Switch to MDG-patched version of underscore. 74 | 75 | 0.5.0 - 2014-05-14 76 | 77 | - Use ddp-ejson instead of meteor-ejson. ddp-ejson is a repackage of 78 | Meteor's latest EJSON package 79 | - Use ddp-srp insead of node-srp. ddp-srp is a repackage of Meteor's 80 | latest SRP package 81 | - Added second callback to ddpclient.call, executed when the DDP 82 | `updated` message is received 83 | - Allow automatic EJSON serialization/deserialization of ObjectIDs 84 | - Expose EJSON package to allow for addition of custom EJSON types 85 | - added DDP pre2 support 86 | - DDP version negotiation 87 | - DDP heartbeat support (reply only) 88 | - `ddpclient.callWithRandomSeed` supports client-generated `_id`s 89 | 90 | 0.4.6 - 2014-04-28 91 | 92 | - Return id used when calling subscribe method 93 | 94 | 0.4.5 - 2014-04-24 95 | 96 | - Fix login with password method to return login token 97 | 98 | 0.4.4 - 2014-02-09 99 | 100 | - Fix a bug where if the server responded to an error on the first 101 | step of SRP authentication it was not handled correctly (i.e when 102 | the user is not found) 103 | 104 | 0.4.3 - 2013-12-19 105 | 106 | - Fix bug with socket reconnects tailspinning into an infinite loop 107 | (#30 by @jagill) 108 | - Fix bug when use_ejson was not always set properly by default. 109 | (#29 by @jagill) 110 | 111 | 0.4.2 - 2013-12-14 112 | 113 | - Use EJSON by default (#28) 114 | 115 | 0.4.1 - 2013-12-07 116 | 117 | - Ability to switch off collections monitoring 118 | 119 | 0.4.0 - 2013-12-07 120 | 121 | - Switched to faye-websockets (#26 by @jagill) 122 | 123 | 0.3.6 - 2013-11-07 124 | 125 | - fixed bug with default params when ignoring root certs (in case the 126 | machine doesn't have the cert) 127 | - Added DDP login with SRP authentication 128 | 129 | 0.3.5 - 2013-11-05 130 | 131 | - Added non strict SSL option in case of missing root certificates 132 | 133 | 0.3.4 - 2013-08-28 134 | 135 | - added EJSON support (default is off) with a couple tests 136 | 137 | 0.3.3 - 2013-05-29 138 | 139 | - fixed bug where an exception could be thrown when sending a message on 140 | a socket that is not opened anymore (issue #18) 141 | - added some tests (work in progress) 142 | 143 | 0.3.2 - 2013-04-08 144 | 145 | - fixed bug where client would reconnect when closing (@tarangp) 146 | 147 | 0.3.1 - 2013-04-06 148 | 149 | - added a failed message to the connect callback if version negotiation 150 | fails. 151 | 152 | 0.3.0 - 2013-03-18 153 | 154 | - moved over to DDP-pre1 155 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | Node DDP Client 2 | =============== 3 | 4 | A callback style [DDP](https://github.com/meteor/meteor/blob/devel/packages/livedata/DDP.md) ([Meteor](http://meteor.com/)'s Distributed Data Protocol) node client, originally based alansikora's [node-js_ddp-client](https://github.com/alansikora/node-js_ddp-client) and Meteor's python client. Uses a more callback style approach. 5 | 6 | The client implements version 1 of DDP, as well as fallbacks to pre1 and pre2. 7 | 8 | Installation 9 | ============ 10 | 11 | ``` 12 | npm install ddp 13 | ``` 14 | 15 | Authentication 16 | ============== 17 | Built-in authentication support was removed in ddp 0.7.0 due to changes in Meteor version 0.8.2. 18 | 19 | DDP Authentication is now implemented by [vsivsi/ddp-login](https://github.com/vsivsi/ddp-login). 20 | 21 | A quick and dirty (but insecure) alternative is to use plain-text logins via a method call: 22 | 23 | ```js 24 | // logging in with e-mail 25 | ddpclient.call("login", [ 26 | { user : { email : "user@domain.com" }, password : "password" } 27 | ], function (err, result) { ... }); 28 | 29 | // logging in with username 30 | ddpclient.call("login", [ 31 | { user : { username : "username" }, password : "password" } 32 | ], function (err, result) { ... }); 33 | ``` 34 | 35 | 36 | Example 37 | ======= 38 | 39 | Please see the example in `examples/example.js`. Or here for reference: 40 | 41 | ```js 42 | var DDPClient = require("ddp"); 43 | 44 | var ddpclient = new DDPClient({ 45 | // All properties optional, defaults shown 46 | host : "localhost", 47 | port : 3000, 48 | ssl : false, 49 | autoReconnect : true, 50 | autoReconnectTimer : 500, 51 | maintainCollections : true, 52 | ddpVersion : '1', // ['1', 'pre2', 'pre1'] available 53 | // uses the SockJs protocol to create the connection 54 | // this still uses websockets, but allows to get the benefits 55 | // from projects like meteorhacks:cluster 56 | // (for load balancing and service discovery) 57 | // do not use `path` option when you are using useSockJs 58 | useSockJs: true, 59 | // Use a full url instead of a set of `host`, `port` and `ssl` 60 | // do not set `useSockJs` option if `url` is used 61 | url: 'wss://example.com/websocket' 62 | }); 63 | 64 | /* 65 | * Connect to the Meteor Server 66 | */ 67 | ddpclient.connect(function(error, wasReconnect) { 68 | // If autoReconnect is true, this callback will be invoked each time 69 | // a server connection is re-established 70 | if (error) { 71 | console.log('DDP connection error!'); 72 | return; 73 | } 74 | 75 | if (wasReconnect) { 76 | console.log('Reestablishment of a connection.'); 77 | } 78 | 79 | console.log('connected!'); 80 | 81 | setTimeout(function () { 82 | /* 83 | * Call a Meteor Method 84 | */ 85 | ddpclient.call( 86 | 'deletePosts', // name of Meteor Method being called 87 | ['foo', 'bar'], // parameters to send to Meteor Method 88 | function (err, result) { // callback which returns the method call results 89 | console.log('called function, result: ' + result); 90 | }, 91 | function () { // callback which fires when server has finished 92 | console.log('updated'); // sending any updated documents as a result of 93 | console.log(ddpclient.collections.posts); // calling this method 94 | } 95 | ); 96 | }, 3000); 97 | 98 | /* 99 | * Call a Meteor Method while passing in a random seed. 100 | * Added in DDP pre2, the random seed will be used on the server to generate 101 | * repeatable IDs. This allows the same id to be generated on the client and server 102 | */ 103 | var Random = require("ddp-random"), 104 | random = Random.createWithSeeds("randomSeed"); // seed an id generator 105 | 106 | ddpclient.callWithRandomSeed( 107 | 'createPost', // name of Meteor Method being called 108 | [{ _id : random.id(), // generate the id on the client 109 | body : "asdf" }], 110 | "randomSeed", // pass the same seed to the server 111 | function (err, result) { // callback which returns the method call results 112 | console.log('called function, result: ' + result); 113 | }, 114 | function () { // callback which fires when server has finished 115 | console.log('updated'); // sending any updated documents as a result of 116 | console.log(ddpclient.collections.posts); // calling this method 117 | } 118 | ); 119 | 120 | /* 121 | * Subscribe to a Meteor Collection 122 | */ 123 | ddpclient.subscribe( 124 | 'posts', // name of Meteor Publish function to subscribe to 125 | [], // any parameters used by the Publish function 126 | function () { // callback when the subscription is complete 127 | console.log('posts complete:'); 128 | console.log(ddpclient.collections.posts); 129 | } 130 | ); 131 | 132 | /* 133 | * Observe a collection. 134 | */ 135 | var observer = ddpclient.observe("posts"); 136 | observer.added = function(id) { 137 | console.log("[ADDED] to " + observer.name + ": " + id); 138 | }; 139 | observer.changed = function(id, oldFields, clearedFields, newFields) { 140 | console.log("[CHANGED] in " + observer.name + ": " + id); 141 | console.log("[CHANGED] old field values: ", oldFields); 142 | console.log("[CHANGED] cleared fields: ", clearedFields); 143 | console.log("[CHANGED] new fields: ", newFields); 144 | }; 145 | observer.removed = function(id, oldValue) { 146 | console.log("[REMOVED] in " + observer.name + ": " + id); 147 | console.log("[REMOVED] previous value: ", oldValue); 148 | }; 149 | setTimeout(function() { observer.stop() }, 6000); 150 | }); 151 | 152 | /* 153 | * Useful for debugging and learning the ddp protocol 154 | */ 155 | ddpclient.on('message', function (msg) { 156 | console.log("ddp message: " + msg); 157 | }); 158 | 159 | /* 160 | * Close the ddp connection. This will close the socket, removing it 161 | * from the event-loop, allowing your application to terminate gracefully 162 | */ 163 | ddpclient.close(); 164 | 165 | /* 166 | * If you need to do something specific on close or errors. 167 | * You can also disable autoReconnect and 168 | * call ddpclient.connect() when you are ready to re-connect. 169 | */ 170 | ddpclient.on('socket-close', function(code, message) { 171 | console.log("Close: %s %s", code, message); 172 | }); 173 | 174 | ddpclient.on('socket-error', function(error) { 175 | console.log("Error: %j", error); 176 | }); 177 | 178 | /* 179 | * You can access the EJSON object used by ddp. 180 | */ 181 | var oid = new ddpclient.EJSON.ObjectID(); 182 | ``` 183 | 184 | SockJS Mode 185 | =============== 186 | 187 | By using the `useSockJs` option like below, DDP connection will use [SockJs](https://github.com/sockjs) protocol to establish the WebSocket connection. 188 | 189 | ```js 190 | var ddpClient = new DDPClient({ useSockJs: true }); 191 | ``` 192 | 193 | Meteor server uses SockJs to implement it's DDP server. With this mode, we can get the benefits provided by [meteorhacks:cluster](https://github.com/meteorhacks/cluster). Some of those are load balancing and service discovery. 194 | 195 | * For load balancing you don't need to anything. 196 | * For service discovery, just use the `path` option to identify the service you are referring to. 197 | 198 | > With this mode, `path` option has a special meaning. So, thing twice before using `path` option when you are using this option. 199 | 200 | Unimplemented Features 201 | ==== 202 | The node DDP client does not implement ordered collections, something that while in the DDP spec has not been implemented in Meteor yet. 203 | 204 | Thanks 205 | ====== 206 | 207 | Many thanks to Alan Sikora for the ddp-client which formed the inspiration for this code. 208 | 209 | Contributions: 210 | * Tom Coleman (@tmeasday) 211 | * Thomas Sarlandie (@sarfata) 212 | * Mason Gravitt (@emgee3) 213 | * Mike Bannister (@possiblities) 214 | * Chris Mather (@eventedmind) 215 | * James Gill (@jagill) 216 | * Vaughn Iverson (@vsivsi) 217 | -------------------------------------------------------------------------------- /lib/ddp-client.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var util = require("util"); 4 | var events = require("events"); 5 | var WebSocket = require("faye-websocket"); 6 | var EJSON = require("ddp-ejson"); 7 | var request = require('request'); 8 | var pathJoin = require('path').join; 9 | var _ = require("ddp-underscore-patched"); 10 | 11 | var DDPClient = function(opts) { 12 | var self = this; 13 | 14 | opts = opts || {}; 15 | 16 | // backwards compatibility 17 | if ("use_ssl" in opts) { opts.ssl = opts.use_ssl; } 18 | if ("auto_reconnect" in opts) { opts.autoReconnect = opts.auto_reconnect; } 19 | if ("auto_reconnect_timer" in opts) { opts.autoReconnectTimer = opts.auto_reconnect_timer; } 20 | if ("maintain_collections" in opts) { opts.maintainCollections = opts.maintain_collections; } 21 | if ("ddp_version" in opts) { opts.ddpVersion = opts.ddp_version; } 22 | 23 | // default arguments 24 | self.host = opts.host || "localhost"; 25 | self.port = opts.port || 3000; 26 | self.path = opts.path; 27 | self.ssl = opts.ssl || self.port === 443; 28 | self.tlsOpts = opts.tlsOpts || {}; 29 | self.useSockJs = opts.useSockJs || false; 30 | self.autoReconnect = ("autoReconnect" in opts) ? opts.autoReconnect : true; 31 | self.autoReconnectTimer = ("autoReconnectTimer" in opts) ? opts.autoReconnectTimer : 500; 32 | self.maintainCollections = ("maintainCollections" in opts) ? opts.maintainCollections : true; 33 | self.url = opts.url; 34 | // support multiple ddp versions 35 | self.ddpVersion = ("ddpVersion" in opts) ? opts.ddpVersion : "1"; 36 | self.supportedDdpVersions = ["1", "pre2", "pre1"]; 37 | 38 | // Expose EJSON object, so client can use EJSON.addType(...) 39 | self.EJSON = EJSON; 40 | 41 | // very very simple collections (name -> [{id -> document}]) 42 | if (self.maintainCollections) { 43 | self.collections = {}; 44 | } 45 | 46 | // internal stuff to track callbacks 47 | self._isConnecting = false; 48 | self._isReconnecting = false; 49 | self._nextId = 0; 50 | self._callbacks = {}; 51 | self._updatedCallbacks = {}; 52 | self._pendingMethods = {}; 53 | self._observers = {}; 54 | }; 55 | 56 | 57 | DDPClient.ERRORS = { 58 | DISCONNECTED: new Error("DDPClient: Disconnected from DDP server") 59 | }; 60 | 61 | 62 | /** 63 | * Inherits from EventEmitter 64 | */ 65 | util.inherits(DDPClient, events.EventEmitter); 66 | 67 | 68 | DDPClient.prototype._prepareHandlers = function() { 69 | var self = this; 70 | 71 | self.socket.on("open", function() { 72 | // just go ahead and open the connection on connect 73 | self._send({ 74 | msg : "connect", 75 | version : self.ddpVersion, 76 | support : self.supportedDdpVersions 77 | }); 78 | }); 79 | 80 | self.socket.on("error", function(error) { 81 | // error received before connection was established 82 | if (self._isConnecting) { 83 | self.emit("failed", error.message); 84 | } 85 | 86 | self.emit("socket-error", error); 87 | }); 88 | 89 | self.socket.on("close", function(event) { 90 | self.emit("socket-close", event.code, event.reason); 91 | self._endPendingMethodCalls(); 92 | self._recoverNetworkError(); 93 | }); 94 | 95 | self.socket.on("message", function(event) { 96 | self._message(event.data); 97 | self.emit("message", event.data); 98 | }); 99 | }; 100 | 101 | DDPClient.prototype._clearReconnectTimeout = function() { 102 | var self = this; 103 | if (self.reconnectTimeout) { 104 | clearTimeout(self.reconnectTimeout); 105 | self.reconnectTimeout = null; 106 | } 107 | }; 108 | 109 | DDPClient.prototype._recoverNetworkError = function() { 110 | var self = this; 111 | if (self.autoReconnect && ! self._connectionFailed && ! self._isClosing) { 112 | self._clearReconnectTimeout(); 113 | self.reconnectTimeout = setTimeout(function() { self.connect(); }, self.autoReconnectTimer); 114 | self._isReconnecting = true; 115 | } 116 | }; 117 | 118 | /////////////////////////////////////////////////////////////////////////// 119 | // RAW, low level functions 120 | DDPClient.prototype._send = function(data) { 121 | if (data.msg !== 'connect' && this._isConnecting) { 122 | this._endPendingMethodCalls() 123 | } else { 124 | this.socket.send( 125 | EJSON.stringify(data) 126 | ); 127 | } 128 | }; 129 | 130 | // handle a message from the server 131 | DDPClient.prototype._message = function(data) { 132 | var self = this; 133 | 134 | data = EJSON.parse(data); 135 | 136 | // TODO: 'addedBefore' -- not yet implemented in Meteor 137 | // TODO: 'movedBefore' -- not yet implemented in Meteor 138 | 139 | if (!data.msg) { 140 | return; 141 | 142 | } else if (data.msg === "failed") { 143 | if (self.supportedDdpVersions.indexOf(data.version) !== -1) { 144 | this.ddpVersion = data.version; 145 | self.connect(); 146 | } else { 147 | self.autoReconnect = false; 148 | self.emit("failed", "Cannot negotiate DDP version"); 149 | } 150 | 151 | } else if (data.msg === "connected") { 152 | self.session = data.session; 153 | self.emit("connected"); 154 | 155 | // method result 156 | } else if (data.msg === "result") { 157 | var cb = self._callbacks[data.id]; 158 | 159 | if (cb) { 160 | cb(data.error, data.result); 161 | delete self._callbacks[data.id]; 162 | } 163 | 164 | // method updated 165 | } else if (data.msg === "updated") { 166 | 167 | _.each(data.methods, function (method) { 168 | var cb = self._updatedCallbacks[method]; 169 | if (cb) { 170 | cb(); 171 | delete self._updatedCallbacks[method]; 172 | } 173 | }); 174 | 175 | // missing subscription 176 | } else if (data.msg === "nosub") { 177 | var cb = self._callbacks[data.id]; 178 | 179 | if (cb) { 180 | cb(data.error); 181 | delete self._callbacks[data.id]; 182 | } 183 | 184 | // add document to collection 185 | } else if (data.msg === "added") { 186 | if (self.maintainCollections && data.collection) { 187 | var name = data.collection, id = data.id; 188 | 189 | if (! self.collections[name]) { self.collections[name] = {}; } 190 | if (! self.collections[name][id]) { self.collections[name][id] = {}; } 191 | 192 | self.collections[name][id]._id = id; 193 | 194 | if (data.fields) { 195 | _.each(data.fields, function(value, key) { 196 | self.collections[name][id][key] = value; 197 | }); 198 | } 199 | 200 | if (self._observers[name]) { 201 | _.each(self._observers[name], function(observer) { 202 | observer.added(id, data.fields); 203 | }); 204 | } 205 | } 206 | 207 | // remove document from collection 208 | } else if (data.msg === "removed") { 209 | if (self.maintainCollections && data.collection) { 210 | var name = data.collection, id = data.id; 211 | 212 | if (! self.collections[name][id]) { 213 | return; 214 | } 215 | 216 | var oldValue = self.collections[name][id]; 217 | 218 | delete self.collections[name][id]; 219 | 220 | if (self._observers[name]) { 221 | _.each(self._observers[name], function(observer) { 222 | observer.removed(id, oldValue); 223 | }); 224 | } 225 | } 226 | 227 | // change document in collection 228 | } else if (data.msg === "changed") { 229 | if (self.maintainCollections && data.collection) { 230 | var name = data.collection, id = data.id; 231 | 232 | if (! self.collections[name]) { return; } 233 | if (! self.collections[name][id]) { return; } 234 | 235 | var oldFields = {}, 236 | clearedFields = data.cleared || [], 237 | newFields = {}; 238 | 239 | if (data.fields) { 240 | _.each(data.fields, function(value, key) { 241 | oldFields[key] = self.collections[name][id][key]; 242 | newFields[key] = value; 243 | self.collections[name][id][key] = value; 244 | }); 245 | } 246 | 247 | if (data.cleared) { 248 | _.each(data.cleared, function(value) { 249 | delete self.collections[name][id][value]; 250 | }); 251 | } 252 | 253 | if (self._observers[name]) { 254 | _.each(self._observers[name], function(observer) { 255 | observer.changed(id, oldFields, clearedFields, newFields); 256 | }); 257 | } 258 | } 259 | 260 | // subscriptions ready 261 | } else if (data.msg === "ready") { 262 | _.each(data.subs, function(id) { 263 | var cb = self._callbacks[id]; 264 | if (cb) { 265 | cb(); 266 | delete self._callbacks[id]; 267 | } 268 | }); 269 | 270 | // minimal heartbeat response for ddp pre2 271 | } else if (data.msg === "ping") { 272 | self._send( 273 | _.has(data, "id") ? { msg : "pong", id : data.id } : { msg : "pong" } 274 | ); 275 | } 276 | }; 277 | 278 | 279 | DDPClient.prototype._getNextId = function() { 280 | return (this._nextId += 1).toString(); 281 | }; 282 | 283 | 284 | DDPClient.prototype._addObserver = function(observer) { 285 | if (! this._observers[observer.name]) { 286 | this._observers[observer.name] = {}; 287 | } 288 | this._observers[observer.name][observer._id] = observer; 289 | }; 290 | 291 | 292 | DDPClient.prototype._removeObserver = function(observer) { 293 | if (! this._observers[observer.name]) { return; } 294 | 295 | delete this._observers[observer.name][observer._id]; 296 | }; 297 | 298 | ////////////////////////////////////////////////////////////////////////// 299 | // USER functions -- use these to control the client 300 | 301 | /* open the connection to the server 302 | * 303 | * connected(): Called when the 'connected' message is received 304 | * If autoReconnect is true (default), the callback will be 305 | * called each time the connection is opened. 306 | */ 307 | DDPClient.prototype.connect = function(connected) { 308 | var self = this; 309 | self._isConnecting = true; 310 | self._connectionFailed = false; 311 | self._isClosing = false; 312 | 313 | if (connected) { 314 | self.addListener("connected", function() { 315 | self._clearReconnectTimeout(); 316 | 317 | connected(undefined, self._isReconnecting); 318 | self._isConnecting = false; 319 | self._isReconnecting = false; 320 | }); 321 | self.addListener("failed", function(error) { 322 | self._isConnecting = false; 323 | self._connectionFailed = true; 324 | connected(error, self._isReconnecting); 325 | }); 326 | } 327 | 328 | if (self.useSockJs) { 329 | self._makeSockJSConnection(); 330 | } else { 331 | var url = self._buildWsUrl(); 332 | self._makeWebSocketConnection(url); 333 | } 334 | }; 335 | 336 | DDPClient.prototype._endPendingMethodCalls = function() { 337 | var self = this; 338 | var ids = _.keys(self._pendingMethods); 339 | self._pendingMethods = {}; 340 | 341 | ids.forEach(function (id) { 342 | if (self._callbacks[id]) { 343 | self._callbacks[id](DDPClient.ERRORS.DISCONNECTED); 344 | delete self._callbacks[id]; 345 | } 346 | 347 | if (self._updatedCallbacks[id]) { 348 | self._updatedCallbacks[id](); 349 | delete self._updatedCallbacks[id]; 350 | } 351 | }); 352 | }; 353 | 354 | DDPClient.prototype._makeSockJSConnection = function() { 355 | var self = this; 356 | 357 | // do the info hit 358 | var protocol = self.ssl ? "https://" : "http://"; 359 | var randomValue = "" + Math.ceil(Math.random() * 9999999); 360 | var path = pathJoin("/", self.path || "", "sockjs/info"); 361 | var url = protocol + self.host + ":" + self.port + path; 362 | 363 | var requestOpts = { 'url': url, 'agentOptions': self.tlsOpts }; 364 | 365 | request.get(requestOpts, function(err, res, body) { 366 | if (err) { 367 | self._recoverNetworkError(); 368 | } else if (body) { 369 | var info; 370 | try { 371 | info = JSON.parse(body); 372 | } catch (e) { 373 | console.error(e); 374 | } 375 | if (!info || !info.base_url) { 376 | // no base_url, then use pure WS handling 377 | var url = self._buildWsUrl(); 378 | self._makeWebSocketConnection(url); 379 | } else if (info.base_url.indexOf("http") === 0) { 380 | // base url for a different host 381 | var url = info.base_url + "/websocket"; 382 | url = url.replace(/^http/, "ws"); 383 | self._makeWebSocketConnection(url); 384 | } else { 385 | // base url for the same host 386 | var path = info.base_url + "/websocket"; 387 | var url = self._buildWsUrl(path); 388 | self._makeWebSocketConnection(url); 389 | } 390 | } else { 391 | // no body. weird. use pure WS handling 392 | var url = self._buildWsUrl(); 393 | self._makeWebSocketConnection(url); 394 | } 395 | }); 396 | }; 397 | 398 | DDPClient.prototype._buildWsUrl = function(path) { 399 | var self = this; 400 | var url; 401 | path = path || self.path || "websocket"; 402 | var protocol = self.ssl ? "wss://" : "ws://"; 403 | if (self.url && !self.useSockJs) { 404 | url = self.url; 405 | } else { 406 | url = protocol + self.host + ":" + self.port; 407 | url += (path.indexOf("/") === 0)? path : "/" + path; 408 | } 409 | return url; 410 | }; 411 | 412 | DDPClient.prototype._makeWebSocketConnection = function(url) { 413 | var self = this; 414 | self.socket = new WebSocket.Client(url, null, self.tlsOpts); 415 | self._prepareHandlers(); 416 | }; 417 | 418 | DDPClient.prototype.close = function() { 419 | var self = this; 420 | self._isClosing = true; 421 | self.socket.close(); 422 | self.removeAllListeners("connected"); 423 | self.removeAllListeners("failed"); 424 | }; 425 | 426 | 427 | // call a method on the server, 428 | // 429 | // callback = function(err, result) 430 | DDPClient.prototype.call = function(name, params, callback, updatedCallback) { 431 | var self = this; 432 | var id = self._getNextId(); 433 | 434 | self._callbacks[id] = function () { 435 | delete self._pendingMethods[id]; 436 | 437 | if (callback) { 438 | callback.apply(this, arguments); 439 | } 440 | }; 441 | 442 | self._updatedCallbacks[id] = function () { 443 | delete self._pendingMethods[id]; 444 | 445 | if (updatedCallback) { 446 | updatedCallback.apply(this, arguments); 447 | } 448 | }; 449 | 450 | self._pendingMethods[id] = true; 451 | 452 | self._send({ 453 | msg : "method", 454 | id : id, 455 | method : name, 456 | params : params 457 | }); 458 | }; 459 | 460 | 461 | DDPClient.prototype.callWithRandomSeed = function(name, params, randomSeed, callback, updatedCallback) { 462 | var self = this; 463 | var id = self._getNextId(); 464 | 465 | if (callback) { 466 | self._callbacks[id] = callback; 467 | } 468 | 469 | if (updatedCallback) { 470 | self._updatedCallbacks[id] = updatedCallback; 471 | } 472 | 473 | self._send({ 474 | msg : "method", 475 | id : id, 476 | method : name, 477 | randomSeed : randomSeed, 478 | params : params 479 | }); 480 | }; 481 | 482 | // open a subscription on the server, callback should handle on ready and nosub 483 | DDPClient.prototype.subscribe = function(name, params, callback) { 484 | var self = this; 485 | var id = self._getNextId(); 486 | 487 | if (callback) { 488 | self._callbacks[id] = callback; 489 | } 490 | 491 | self._send({ 492 | msg : "sub", 493 | id : id, 494 | name : name, 495 | params : params 496 | }); 497 | 498 | return id; 499 | }; 500 | 501 | DDPClient.prototype.unsubscribe = function(id) { 502 | var self = this; 503 | 504 | self._send({ 505 | msg : "unsub", 506 | id : id 507 | }); 508 | }; 509 | 510 | /** 511 | * Adds an observer to a collection and returns the observer. 512 | * Observation can be stopped by calling the stop() method on the observer. 513 | * Functions for added, changed and removed can be added to the observer 514 | * afterward. 515 | */ 516 | DDPClient.prototype.observe = function(name, added, changed, removed) { 517 | var self = this; 518 | var observer = {}; 519 | var id = self._getNextId(); 520 | 521 | // name, _id are immutable 522 | Object.defineProperty(observer, "name", { 523 | get: function() { return name; }, 524 | enumerable: true 525 | }); 526 | 527 | Object.defineProperty(observer, "_id", { get: function() { return id; }}); 528 | 529 | observer.added = added || function(){}; 530 | observer.changed = changed || function(){}; 531 | observer.removed = removed || function(){}; 532 | 533 | observer.stop = function() { 534 | self._removeObserver(observer); 535 | }; 536 | 537 | self._addObserver(observer); 538 | 539 | return observer; 540 | }; 541 | 542 | module.exports = DDPClient; 543 | -------------------------------------------------------------------------------- /test/ddp-client.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'), 2 | sinon = require('sinon'), 3 | rewire = require('rewire'), 4 | events = require('events'), 5 | EJSON = require('ddp-ejson'); 6 | 7 | var DDPClient = rewire("../lib/ddp-client"); 8 | 9 | var wsConstructor, wsMock; 10 | 11 | 12 | function prepareMocks() { 13 | wsMock = new events.EventEmitter(); 14 | wsMock.close = sinon.stub(); 15 | 16 | wsConstructor = sinon.stub(); 17 | wsConstructor.returns(wsMock); 18 | DDPClient.__set__('WebSocket', { Client: wsConstructor }); 19 | } 20 | 21 | 22 | describe("Connect to remote server", function() { 23 | beforeEach(function() { 24 | prepareMocks(); 25 | }); 26 | 27 | it('should connect to localhost by default', function() { 28 | new DDPClient().connect(); 29 | 30 | assert(wsConstructor.calledOnce); 31 | assert(wsConstructor.calledWithNew()); 32 | assert(wsConstructor.call); 33 | assert.deepEqual(wsConstructor.args, [['ws://localhost:3000/websocket', null, {}]]); 34 | }); 35 | 36 | it('should connect to the provided host', function() { 37 | new DDPClient({'host': 'myserver.com'}).connect(); 38 | assert.deepEqual(wsConstructor.args, [['ws://myserver.com:3000/websocket', null, {}]]); 39 | }); 40 | 41 | it('should connect to the provided host and port', function() { 42 | new DDPClient({'host': 'myserver.com', 'port': 42}).connect(); 43 | assert.deepEqual(wsConstructor.args, [['ws://myserver.com:42/websocket', null, {}]]); 44 | }); 45 | 46 | it('should use ssl if the port is 443', function() { 47 | new DDPClient({'host': 'myserver.com', 'port': 443}).connect(); 48 | assert.deepEqual(wsConstructor.args, [['wss://myserver.com:443/websocket', null, {}]]); 49 | }); 50 | 51 | it('should propagate tls options if specified', function() { 52 | var tlsOpts = { 53 | 'ca': ['fake_pem_content'] 54 | } 55 | new DDPClient({'host': 'myserver.com', 'port': 443, 'tlsOpts': tlsOpts}).connect(); 56 | assert.deepEqual(wsConstructor.args, [['wss://myserver.com:443/websocket', null, tlsOpts]]); 57 | }); 58 | 59 | it('should connect to the provided url', function() { 60 | new DDPClient({'url': 'wss://myserver.com/websocket'}).connect(); 61 | assert.deepEqual(wsConstructor.args, [['wss://myserver.com/websocket', null, {} ]]); 62 | }); 63 | 64 | it('should fallback to sockjs if url and useSockJs:true are provided', function() { 65 | var ddpclient = new DDPClient({'url': 'wss://myserver.com/websocket', 'useSockJs': true}); 66 | ddpclient._makeSockJSConnection = sinon.stub(); 67 | ddpclient.connect(); 68 | assert.ok(ddpclient._makeSockJSConnection.called); 69 | }); 70 | 71 | it('should clear event listeners on close', function(done) { 72 | var ddpclient = new DDPClient(); 73 | var callback = sinon.stub(); 74 | 75 | ddpclient.connect(callback); 76 | ddpclient.close(); 77 | ddpclient.connect(callback); 78 | 79 | setTimeout(function() { 80 | assert.equal(ddpclient.listeners('connected').length, 1); 81 | assert.equal(ddpclient.listeners('failed').length, 1); 82 | done(); 83 | }, 15); 84 | }); 85 | 86 | it('should call the connection callback when connection is established', function(done) { 87 | var ddpclient = new DDPClient(); 88 | var callback = sinon.spy(); 89 | 90 | ddpclient.connect(callback); 91 | wsMock.emit('message', { data: '{ "msg": "connected" }' }); 92 | 93 | setTimeout(function() { 94 | assert(callback.calledWith(undefined, false)); 95 | done(); 96 | }, 15); 97 | }); 98 | 99 | it('should pass socket errors occurring during connection to the connection callback', function(done) { 100 | var ddpclient = new DDPClient(); 101 | var callback = sinon.spy(); 102 | 103 | var socketError = "Network error: ws://localhost:3000/websocket: connect ECONNREFUSED"; 104 | 105 | ddpclient.connect(callback); 106 | wsMock.emit('error', { message: socketError }); 107 | 108 | setTimeout(function() { 109 | assert(callback.calledWith(socketError, false)); 110 | done(); 111 | }, 15); 112 | }); 113 | }); 114 | 115 | 116 | describe('Automatic reconnection', function() { 117 | beforeEach(function() { 118 | prepareMocks(); 119 | }); 120 | 121 | /* We should be able to get this test to work with clock.tick() but for some weird 122 | reasons it does not work. See: https://github.com/cjohansen/Sinon.JS/issues/283 123 | */ 124 | it('should reconnect when the connection fails', function(done) { 125 | var ddpclient = new DDPClient({ autoReconnectTimer: 10 }); 126 | 127 | ddpclient.connect(); 128 | wsMock.emit('close', {}); 129 | 130 | // At this point, the constructor should have been called only once. 131 | assert(wsConstructor.calledOnce); 132 | 133 | setTimeout(function() { 134 | // Now the constructor should have been called twice 135 | assert(wsConstructor.calledTwice); 136 | done(); 137 | }, 15); 138 | }); 139 | 140 | it('should reconnect only once when the connection fails rapidly', function(done) { 141 | var ddpclient = new DDPClient({ autoReconnectTimer: 5 }); 142 | 143 | ddpclient.connect(); 144 | wsMock.emit('close', {}); 145 | wsMock.emit('close', {}); 146 | wsMock.emit('close', {}); 147 | 148 | // At this point, the constructor should have been called only once. 149 | assert(wsConstructor.calledOnce); 150 | 151 | setTimeout(function() { 152 | // Now the constructor should have been called twice 153 | assert(wsConstructor.calledTwice); 154 | done(); 155 | }, 15); 156 | }); 157 | 158 | it('should save currently running method calls', function() { 159 | var ddpclient = new DDPClient(); 160 | ddpclient._getNextId = sinon.stub().returns('_test'); 161 | ddpclient._send = Function.prototype; 162 | 163 | ddpclient.connect(); 164 | ddpclient.call(); 165 | 166 | assert("_test" in ddpclient._pendingMethods) 167 | }); 168 | 169 | it('should remove id when callback is called', function() { 170 | var ddpclient = new DDPClient(); 171 | ddpclient._getNextId = sinon.stub().returns('_test'); 172 | ddpclient._send = Function.prototype; 173 | 174 | ddpclient.connect(); 175 | ddpclient.call(); 176 | 177 | assert("_test" in ddpclient._pendingMethods) 178 | 179 | ddpclient._callbacks._test(); 180 | assert(!("_test" in ddpclient._pendingMethods)) 181 | }); 182 | 183 | it('should remove id when updated-callback is called', function() { 184 | var ddpclient = new DDPClient(); 185 | ddpclient._getNextId = sinon.stub().returns('_test'); 186 | ddpclient._send = Function.prototype; 187 | 188 | ddpclient.connect(); 189 | ddpclient.call(); 190 | 191 | assert("_test" in ddpclient._pendingMethods) 192 | 193 | ddpclient._updatedCallbacks._test(); 194 | assert(!("_test" in ddpclient._pendingMethods)) 195 | }); 196 | 197 | it('should end method calls which could not be completed', function() { 198 | var ddpclient = new DDPClient(); 199 | var callback = sinon.spy(); 200 | var updatedCallback = sinon.spy(); 201 | 202 | ddpclient._pendingMethods = { _test: true }; 203 | ddpclient._callbacks = { _test: callback }; 204 | ddpclient._updatedCallbacks = { _test: updatedCallback }; 205 | 206 | ddpclient.connect(); 207 | ddpclient.socket.emit('close', {}); 208 | 209 | assert(callback.calledOnce); 210 | assert(callback.calledWithExactly(DDPClient.ERRORS.DISCONNECTED)); 211 | 212 | assert(updatedCallback.calledOnce); 213 | 214 | // callbacks should be removed after calling them 215 | assert(!("_test" in ddpclient._callbacks)); 216 | assert(!("_test" in ddpclient._updatedCallbacks)); 217 | assert(!("_test" in ddpclient._pendingMethods)); 218 | }); 219 | }); 220 | 221 | 222 | describe('EJSON', function() { 223 | var DDPMessage = '{"msg":"added","collection":"posts","id":"2trpvcQ4pn32ZYXco","fields":{"date":{"$date":1371591394454},"bindata":{"$binary":"QUJDRA=="}}}'; 224 | var EJSONObject = EJSON.parse(DDPMessage); 225 | 226 | it('should expose the EJSON object', function(done) { 227 | var ddpclient = new DDPClient(); 228 | 229 | assert(ddpclient.EJSON); 230 | assert(ddpclient.EJSON.addType); 231 | 232 | done(); 233 | }); 234 | 235 | it('should decode binary and dates', function(done) { 236 | var ddpclient = new DDPClient({ use_ejson : true }); 237 | 238 | ddpclient._message(DDPMessage); 239 | 240 | assert.deepEqual(ddpclient.collections.posts['2trpvcQ4pn32ZYXco'].date, new Date(1371591394454)); 241 | 242 | assert.deepEqual(ddpclient.collections.posts['2trpvcQ4pn32ZYXco'].bindata, new Uint8Array([65, 66, 67, 68])); 243 | 244 | ddpclient.socket = {}; 245 | ddpclient.socket.send = function (opts) { 246 | assert(opts.indexOf("date") !== -1); 247 | assert(opts.indexOf("$date") !== -1); 248 | assert(opts.indexOf("1371591394454") !== -1); 249 | 250 | assert(opts.indexOf("bindata") !== -1); 251 | assert(opts.indexOf("$binary") !== -1); 252 | assert(opts.indexOf("QUJDRA==") !== -1); 253 | }; 254 | 255 | ddpclient._send(EJSONObject.fields); 256 | 257 | done(); 258 | }); 259 | 260 | }); 261 | 262 | 263 | describe('Collection maintenance and observation', function() { 264 | var addedMessage = '{"msg":"added","collection":"posts","id":"2trpvcQ4pn32ZYXco","fields":{"text":"A cat was here","value":true}}'; 265 | var changedMessage = '{"msg":"changed","collection":"posts","id":"2trpvcQ4pn32ZYXco","fields":{"text":"A dog was here"}}'; 266 | var changedMessage2 = '{"msg":"changed","collection":"posts","id":"2trpvcQ4pn32ZYXco","cleared":["value"]}'; 267 | var removedMessage = '{"msg":"removed","collection":"posts","id":"2trpvcQ4pn32ZYXco"}'; 268 | var observer; 269 | 270 | it('should maintain collections by default', function() { 271 | var ddpclient = new DDPClient(), observed = false; 272 | observer = ddpclient.observe("posts"); 273 | observer.added = function(id) { if (id === '2trpvcQ4pn32ZYXco') observed = true; } 274 | 275 | ddpclient._message(addedMessage); 276 | // ensure collections exist and are populated by add messages 277 | assert.equal(ddpclient.collections.posts['2trpvcQ4pn32ZYXco'].text, "A cat was here"); 278 | assert(observed, "addition observed"); 279 | }); 280 | 281 | it('should maintain collections if maintainCollections is true', function() { 282 | var ddpclient = new DDPClient({ maintainCollections : true }); 283 | ddpclient._message(addedMessage); 284 | // ensure collections exist and are populated by add messages 285 | assert.equal(ddpclient.collections.posts['2trpvcQ4pn32ZYXco'].text, "A cat was here"); 286 | }); 287 | 288 | it('should not maintain collections if maintainCollections is false', function() { 289 | var ddpclient = new DDPClient({ maintainCollections : false }); 290 | ddpclient._message(addedMessage); 291 | // ensure there are no collections 292 | assert(!ddpclient.collections); 293 | }); 294 | 295 | it('should response to "added" messages', function() { 296 | var ddpclient = new DDPClient(); 297 | ddpclient._message(addedMessage); 298 | assert.equal(ddpclient.collections.posts['2trpvcQ4pn32ZYXco']._id, "2trpvcQ4pn32ZYXco"); 299 | assert.equal(ddpclient.collections.posts['2trpvcQ4pn32ZYXco'].text, "A cat was here"); 300 | assert.equal(ddpclient.collections.posts['2trpvcQ4pn32ZYXco'].value, true); 301 | }); 302 | 303 | it('should response to "changed" messages', function() { 304 | var ddpclient = new DDPClient(), observed = false; 305 | observer = ddpclient.observe("posts"); 306 | observer.changed = function(id, oldFields, clearedFields, newFields) { 307 | if (id === "2trpvcQ4pn32ZYXco" 308 | && oldFields.text === "A cat was here" 309 | && newFields.text === "A dog was here") { 310 | observed = true; 311 | } 312 | }; 313 | 314 | ddpclient._message(addedMessage); 315 | ddpclient._message(changedMessage); 316 | assert.equal(ddpclient.collections.posts['2trpvcQ4pn32ZYXco'].text, "A dog was here"); 317 | assert.equal(ddpclient.collections.posts['2trpvcQ4pn32ZYXco'].value, true); 318 | assert(observed, "field change observed"); 319 | }); 320 | 321 | it('should response to "changed" messages with "cleared"', function() { 322 | var ddpclient = new DDPClient(), observed = false; 323 | observer = ddpclient.observe("posts"); 324 | observer.changed = function(id, oldFields, clearedFields) { 325 | if (id === "2trpvcQ4pn32ZYXco" && clearedFields.length === 1 && clearedFields[0] === "value") { 326 | observed = true; 327 | } 328 | }; 329 | 330 | ddpclient._message(addedMessage); 331 | ddpclient._message(changedMessage); 332 | ddpclient._message(changedMessage2); 333 | assert.equal(ddpclient.collections.posts['2trpvcQ4pn32ZYXco'].text, "A dog was here"); 334 | assert(!ddpclient.collections.posts['2trpvcQ4pn32ZYXco'].hasOwnProperty('value')); 335 | assert(observed, "cleared change observed") 336 | }); 337 | 338 | it('should response to "removed" messages', function() { 339 | var ddpclient = new DDPClient(), oldval; 340 | observer = ddpclient.observe("posts"); 341 | observer.removed = function(id, oldValue) { oldval = oldValue; }; 342 | 343 | ddpclient._message(addedMessage); 344 | ddpclient._message(removedMessage); 345 | assert(!ddpclient.collections.posts.hasOwnProperty('2trpvcQ4pn32ZYXco')); 346 | assert(oldval, "Removal observed"); 347 | assert.equal(oldval.text, "A cat was here"); 348 | assert.equal(oldval.value, true); 349 | }); 350 | }); 351 | 352 | 353 | describe("SockJS", function() { 354 | it("should use direct WS connection if there is a path", function() { 355 | var ddpclient = new DDPClient(); 356 | ddpclient._makeWebSocketConnection = sinon.stub(); 357 | ddpclient.connect(); 358 | 359 | assert.ok(ddpclient._makeWebSocketConnection.called); 360 | }); 361 | 362 | it("should fallback to sockjs if there useSockJS option", function() { 363 | var ddpclient = new DDPClient({ useSockJs: true }); 364 | ddpclient._makeSockJSConnection = sinon.stub(); 365 | ddpclient.connect(); 366 | 367 | assert.ok(ddpclient._makeSockJSConnection.called); 368 | }); 369 | 370 | describe("after info hit", function() { 371 | var request = require("request"); 372 | it("should connect to the correct url", function(done) { 373 | var get = function(opts, callback) { 374 | assert.equal(opts.url, "http://the-host:9000/sockjs/info"); 375 | done(); 376 | }; 377 | 378 | WithRequestGet(get, function() { 379 | var opts = { 380 | host: "the-host", 381 | port: 9000 382 | }; 383 | var ddpclient = new DDPClient(opts); 384 | ddpclient._makeSockJSConnection(); 385 | }); 386 | }); 387 | 388 | it("should support custom paths", function(done) { 389 | var get = function(opts, callback) { 390 | assert.equal(opts.url, "http://the-host:9000/search/sockjs/info"); 391 | done(); 392 | }; 393 | 394 | WithRequestGet(get, function() { 395 | var opts = { 396 | host: "the-host", 397 | port: 9000, 398 | path: "search" 399 | }; 400 | var ddpclient = new DDPClient(opts); 401 | ddpclient._makeSockJSConnection(); 402 | }); 403 | }); 404 | 405 | it("should retry if there is an error", function() { 406 | var error = { message: "error" }; 407 | var get = function(opts, callback) { 408 | callback(error); 409 | }; 410 | 411 | WithRequestGet(get, function() { 412 | var ddpclient = new DDPClient(); 413 | ddpclient._recoverNetworkError = sinon.stub(); 414 | ddpclient._makeSockJSConnection(); 415 | assert.ok(ddpclient._recoverNetworkError.called); 416 | }); 417 | }); 418 | 419 | it("should use direct WS if there is no body", function() { 420 | var info = null; 421 | var get = function(opts, callback) { 422 | callback(null, null, info); 423 | }; 424 | 425 | WithRequestGet(get, function() { 426 | var ddpclient = new DDPClient(); 427 | ddpclient._makeWebSocketConnection = sinon.stub(); 428 | ddpclient._makeSockJSConnection(); 429 | 430 | var wsUrl = "ws://localhost:3000/websocket"; 431 | assert.ok(ddpclient._makeWebSocketConnection.calledWith(wsUrl)); 432 | }); 433 | }); 434 | 435 | it("should use direct WS if there is no base_url", function() { 436 | var info = '{}'; 437 | var get = function(opts, callback) { 438 | callback(null, null, info); 439 | }; 440 | 441 | WithRequestGet(get, function() { 442 | var ddpclient = new DDPClient(); 443 | ddpclient._makeWebSocketConnection = sinon.stub(); 444 | ddpclient._makeSockJSConnection(); 445 | 446 | var wsUrl = "ws://localhost:3000/websocket"; 447 | assert.ok(ddpclient._makeWebSocketConnection.calledWith(wsUrl)); 448 | }); 449 | }); 450 | 451 | it("should use full base url if it's starts with http", function() { 452 | var info = '{"base_url": "https://somepath"}'; 453 | var get = function(opts, callback) { 454 | callback(null, null, info); 455 | }; 456 | 457 | WithRequestGet(get, function() { 458 | var ddpclient = new DDPClient(); 459 | ddpclient._makeWebSocketConnection = sinon.stub(); 460 | ddpclient._makeSockJSConnection(); 461 | 462 | var wsUrl = "wss://somepath/websocket"; 463 | assert.ok(ddpclient._makeWebSocketConnection.calledWith(wsUrl)); 464 | }); 465 | }); 466 | 467 | it("should compute url based on the base_url if it's not starts with http", function() { 468 | var info = '{"base_url": "/somepath"}'; 469 | var get = function(opts, callback) { 470 | callback(null, null, info); 471 | }; 472 | 473 | WithRequestGet(get, function() { 474 | var ddpclient = new DDPClient(); 475 | ddpclient._makeWebSocketConnection = sinon.stub(); 476 | ddpclient._makeSockJSConnection(); 477 | 478 | var wsUrl = "ws://localhost:3000/somepath/websocket"; 479 | assert.ok(ddpclient._makeWebSocketConnection.calledWith(wsUrl)); 480 | }); 481 | }); 482 | 483 | it("should propagate tls options", function(done) { 484 | var tlsOpts = {'ca': ['fake_pem_content']}; 485 | var get = function(opts, callback) { 486 | assert.equal(opts.agentOptions, tlsOpts); 487 | done(); 488 | }; 489 | 490 | WithRequestGet(get, function() { 491 | var opts = { 492 | host: "the-host", 493 | port: 9000, 494 | path: "search", 495 | tlsOpts: tlsOpts 496 | }; 497 | var ddpclient = new DDPClient(opts); 498 | ddpclient._makeSockJSConnection(); 499 | }); 500 | }); 501 | }); 502 | }); 503 | 504 | function WithRequestGet(getFn, fn) { 505 | var request = require("request"); 506 | var originalGet = request.get; 507 | request.get = getFn; 508 | 509 | fn(); 510 | 511 | request.get = originalGet; 512 | } 513 | --------------------------------------------------------------------------------