├── LICENSE ├── README.md ├── index.js └── package.json /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Harrison Harnisch 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 all 13 | 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 THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 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 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-client 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 | One can authenticate using plain-text logins as follows: 20 | 21 | ```js 22 | // logging in with e-mail 23 | ddpclient.call("login", [ 24 | { user : { email : "user@domain.com" }, password : "password" } 25 | ], function (err, result) { ... }); 26 | 27 | // logging in with username 28 | ddpclient.call("login", [ 29 | { user : { username : "username" }, password : "password" } 30 | ], function (err, result) { ... }); 31 | ``` 32 | 33 | ```js 34 | var DDPClient = require("ddp-client"); 35 | 36 | var ddpclient = new DDPClient({ 37 | // All properties optional, defaults shown 38 | host : "localhost", 39 | port : 3000, 40 | ssl : false, 41 | autoReconnect : true, 42 | autoReconnectTimer : 500, 43 | maintainCollections : true, 44 | ddpVersion : '1', // ['1', 'pre2', 'pre1'] available 45 | // Use a full url instead of a set of `host`, `port` and `ssl` 46 | url: 'wss://example.com/websocket' 47 | socketConstructor: WebSocket // Another constructor to create new WebSockets 48 | }); 49 | 50 | /* 51 | * Connect to the Meteor Server 52 | */ 53 | ddpclient.connect(function(error, wasReconnect) { 54 | // If autoReconnect is true, this callback will be invoked each time 55 | // a server connection is re-established 56 | if (error) { 57 | console.log('DDP connection error!'); 58 | return; 59 | } 60 | 61 | if (wasReconnect) { 62 | console.log('Reestablishment of a connection.'); 63 | } 64 | 65 | console.log('connected!'); 66 | 67 | setTimeout(function () { 68 | /* 69 | * Call a Meteor Method 70 | */ 71 | ddpclient.call( 72 | 'deletePosts', // name of Meteor Method being called 73 | ['foo', 'bar'], // parameters to send to Meteor Method 74 | function (err, result) { // callback which returns the method call results 75 | console.log('called function, result: ' + result); 76 | }, 77 | function () { // callback which fires when server has finished 78 | console.log('updated'); // sending any updated documents as a result of 79 | console.log(ddpclient.collections.posts); // calling this method 80 | } 81 | ); 82 | }, 3000); 83 | 84 | /* 85 | * Call a Meteor Method while passing in a random seed. 86 | * Added in DDP pre2, the random seed will be used on the server to generate 87 | * repeatable IDs. This allows the same id to be generated on the client and server 88 | */ 89 | var Random = require("ddp-random"), 90 | random = Random.createWithSeeds("randomSeed"); // seed an id generator 91 | 92 | ddpclient.callWithRandomSeed( 93 | 'createPost', // name of Meteor Method being called 94 | [{ _id : random.id(), // generate the id on the client 95 | body : "asdf" }], 96 | "randomSeed", // pass the same seed to the server 97 | function (err, result) { // callback which returns the method call results 98 | console.log('called function, result: ' + result); 99 | }, 100 | function () { // callback which fires when server has finished 101 | console.log('updated'); // sending any updated documents as a result of 102 | console.log(ddpclient.collections.posts); // calling this method 103 | } 104 | ); 105 | 106 | /* 107 | * Subscribe to a Meteor Collection 108 | */ 109 | ddpclient.subscribe( 110 | 'posts', // name of Meteor Publish function to subscribe to 111 | [], // any parameters used by the Publish function 112 | function () { // callback when the subscription is complete 113 | console.log('posts complete:'); 114 | console.log(ddpclient.collections.posts); 115 | } 116 | ); 117 | 118 | /* 119 | * Observe a collection. 120 | */ 121 | var observer = ddpclient.observe("posts"); 122 | observer.added = function(id) { 123 | console.log("[ADDED] to " + observer.name + ": " + id); 124 | }; 125 | observer.changed = function(id, oldFields, clearedFields, newFields) { 126 | console.log("[CHANGED] in " + observer.name + ": " + id); 127 | console.log("[CHANGED] old field values: ", oldFields); 128 | console.log("[CHANGED] cleared fields: ", clearedFields); 129 | console.log("[CHANGED] new fields: ", newFields); 130 | }; 131 | observer.removed = function(id, oldValue) { 132 | console.log("[REMOVED] in " + observer.name + ": " + id); 133 | console.log("[REMOVED] previous value: ", oldValue); 134 | }; 135 | setTimeout(function() { observer.stop() }, 6000); 136 | }); 137 | 138 | /* 139 | * Useful for debugging and learning the ddp protocol 140 | */ 141 | ddpclient.on('message', function (msg) { 142 | console.log("ddp message: " + msg); 143 | }); 144 | 145 | /* 146 | * Close the ddp connection. This will close the socket, removing it 147 | * from the event-loop, allowing your application to terminate gracefully 148 | */ 149 | ddpclient.close(); 150 | 151 | /* 152 | * If you need to do something specific on close or errors. 153 | * You can also disable autoReconnect and 154 | * call ddpclient.connect() when you are ready to re-connect. 155 | */ 156 | ddpclient.on('socket-close', function(code, message) { 157 | console.log("Close: %s %s", code, message); 158 | }); 159 | 160 | ddpclient.on('socket-error', function(error) { 161 | console.log("Error: %j", error); 162 | }); 163 | 164 | /* 165 | * You can access the EJSON object used by ddp. 166 | */ 167 | var oid = new ddpclient.EJSON.ObjectID(); 168 | ``` 169 | 170 | Unimplemented Features 171 | ==== 172 | The node DDP client does not implement ordered collections, something that while in the DDP spec has not been implemented in Meteor yet. 173 | 174 | Thanks 175 | ====== 176 | 177 | Inspired by https://github.com/oortcloud/node-ddp-client 178 | 179 | * Tom Coleman (@tmeasday) 180 | * Thomas Sarlandie (@sarfata) 181 | * Mason Gravitt (@emgee3) 182 | * Mike Bannister (@possiblities) 183 | * Chris Mather (@eventedmind) 184 | * James Gill (@jagill) 185 | * Vaughn Iverson (@vsivsi) 186 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _ = require('underscore'); 4 | var EventEmitter = require('events').EventEmitter; 5 | var EJSON = require("ejson"); 6 | 7 | class DDPClient extends EventEmitter{ 8 | constructor(opts) { 9 | super(); 10 | var self = this; 11 | opts = opts || {}; 12 | // backwards compatibility 13 | if ("use_ssl" in opts) 14 | opts.ssl = opts.use_ssl; 15 | if ("auto_reconnect" in opts) 16 | opts.autoReconnect = opts.auto_reconnect; 17 | if ("auto_reconnect_timer" in opts) 18 | opts.autoReconnectTimer = opts.auto_reconnect_timer; 19 | if ("maintain_collections" in opts) 20 | opts.maintainCollections = opts.maintain_collections; 21 | if ("ddp_version" in opts) 22 | opts.ddpVersion = opts.ddp_version; 23 | 24 | // default arguments 25 | self.host = opts.host || "localhost"; 26 | self.port = opts.port || 3000; 27 | self.path = opts.path; 28 | self.ssl = opts.ssl || self.port === 443; 29 | self.tlsOpts = opts.tlsOpts || {}; 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 | self.socketConstructor = opts.socketContructor || WebSocket; 35 | 36 | // support multiple ddp versions 37 | self.ddpVersion = ("ddpVersion" in opts) ? opts.ddpVersion : "1"; 38 | self.supportedDdpVersions = ["1", "pre2", "pre1"]; 39 | 40 | // Expose EJSON object, so client can use EJSON.addType(...) 41 | self.EJSON = EJSON; 42 | 43 | // very very simple collections (name -> [{id -> document}]) 44 | if (self.maintainCollections) { 45 | self.collections = {}; 46 | } 47 | 48 | // internal stuff to track callbacks 49 | self._isConnecting = false; 50 | self._isReconnecting = false; 51 | self._nextId = 0; 52 | self._callbacks = {}; 53 | self._updatedCallbacks = {}; 54 | self._pendingMethods = {}; 55 | self._observers = {}; 56 | } 57 | 58 | _prepareHandlers() { 59 | var self = this; 60 | self.socket.onopen = function() { 61 | // just go ahead and open the connection on connect 62 | self._send({ 63 | msg : "connect", 64 | version : self.ddpVersion, 65 | support : self.supportedDdpVersions 66 | }); 67 | }; 68 | 69 | self.socket.onerror = function(error) { 70 | // error received before connection was established 71 | if (self._isConnecting) { 72 | self.emit("failed", error.message); 73 | } 74 | 75 | self.emit("socket-error", error); 76 | }; 77 | 78 | self.socket.onclose = function(event) { 79 | self.emit("socket-close", event.code, event.reason); 80 | self._endPendingMethodCalls(); 81 | self._recoverNetworkError(); 82 | }; 83 | 84 | self.socket.onmessage = function(event) { 85 | self._message(event.data); 86 | self.emit("message", event.data); 87 | }; 88 | } 89 | 90 | _clearReconnectTimeout() { 91 | var self = this; 92 | if (self.reconnectTimeout) { 93 | clearTimeout(self.reconnectTimeout); 94 | self.reconnectTimeout = null; 95 | } 96 | } 97 | 98 | _recoverNetworkError() { 99 | var self = this; 100 | if (self.autoReconnect && ! self._connectionFailed && ! self._isClosing) { 101 | self._clearReconnectTimeout(); 102 | self.reconnectTimeout = setTimeout(function() { self.connect(); }, self.autoReconnectTimer); 103 | self._isReconnecting = true; 104 | } 105 | } 106 | 107 | /////////////////////////////////////////////////////////////////////////// 108 | // RAW, low level functions 109 | _send(data) { 110 | var self = this; 111 | self.socket.send( 112 | EJSON.stringify(data) 113 | ); 114 | } 115 | 116 | // handle a message from the server 117 | _message(data) { 118 | var self = this; 119 | data = EJSON.parse(data); 120 | 121 | // TODO: 'addedBefore' -- not yet implemented in Meteor 122 | // TODO: 'movedBefore' -- not yet implemented in Meteor 123 | 124 | if (!data.msg) { 125 | return; 126 | 127 | } else if (data.msg === "failed") { 128 | if (self.supportedDdpVersions.indexOf(data.version) !== -1) { 129 | self.ddpVersion = data.version; 130 | self.connect(); 131 | } else { 132 | self.autoReconnect = false; 133 | self.emit("failed", "Cannot negotiate DDP version"); 134 | } 135 | 136 | } else if (data.msg === "connected") { 137 | self.session = data.session; 138 | self.emit("connected"); 139 | 140 | // method result 141 | } else if (data.msg === "result") { 142 | var cb = self._callbacks[data.id]; 143 | 144 | if (cb) { 145 | cb(data.error, data.result); 146 | delete self._callbacks[data.id]; 147 | } 148 | 149 | // method updated 150 | } else if (data.msg === "updated") { 151 | 152 | _.each(data.methods, function (method) { 153 | var cb = self._updatedCallbacks[method]; 154 | if (cb) { 155 | cb(); 156 | delete self._updatedCallbacks[method]; 157 | } 158 | }); 159 | 160 | // missing subscription 161 | } else if (data.msg === "nosub") { 162 | var cb = self._callbacks[data.id]; 163 | 164 | if (cb) { 165 | cb(data.error); 166 | delete self._callbacks[data.id]; 167 | } 168 | 169 | // add document to collection 170 | } else if (data.msg === "added") { 171 | if (self.maintainCollections && data.collection) { 172 | var name = data.collection, id = data.id; 173 | 174 | if (! self.collections[name]) { self.collections[name] = {}; } 175 | if (! self.collections[name][id]) { self.collections[name][id] = {}; } 176 | 177 | self.collections[name][id]._id = id; 178 | 179 | if (data.fields) { 180 | _.each(data.fields, function(value, key) { 181 | self.collections[name][id][key] = value; 182 | }); 183 | } 184 | 185 | if (self._observers[name]) { 186 | _.each(self._observers[name], function(observer) { 187 | observer.added(id); 188 | }); 189 | } 190 | } 191 | 192 | // remove document from collection 193 | } else if (data.msg === "removed") { 194 | if (self.maintainCollections && data.collection) { 195 | var name = data.collection, id = data.id; 196 | 197 | if (! self.collections[name][id]) { 198 | return; 199 | } 200 | 201 | var oldValue = self.collections[name][id]; 202 | 203 | delete self.collections[name][id]; 204 | 205 | if (self._observers[name]) { 206 | _.each(self._observers[name], function(observer) { 207 | observer.removed(id, oldValue); 208 | }); 209 | } 210 | } 211 | 212 | // change document in collection 213 | } else if (data.msg === "changed") { 214 | if (self.maintainCollections && data.collection) { 215 | var name = data.collection, id = data.id; 216 | 217 | if (! self.collections[name]) { return; } 218 | if (! self.collections[name][id]) { return; } 219 | 220 | var oldFields = {}, 221 | clearedFields = data.cleared || [], 222 | newFields = {}; 223 | 224 | if (data.fields) { 225 | _.each(data.fields, function(value, key) { 226 | oldFields[key] = self.collections[name][id][key]; 227 | newFields[key] = value; 228 | self.collections[name][id][key] = value; 229 | }); 230 | } 231 | 232 | if (data.cleared) { 233 | _.each(data.cleared, function(value) { 234 | delete self.collections[name][id][value]; 235 | }); 236 | } 237 | 238 | if (self._observers[name]) { 239 | _.each(self._observers[name], function(observer) { 240 | observer.changed(id, oldFields, clearedFields, newFields); 241 | }); 242 | } 243 | } 244 | 245 | // subscriptions ready 246 | } else if (data.msg === "ready") { 247 | _.each(data.subs, function(id) { 248 | var cb = self._callbacks[id]; 249 | if (cb) { 250 | cb(); 251 | delete self._callbacks[id]; 252 | } 253 | }); 254 | 255 | // minimal heartbeat response for ddp pre2 256 | } else if (data.msg === "ping") { 257 | self._send( 258 | _.has(data, "id") ? { msg : "pong", id : data.id } : { msg : "pong" } 259 | ); 260 | } 261 | } 262 | 263 | 264 | _getNextId() { 265 | var self = this; 266 | return (self._nextId += 1).toString(); 267 | } 268 | 269 | 270 | _addObserver(observer) { 271 | var self = this; 272 | if (! self._observers[observer.name]) { 273 | self._observers[observer.name] = {}; 274 | } 275 | self._observers[observer.name][observer._id] = observer; 276 | } 277 | 278 | 279 | _removeObserver(observer) { 280 | var self = this; 281 | if (! self._observers[observer.name]) { return; } 282 | 283 | delete self._observers[observer.name][observer._id]; 284 | } 285 | 286 | ////////////////////////////////////////////////////////////////////////// 287 | // USER functions -- use these to control the client 288 | 289 | /* open the connection to the server 290 | * 291 | * connected(): Called when the 'connected' message is received 292 | * If autoReconnect is true (default), the callback will be 293 | * called each time the connection is opened. 294 | */ 295 | connect(connected) { 296 | var self = this; 297 | self._isConnecting = true; 298 | self._connectionFailed = false; 299 | self._isClosing = false; 300 | 301 | if (connected) { 302 | self.addListener("connected", function() { 303 | self._clearReconnectTimeout(); 304 | 305 | connected(undefined, self._isReconnecting); 306 | self._isConnecting = false; 307 | self._isReconnecting = false; 308 | }); 309 | self.addListener("failed", function(error) { 310 | self._isConnecting = false; 311 | self._connectionFailed = true; 312 | connected(error, self._isReconnecting); 313 | }); 314 | } 315 | 316 | var url = self._buildWsUrl(); 317 | self._makeWebSocketConnection(url); 318 | 319 | } 320 | 321 | _endPendingMethodCalls() { 322 | var self = this; 323 | var ids = _.keys(self._pendingMethods); 324 | self._pendingMethods = {}; 325 | 326 | ids.forEach(function (id) { 327 | if (self._callbacks[id]) { 328 | self._callbacks[id](new Error("DDPClient: Disconnected from DDP server")); 329 | delete self._callbacks[id]; 330 | } 331 | 332 | if (self._updatedCallbacks[id]) { 333 | self._updatedCallbacks[id](); 334 | delete self._updatedCallbacks[id]; 335 | } 336 | }); 337 | } 338 | 339 | _buildWsUrl(path) { 340 | var self = this; 341 | var url; 342 | path = path || self.path || "websocket"; 343 | var protocol = self.ssl ? "wss://" : "ws://"; 344 | if (self.url) { 345 | url = self.url; 346 | } else { 347 | url = protocol + self.host + ":" + self.port; 348 | url += (path.indexOf("/") === 0)? path : "/" + path; 349 | } 350 | return url; 351 | } 352 | 353 | _makeWebSocketConnection(url) { 354 | var self = this; 355 | self.socket = new self.socketConstructor(url); 356 | self._prepareHandlers(); 357 | } 358 | 359 | close() { 360 | var self = this; 361 | self._isClosing = true; 362 | self.socket.close(); 363 | self.removeAllListeners("connected"); 364 | self.removeAllListeners("failed"); 365 | } 366 | 367 | 368 | // call a method on the server, 369 | // 370 | // callback = function(err, result) 371 | call(name, params, callback, updatedCallback) { 372 | var self = this; 373 | var id = self._getNextId(); 374 | 375 | self._callbacks[id] = function () { 376 | delete self._pendingMethods[id]; 377 | 378 | if (callback) { 379 | callback.apply(this, arguments); 380 | } 381 | }; 382 | 383 | self._updatedCallbacks[id] = function () { 384 | delete self._pendingMethods[id]; 385 | 386 | if (updatedCallback) { 387 | updatedCallback.apply(this, arguments); 388 | } 389 | }; 390 | 391 | self._pendingMethods[id] = true; 392 | 393 | self._send({ 394 | msg : "method", 395 | id : id, 396 | method : name, 397 | params : params 398 | }); 399 | } 400 | 401 | 402 | callWithRandomSeed(name, params, randomSeed, callback, updatedCallback) { 403 | var self = this; 404 | var id = self._getNextId(); 405 | 406 | if (callback) { 407 | self._callbacks[id] = callback; 408 | } 409 | 410 | if (updatedCallback) { 411 | self._updatedCallbacks[id] = updatedCallback; 412 | } 413 | 414 | self._send({ 415 | msg : "method", 416 | id : id, 417 | method : name, 418 | randomSeed : randomSeed, 419 | params : params 420 | }); 421 | } 422 | 423 | // open a subscription on the server, callback should handle on ready and nosub 424 | subscribe(name, params, callback) { 425 | var self = this; 426 | var id = self._getNextId(); 427 | 428 | if (callback) { 429 | self._callbacks[id] = callback; 430 | } 431 | 432 | self._send({ 433 | msg : "sub", 434 | id : id, 435 | name : name, 436 | params : params 437 | }); 438 | 439 | return id; 440 | } 441 | 442 | unsubscribe(id) { 443 | var self = this; 444 | self._send({ 445 | msg : "unsub", 446 | id : id 447 | }); 448 | } 449 | 450 | /** 451 | * Adds an observer to a collection and returns the observer. 452 | * Observation can be stopped by calling the stop() method on the observer. 453 | * Functions for added, updated and removed can be added to the observer 454 | * afterward. 455 | */ 456 | observe(name, added, updated, removed) { 457 | var self = this; 458 | var observer = {}; 459 | var id = self._getNextId(); 460 | 461 | // name, _id are immutable 462 | Object.defineProperty(observer, "name", { 463 | get: function() { return name; }, 464 | enumerable: true 465 | }); 466 | 467 | Object.defineProperty(observer, "_id", { get: function() { return id; }}); 468 | 469 | observer.added = added || function(){}; 470 | observer.updated = updated || function(){}; 471 | observer.removed = removed || function(){}; 472 | 473 | observer.stop = function() { 474 | self._removeObserver(observer); 475 | }; 476 | 477 | self._addObserver(observer); 478 | 479 | return observer; 480 | } 481 | 482 | } 483 | 484 | module.exports = DDPClient; 485 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ddp-client", 3 | "version": "0.1.1", 4 | "description": "DDP Client for browsers and native JS runtimes (no dependencies on document or 3rd party WebSocket libraries)", 5 | "main": "index.js", 6 | "keywords": [ 7 | "ddp", 8 | "meteor", 9 | "protocol" 10 | ], 11 | "dependencies": { 12 | "underscore": "1.8.3", 13 | "events": "1.0.2", 14 | "ejson": "2.0.1" 15 | }, 16 | "author": "Harrison Harnisch ", 17 | "license": "MIT", 18 | "repository": { 19 | "type": "git", 20 | "url": "https@github.com:hharnisc/node-ddp-client.git" 21 | }, 22 | "bugs": "https://github.com/hharnisc/node-ddp-client/issues" 23 | } 24 | --------------------------------------------------------------------------------