├── .babelrc ├── .gitignore ├── README.md ├── dist ├── .gitkeep └── socket.js ├── gulpfile.js ├── package.json └── vendor └── socket.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | /node_modules 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Phoenix Framework Socket JavaScript API 2 | 3 | This is a source of NPM package that contains extracted Socket JavaScript API 4 | from the [Phoenix Framework](http://www.phoenixframework.org/). 5 | 6 | # Installation 7 | 8 | `npm install --save phoenix-socket` 9 | 10 | # Authors 11 | 12 | API was made by authors of the [Phoenix Framework](http://www.phoenixframework.org/) 13 | - see their website for complete list of authors. 14 | 15 | Packaged by Marcin Lewandowski. 16 | 17 | # Versioning scheme 18 | 19 | Version of the NPM package is always matching version of [Phoenix Framework](http://www.phoenixframework.org/) 20 | that contained the API. 21 | 22 | # License 23 | 24 | The same as [Phoenix Framework](http://www.phoenixframework.org/) (MIT) 25 | -------------------------------------------------------------------------------- /dist/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mspanc/phoenix_socket/b29ff7b1a16bfb1c840a097c5114f96c1bb81539/dist/.gitkeep -------------------------------------------------------------------------------- /dist/socket.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 8 | 9 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 10 | 11 | function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } 12 | 13 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 14 | 15 | // Phoenix Channels JavaScript client 16 | // 17 | // ## Socket Connection 18 | // 19 | // A single connection is established to the server and 20 | // channels are multiplexed over the connection. 21 | // Connect to the server using the `Socket` class: 22 | // 23 | // let socket = new Socket("/ws", {params: {userToken: "123"}}) 24 | // socket.connect() 25 | // 26 | // The `Socket` constructor takes the mount point of the socket, 27 | // the authentication params, as well as options that can be found in 28 | // the Socket docs, such as configuring the `LongPoll` transport, and 29 | // heartbeat. 30 | // 31 | // ## Channels 32 | // 33 | // Channels are isolated, concurrent processes on the server that 34 | // subscribe to topics and broker events between the client and server. 35 | // To join a channel, you must provide the topic, and channel params for 36 | // authorization. Here's an example chat room example where `"new_msg"` 37 | // events are listened for, messages are pushed to the server, and 38 | // the channel is joined with ok/error/timeout matches: 39 | // 40 | // let channel = socket.channel("room:123", {token: roomToken}) 41 | // channel.on("new_msg", msg => console.log("Got message", msg) ) 42 | // $input.onEnter( e => { 43 | // channel.push("new_msg", {body: e.target.val}, 10000) 44 | // .receive("ok", (msg) => console.log("created message", msg) ) 45 | // .receive("error", (reasons) => console.log("create failed", reasons) ) 46 | // .receive("timeout", () => console.log("Networking issue...") ) 47 | // }) 48 | // channel.join() 49 | // .receive("ok", ({messages}) => console.log("catching up", messages) ) 50 | // .receive("error", ({reason}) => console.log("failed join", reason) ) 51 | // .receive("timeout", () => console.log("Networking issue. Still waiting...") ) 52 | // 53 | // 54 | // ## Joining 55 | // 56 | // Creating a channel with `socket.channel(topic, params)`, binds the params to 57 | // `channel.params`, which are sent up on `channel.join()`. 58 | // Subsequent rejoins will send up the modified params for 59 | // updating authorization params, or passing up last_message_id information. 60 | // Successful joins receive an "ok" status, while unsuccessful joins 61 | // receive "error". 62 | // 63 | // ## Duplicate Join Subscriptions 64 | // 65 | // While the client may join any number of topics on any number of channels, 66 | // the client may only hold a single subscription for each unique topic at any 67 | // given time. When attempting to create a duplicate subscription, 68 | // the server will close the existing channel, log a warning, and 69 | // spawn a new channel for the topic. The client will have their 70 | // `channel.onClose` callbacks fired for the existing channel, and the new 71 | // channel join will have its receive hooks processed as normal. 72 | // 73 | // ## Pushing Messages 74 | // 75 | // From the previous example, we can see that pushing messages to the server 76 | // can be done with `channel.push(eventName, payload)` and we can optionally 77 | // receive responses from the push. Additionally, we can use 78 | // `receive("timeout", callback)` to abort waiting for our other `receive` hooks 79 | // and take action after some period of waiting. The default timeout is 5000ms. 80 | // 81 | // 82 | // ## Socket Hooks 83 | // 84 | // Lifecycle events of the multiplexed connection can be hooked into via 85 | // `socket.onError()` and `socket.onClose()` events, ie: 86 | // 87 | // socket.onError( () => console.log("there was an error with the connection!") ) 88 | // socket.onClose( () => console.log("the connection dropped") ) 89 | // 90 | // 91 | // ## Channel Hooks 92 | // 93 | // For each joined channel, you can bind to `onError` and `onClose` events 94 | // to monitor the channel lifecycle, ie: 95 | // 96 | // channel.onError( () => console.log("there was an error!") ) 97 | // channel.onClose( () => console.log("the channel has gone away gracefully") ) 98 | // 99 | // ### onError hooks 100 | // 101 | // `onError` hooks are invoked if the socket connection drops, or the channel 102 | // crashes on the server. In either case, a channel rejoin is attempted 103 | // automatically in an exponential backoff manner. 104 | // 105 | // ### onClose hooks 106 | // 107 | // `onClose` hooks are invoked only in two cases. 1) the channel explicitly 108 | // closed on the server, or 2). The client explicitly closed, by calling 109 | // `channel.leave()` 110 | // 111 | // 112 | // ## Presence 113 | // 114 | // The `Presence` object provides features for syncing presence information 115 | // from the server with the client and handling presences joining and leaving. 116 | // 117 | // ### Syncing initial state from the server 118 | // 119 | // `Presence.syncState` is used to sync the list of presences on the server 120 | // with the client's state. An optional `onJoin` and `onLeave` callback can 121 | // be provided to react to changes in the client's local presences across 122 | // disconnects and reconnects with the server. 123 | // 124 | // `Presence.syncDiff` is used to sync a diff of presence join and leave 125 | // events from the server, as they happen. Like `syncState`, `syncDiff` 126 | // accepts optional `onJoin` and `onLeave` callbacks to react to a user 127 | // joining or leaving from a device. 128 | // 129 | // ### Listing Presences 130 | // 131 | // `Presence.list` is used to return a list of presence information 132 | // based on the local state of metadata. By default, all presence 133 | // metadata is returned, but a `listBy` function can be supplied to 134 | // allow the client to select which metadata to use for a given presence. 135 | // For example, you may have a user online from different devices with a 136 | // a metadata status of "online", but they have set themselves to "away" 137 | // on another device. In this case, they app may choose to use the "away" 138 | // status for what appears on the UI. The example below defines a `listBy` 139 | // function which prioritizes the first metadata which was registered for 140 | // each user. This could be the first tab they opened, or the first device 141 | // they came online from: 142 | // 143 | // let state = {} 144 | // state = Presence.syncState(state, stateFromServer) 145 | // let listBy = (id, {metas: [first, ...rest]}) => { 146 | // first.count = rest.length + 1 // count of this user's presences 147 | // first.id = id 148 | // return first 149 | // } 150 | // let onlineUsers = Presence.list(state, listBy) 151 | // 152 | // 153 | // ### Example Usage 154 | // 155 | // // detect if user has joined for the 1st time or from another tab/device 156 | // let onJoin = (id, current, newPres) => { 157 | // if(!current){ 158 | // console.log("user has entered for the first time", newPres) 159 | // } else { 160 | // console.log("user additional presence", newPres) 161 | // } 162 | // } 163 | // // detect if user has left from all tabs/devices, or is still present 164 | // let onLeave = (id, current, leftPres) => { 165 | // if(current.metas.length === 0){ 166 | // console.log("user has left from all devices", leftPres) 167 | // } else { 168 | // console.log("user left from a device", leftPres) 169 | // } 170 | // } 171 | // let presences = {} // client's initial empty presence state 172 | // // receive initial presence data from server, sent after join 173 | // myChannel.on("presences", state => { 174 | // presences = Presence.syncState(presences, state, onJoin, onLeave) 175 | // displayUsers(Presence.list(presences)) 176 | // }) 177 | // // receive "presence_diff" from server, containing join/leave events 178 | // myChannel.on("presence_diff", diff => { 179 | // presences = Presence.syncDiff(presences, diff, onJoin, onLeave) 180 | // this.setState({users: Presence.list(room.presences, listBy)}) 181 | // }) 182 | // 183 | var VSN = "1.0.0"; 184 | var SOCKET_STATES = { connecting: 0, open: 1, closing: 2, closed: 3 }; 185 | var DEFAULT_TIMEOUT = 10000; 186 | var CHANNEL_STATES = { 187 | closed: "closed", 188 | errored: "errored", 189 | joined: "joined", 190 | joining: "joining", 191 | leaving: "leaving" 192 | }; 193 | var CHANNEL_EVENTS = { 194 | close: "phx_close", 195 | error: "phx_error", 196 | join: "phx_join", 197 | reply: "phx_reply", 198 | leave: "phx_leave" 199 | }; 200 | var TRANSPORTS = { 201 | longpoll: "longpoll", 202 | websocket: "websocket" 203 | }; 204 | 205 | var Push = function () { 206 | 207 | // Initializes the Push 208 | // 209 | // channel - The Channel 210 | // event - The event, for example `"phx_join"` 211 | // payload - The payload, for example `{user_id: 123}` 212 | // timeout - The push timeout in milliseconds 213 | // 214 | function Push(channel, event, payload, timeout) { 215 | _classCallCheck(this, Push); 216 | 217 | this.channel = channel; 218 | this.event = event; 219 | this.payload = payload || {}; 220 | this.receivedResp = null; 221 | this.timeout = timeout; 222 | this.timeoutTimer = null; 223 | this.recHooks = []; 224 | this.sent = false; 225 | } 226 | 227 | _createClass(Push, [{ 228 | key: "resend", 229 | value: function resend(timeout) { 230 | this.timeout = timeout; 231 | this.cancelRefEvent(); 232 | this.ref = null; 233 | this.refEvent = null; 234 | this.receivedResp = null; 235 | this.sent = false; 236 | this.send(); 237 | } 238 | }, { 239 | key: "send", 240 | value: function send() { 241 | if (this.hasReceived("timeout")) { 242 | return; 243 | } 244 | this.startTimeout(); 245 | this.sent = true; 246 | this.channel.socket.push({ 247 | topic: this.channel.topic, 248 | event: this.event, 249 | payload: this.payload, 250 | ref: this.ref 251 | }); 252 | } 253 | }, { 254 | key: "receive", 255 | value: function receive(status, callback) { 256 | if (this.hasReceived(status)) { 257 | callback(this.receivedResp.response); 258 | } 259 | 260 | this.recHooks.push({ status: status, callback: callback }); 261 | return this; 262 | } 263 | 264 | // private 265 | 266 | }, { 267 | key: "matchReceive", 268 | value: function matchReceive(_ref) { 269 | var status = _ref.status, 270 | response = _ref.response, 271 | ref = _ref.ref; 272 | 273 | this.recHooks.filter(function (h) { 274 | return h.status === status; 275 | }).forEach(function (h) { 276 | return h.callback(response); 277 | }); 278 | } 279 | }, { 280 | key: "cancelRefEvent", 281 | value: function cancelRefEvent() { 282 | if (!this.refEvent) { 283 | return; 284 | } 285 | this.channel.off(this.refEvent); 286 | } 287 | }, { 288 | key: "cancelTimeout", 289 | value: function cancelTimeout() { 290 | clearTimeout(this.timeoutTimer); 291 | this.timeoutTimer = null; 292 | } 293 | }, { 294 | key: "startTimeout", 295 | value: function startTimeout() { 296 | var _this = this; 297 | 298 | if (this.timeoutTimer) { 299 | return; 300 | } 301 | this.ref = this.channel.socket.makeRef(); 302 | this.refEvent = this.channel.replyEventName(this.ref); 303 | 304 | this.channel.on(this.refEvent, function (payload) { 305 | _this.cancelRefEvent(); 306 | _this.cancelTimeout(); 307 | _this.receivedResp = payload; 308 | _this.matchReceive(payload); 309 | }); 310 | 311 | this.timeoutTimer = setTimeout(function () { 312 | _this.trigger("timeout", {}); 313 | }, this.timeout); 314 | } 315 | }, { 316 | key: "hasReceived", 317 | value: function hasReceived(status) { 318 | return this.receivedResp && this.receivedResp.status === status; 319 | } 320 | }, { 321 | key: "trigger", 322 | value: function trigger(status, response) { 323 | this.channel.trigger(this.refEvent, { status: status, response: response }); 324 | } 325 | }]); 326 | 327 | return Push; 328 | }(); 329 | 330 | var Channel = exports.Channel = function () { 331 | function Channel(topic, params, socket) { 332 | var _this2 = this; 333 | 334 | _classCallCheck(this, Channel); 335 | 336 | this.state = CHANNEL_STATES.closed; 337 | this.topic = topic; 338 | this.params = params || {}; 339 | this.socket = socket; 340 | this.bindings = []; 341 | this.timeout = this.socket.timeout; 342 | this.joinedOnce = false; 343 | this.joinPush = new Push(this, CHANNEL_EVENTS.join, this.params, this.timeout); 344 | this.pushBuffer = []; 345 | this.rejoinTimer = new Timer(function () { 346 | return _this2.rejoinUntilConnected(); 347 | }, this.socket.reconnectAfterMs); 348 | this.joinPush.receive("ok", function () { 349 | _this2.state = CHANNEL_STATES.joined; 350 | _this2.rejoinTimer.reset(); 351 | _this2.pushBuffer.forEach(function (pushEvent) { 352 | return pushEvent.send(); 353 | }); 354 | _this2.pushBuffer = []; 355 | }); 356 | this.onClose(function () { 357 | _this2.rejoinTimer.reset(); 358 | _this2.socket.log("channel", "close " + _this2.topic + " " + _this2.joinRef()); 359 | _this2.state = CHANNEL_STATES.closed; 360 | _this2.socket.remove(_this2); 361 | }); 362 | this.onError(function (reason) { 363 | if (_this2.isLeaving() || _this2.isClosed()) { 364 | return; 365 | } 366 | _this2.socket.log("channel", "error " + _this2.topic, reason); 367 | _this2.state = CHANNEL_STATES.errored; 368 | _this2.rejoinTimer.scheduleTimeout(); 369 | }); 370 | this.joinPush.receive("timeout", function () { 371 | if (!_this2.isJoining()) { 372 | return; 373 | } 374 | _this2.socket.log("channel", "timeout " + _this2.topic, _this2.joinPush.timeout); 375 | _this2.state = CHANNEL_STATES.errored; 376 | _this2.rejoinTimer.scheduleTimeout(); 377 | }); 378 | this.on(CHANNEL_EVENTS.reply, function (payload, ref) { 379 | _this2.trigger(_this2.replyEventName(ref), payload); 380 | }); 381 | } 382 | 383 | _createClass(Channel, [{ 384 | key: "rejoinUntilConnected", 385 | value: function rejoinUntilConnected() { 386 | this.rejoinTimer.scheduleTimeout(); 387 | if (this.socket.isConnected()) { 388 | this.rejoin(); 389 | } 390 | } 391 | }, { 392 | key: "join", 393 | value: function join() { 394 | var timeout = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.timeout; 395 | 396 | if (this.joinedOnce) { 397 | throw "tried to join multiple times. 'join' can only be called a single time per channel instance"; 398 | } else { 399 | this.joinedOnce = true; 400 | this.rejoin(timeout); 401 | return this.joinPush; 402 | } 403 | } 404 | }, { 405 | key: "onClose", 406 | value: function onClose(callback) { 407 | this.on(CHANNEL_EVENTS.close, callback); 408 | } 409 | }, { 410 | key: "onError", 411 | value: function onError(callback) { 412 | this.on(CHANNEL_EVENTS.error, function (reason) { 413 | return callback(reason); 414 | }); 415 | } 416 | }, { 417 | key: "on", 418 | value: function on(event, callback) { 419 | this.bindings.push({ event: event, callback: callback }); 420 | } 421 | }, { 422 | key: "off", 423 | value: function off(event) { 424 | this.bindings = this.bindings.filter(function (bind) { 425 | return bind.event !== event; 426 | }); 427 | } 428 | }, { 429 | key: "canPush", 430 | value: function canPush() { 431 | return this.socket.isConnected() && this.isJoined(); 432 | } 433 | }, { 434 | key: "push", 435 | value: function push(event, payload) { 436 | var timeout = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : this.timeout; 437 | 438 | if (!this.joinedOnce) { 439 | throw "tried to push '" + event + "' to '" + this.topic + "' before joining. Use channel.join() before pushing events"; 440 | } 441 | var pushEvent = new Push(this, event, payload, timeout); 442 | if (this.canPush()) { 443 | pushEvent.send(); 444 | } else { 445 | pushEvent.startTimeout(); 446 | this.pushBuffer.push(pushEvent); 447 | } 448 | 449 | return pushEvent; 450 | } 451 | 452 | // Leaves the channel 453 | // 454 | // Unsubscribes from server events, and 455 | // instructs channel to terminate on server 456 | // 457 | // Triggers onClose() hooks 458 | // 459 | // To receive leave acknowledgements, use the a `receive` 460 | // hook to bind to the server ack, ie: 461 | // 462 | // channel.leave().receive("ok", () => alert("left!") ) 463 | // 464 | 465 | }, { 466 | key: "leave", 467 | value: function leave() { 468 | var _this3 = this; 469 | 470 | var timeout = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.timeout; 471 | 472 | this.state = CHANNEL_STATES.leaving; 473 | var onClose = function onClose() { 474 | _this3.socket.log("channel", "leave " + _this3.topic); 475 | _this3.trigger(CHANNEL_EVENTS.close, "leave", _this3.joinRef()); 476 | }; 477 | var leavePush = new Push(this, CHANNEL_EVENTS.leave, {}, timeout); 478 | leavePush.receive("ok", function () { 479 | return onClose(); 480 | }).receive("timeout", function () { 481 | return onClose(); 482 | }); 483 | leavePush.send(); 484 | if (!this.canPush()) { 485 | leavePush.trigger("ok", {}); 486 | } 487 | 488 | return leavePush; 489 | } 490 | 491 | // Overridable message hook 492 | // 493 | // Receives all events for specialized message handling 494 | // before dispatching to the channel callbacks. 495 | // 496 | // Must return the payload, modified or unmodified 497 | 498 | }, { 499 | key: "onMessage", 500 | value: function onMessage(event, payload, ref) { 501 | return payload; 502 | } 503 | 504 | // private 505 | 506 | }, { 507 | key: "isMember", 508 | value: function isMember(topic) { 509 | return this.topic === topic; 510 | } 511 | }, { 512 | key: "joinRef", 513 | value: function joinRef() { 514 | return this.joinPush.ref; 515 | } 516 | }, { 517 | key: "sendJoin", 518 | value: function sendJoin(timeout) { 519 | this.state = CHANNEL_STATES.joining; 520 | this.joinPush.resend(timeout); 521 | } 522 | }, { 523 | key: "rejoin", 524 | value: function rejoin() { 525 | var timeout = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.timeout; 526 | if (this.isLeaving()) { 527 | return; 528 | } 529 | this.sendJoin(timeout); 530 | } 531 | }, { 532 | key: "trigger", 533 | value: function trigger(event, payload, ref) { 534 | var close = CHANNEL_EVENTS.close, 535 | error = CHANNEL_EVENTS.error, 536 | leave = CHANNEL_EVENTS.leave, 537 | join = CHANNEL_EVENTS.join; 538 | 539 | if (ref && [close, error, leave, join].indexOf(event) >= 0 && ref !== this.joinRef()) { 540 | return; 541 | } 542 | var handledPayload = this.onMessage(event, payload, ref); 543 | if (payload && !handledPayload) { 544 | throw "channel onMessage callbacks must return the payload, modified or unmodified"; 545 | } 546 | 547 | this.bindings.filter(function (bind) { 548 | return bind.event === event; 549 | }).map(function (bind) { 550 | return bind.callback(handledPayload, ref); 551 | }); 552 | } 553 | }, { 554 | key: "replyEventName", 555 | value: function replyEventName(ref) { 556 | return "chan_reply_" + ref; 557 | } 558 | }, { 559 | key: "isClosed", 560 | value: function isClosed() { 561 | return this.state === CHANNEL_STATES.closed; 562 | } 563 | }, { 564 | key: "isErrored", 565 | value: function isErrored() { 566 | return this.state === CHANNEL_STATES.errored; 567 | } 568 | }, { 569 | key: "isJoined", 570 | value: function isJoined() { 571 | return this.state === CHANNEL_STATES.joined; 572 | } 573 | }, { 574 | key: "isJoining", 575 | value: function isJoining() { 576 | return this.state === CHANNEL_STATES.joining; 577 | } 578 | }, { 579 | key: "isLeaving", 580 | value: function isLeaving() { 581 | return this.state === CHANNEL_STATES.leaving; 582 | } 583 | }]); 584 | 585 | return Channel; 586 | }(); 587 | 588 | var Socket = exports.Socket = function () { 589 | 590 | // Initializes the Socket 591 | // 592 | // endPoint - The string WebSocket endpoint, ie, "ws://example.com/ws", 593 | // "wss://example.com" 594 | // "/ws" (inherited host & protocol) 595 | // opts - Optional configuration 596 | // transport - The Websocket Transport, for example WebSocket or Phoenix.LongPoll. 597 | // Defaults to WebSocket with automatic LongPoll fallback. 598 | // timeout - The default timeout in milliseconds to trigger push timeouts. 599 | // Defaults `DEFAULT_TIMEOUT` 600 | // heartbeatIntervalMs - The millisec interval to send a heartbeat message 601 | // reconnectAfterMs - The optional function that returns the millsec 602 | // reconnect interval. Defaults to stepped backoff of: 603 | // 604 | // function(tries){ 605 | // return [1000, 5000, 10000][tries - 1] || 10000 606 | // } 607 | // 608 | // logger - The optional function for specialized logging, ie: 609 | // `logger: (kind, msg, data) => { console.log(`${kind}: ${msg}`, data) } 610 | // 611 | // longpollerTimeout - The maximum timeout of a long poll AJAX request. 612 | // Defaults to 20s (double the server long poll timer). 613 | // 614 | // params - The optional params to pass when connecting 615 | // 616 | // For IE8 support use an ES5-shim (https://github.com/es-shims/es5-shim) 617 | // 618 | function Socket(endPoint) { 619 | var _this4 = this; 620 | 621 | var opts = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 622 | 623 | _classCallCheck(this, Socket); 624 | 625 | this.stateChangeCallbacks = { open: [], close: [], error: [], message: [] }; 626 | this.channels = []; 627 | this.sendBuffer = []; 628 | this.ref = 0; 629 | this.timeout = opts.timeout || DEFAULT_TIMEOUT; 630 | this.transport = opts.transport || window.WebSocket || LongPoll; 631 | this.heartbeatIntervalMs = opts.heartbeatIntervalMs || 30000; 632 | this.reconnectAfterMs = opts.reconnectAfterMs || function (tries) { 633 | return [1000, 2000, 5000, 10000][tries - 1] || 10000; 634 | }; 635 | this.logger = opts.logger || function () {}; // noop 636 | this.longpollerTimeout = opts.longpollerTimeout || 20000; 637 | this.params = opts.params || {}; 638 | this.endPoint = endPoint + "/" + TRANSPORTS.websocket; 639 | this.reconnectTimer = new Timer(function () { 640 | _this4.disconnect(function () { 641 | return _this4.connect(); 642 | }); 643 | }, this.reconnectAfterMs); 644 | } 645 | 646 | _createClass(Socket, [{ 647 | key: "protocol", 648 | value: function protocol() { 649 | return location.protocol.match(/^https/) ? "wss" : "ws"; 650 | } 651 | }, { 652 | key: "endPointURL", 653 | value: function endPointURL() { 654 | var uri = Ajax.appendParams(Ajax.appendParams(this.endPoint, this.params), { vsn: VSN }); 655 | if (uri.charAt(0) !== "/") { 656 | return uri; 657 | } 658 | if (uri.charAt(1) === "/") { 659 | return this.protocol() + ":" + uri; 660 | } 661 | 662 | return this.protocol() + "://" + location.host + uri; 663 | } 664 | }, { 665 | key: "disconnect", 666 | value: function disconnect(callback, code, reason) { 667 | if (this.conn) { 668 | this.conn.onclose = function () {}; // noop 669 | if (code) { 670 | this.conn.close(code, reason || ""); 671 | } else { 672 | this.conn.close(); 673 | } 674 | this.conn = null; 675 | } 676 | callback && callback(); 677 | } 678 | 679 | // params - The params to send when connecting, for example `{user_id: userToken}` 680 | 681 | }, { 682 | key: "connect", 683 | value: function connect(params) { 684 | var _this5 = this; 685 | 686 | if (params) { 687 | console && console.log("passing params to connect is deprecated. Instead pass :params to the Socket constructor"); 688 | this.params = params; 689 | } 690 | if (this.conn) { 691 | return; 692 | } 693 | 694 | this.conn = new this.transport(this.endPointURL()); 695 | this.conn.timeout = this.longpollerTimeout; 696 | this.conn.onopen = function () { 697 | return _this5.onConnOpen(); 698 | }; 699 | this.conn.onerror = function (error) { 700 | return _this5.onConnError(error); 701 | }; 702 | this.conn.onmessage = function (event) { 703 | return _this5.onConnMessage(event); 704 | }; 705 | this.conn.onclose = function (event) { 706 | return _this5.onConnClose(event); 707 | }; 708 | } 709 | 710 | // Logs the message. Override `this.logger` for specialized logging. noops by default 711 | 712 | }, { 713 | key: "log", 714 | value: function log(kind, msg, data) { 715 | this.logger(kind, msg, data); 716 | } 717 | 718 | // Registers callbacks for connection state change events 719 | // 720 | // Examples 721 | // 722 | // socket.onError(function(error){ alert("An error occurred") }) 723 | // 724 | 725 | }, { 726 | key: "onOpen", 727 | value: function onOpen(callback) { 728 | this.stateChangeCallbacks.open.push(callback); 729 | } 730 | }, { 731 | key: "onClose", 732 | value: function onClose(callback) { 733 | this.stateChangeCallbacks.close.push(callback); 734 | } 735 | }, { 736 | key: "onError", 737 | value: function onError(callback) { 738 | this.stateChangeCallbacks.error.push(callback); 739 | } 740 | }, { 741 | key: "onMessage", 742 | value: function onMessage(callback) { 743 | this.stateChangeCallbacks.message.push(callback); 744 | } 745 | }, { 746 | key: "onConnOpen", 747 | value: function onConnOpen() { 748 | var _this6 = this; 749 | 750 | this.log("transport", "connected to " + this.endPointURL(), this.transport.prototype); 751 | this.flushSendBuffer(); 752 | this.reconnectTimer.reset(); 753 | if (!this.conn.skipHeartbeat) { 754 | clearInterval(this.heartbeatTimer); 755 | this.heartbeatTimer = setInterval(function () { 756 | return _this6.sendHeartbeat(); 757 | }, this.heartbeatIntervalMs); 758 | } 759 | this.stateChangeCallbacks.open.forEach(function (callback) { 760 | return callback(); 761 | }); 762 | } 763 | }, { 764 | key: "onConnClose", 765 | value: function onConnClose(event) { 766 | this.log("transport", "close", event); 767 | this.triggerChanError(); 768 | clearInterval(this.heartbeatTimer); 769 | this.reconnectTimer.scheduleTimeout(); 770 | this.stateChangeCallbacks.close.forEach(function (callback) { 771 | return callback(event); 772 | }); 773 | } 774 | }, { 775 | key: "onConnError", 776 | value: function onConnError(error) { 777 | this.log("transport", error); 778 | this.triggerChanError(); 779 | this.stateChangeCallbacks.error.forEach(function (callback) { 780 | return callback(error); 781 | }); 782 | } 783 | }, { 784 | key: "triggerChanError", 785 | value: function triggerChanError() { 786 | this.channels.forEach(function (channel) { 787 | return channel.trigger(CHANNEL_EVENTS.error); 788 | }); 789 | } 790 | }, { 791 | key: "connectionState", 792 | value: function connectionState() { 793 | switch (this.conn && this.conn.readyState) { 794 | case SOCKET_STATES.connecting: 795 | return "connecting"; 796 | case SOCKET_STATES.open: 797 | return "open"; 798 | case SOCKET_STATES.closing: 799 | return "closing"; 800 | default: 801 | return "closed"; 802 | } 803 | } 804 | }, { 805 | key: "isConnected", 806 | value: function isConnected() { 807 | return this.connectionState() === "open"; 808 | } 809 | }, { 810 | key: "remove", 811 | value: function remove(channel) { 812 | this.channels = this.channels.filter(function (c) { 813 | return c.joinRef() !== channel.joinRef(); 814 | }); 815 | } 816 | }, { 817 | key: "channel", 818 | value: function channel(topic) { 819 | var chanParams = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 820 | 821 | var chan = new Channel(topic, chanParams, this); 822 | this.channels.push(chan); 823 | return chan; 824 | } 825 | }, { 826 | key: "push", 827 | value: function push(data) { 828 | var _this7 = this; 829 | 830 | var topic = data.topic, 831 | event = data.event, 832 | payload = data.payload, 833 | ref = data.ref; 834 | 835 | var callback = function callback() { 836 | return _this7.conn.send(JSON.stringify(data)); 837 | }; 838 | this.log("push", topic + " " + event + " (" + ref + ")", payload); 839 | if (this.isConnected()) { 840 | callback(); 841 | } else { 842 | this.sendBuffer.push(callback); 843 | } 844 | } 845 | 846 | // Return the next message ref, accounting for overflows 847 | 848 | }, { 849 | key: "makeRef", 850 | value: function makeRef() { 851 | var newRef = this.ref + 1; 852 | if (newRef === this.ref) { 853 | this.ref = 0; 854 | } else { 855 | this.ref = newRef; 856 | } 857 | 858 | return this.ref.toString(); 859 | } 860 | }, { 861 | key: "sendHeartbeat", 862 | value: function sendHeartbeat() { 863 | if (!this.isConnected()) { 864 | return; 865 | } 866 | this.push({ topic: "phoenix", event: "heartbeat", payload: {}, ref: this.makeRef() }); 867 | } 868 | }, { 869 | key: "flushSendBuffer", 870 | value: function flushSendBuffer() { 871 | if (this.isConnected() && this.sendBuffer.length > 0) { 872 | this.sendBuffer.forEach(function (callback) { 873 | return callback(); 874 | }); 875 | this.sendBuffer = []; 876 | } 877 | } 878 | }, { 879 | key: "onConnMessage", 880 | value: function onConnMessage(rawMessage) { 881 | var msg = JSON.parse(rawMessage.data); 882 | var topic = msg.topic, 883 | event = msg.event, 884 | payload = msg.payload, 885 | ref = msg.ref; 886 | 887 | this.log("receive", (payload.status || "") + " " + topic + " " + event + " " + (ref && "(" + ref + ")" || ""), payload); 888 | this.channels.filter(function (channel) { 889 | return channel.isMember(topic); 890 | }).forEach(function (channel) { 891 | return channel.trigger(event, payload, ref); 892 | }); 893 | this.stateChangeCallbacks.message.forEach(function (callback) { 894 | return callback(msg); 895 | }); 896 | } 897 | }]); 898 | 899 | return Socket; 900 | }(); 901 | 902 | var LongPoll = exports.LongPoll = function () { 903 | function LongPoll(endPoint) { 904 | _classCallCheck(this, LongPoll); 905 | 906 | this.endPoint = null; 907 | this.token = null; 908 | this.skipHeartbeat = true; 909 | this.onopen = function () {}; // noop 910 | this.onerror = function () {}; // noop 911 | this.onmessage = function () {}; // noop 912 | this.onclose = function () {}; // noop 913 | this.pollEndpoint = this.normalizeEndpoint(endPoint); 914 | this.readyState = SOCKET_STATES.connecting; 915 | 916 | this.poll(); 917 | } 918 | 919 | _createClass(LongPoll, [{ 920 | key: "normalizeEndpoint", 921 | value: function normalizeEndpoint(endPoint) { 922 | return endPoint.replace("ws://", "http://").replace("wss://", "https://").replace(new RegExp("(.*)\/" + TRANSPORTS.websocket), "$1/" + TRANSPORTS.longpoll); 923 | } 924 | }, { 925 | key: "endpointURL", 926 | value: function endpointURL() { 927 | return Ajax.appendParams(this.pollEndpoint, { token: this.token }); 928 | } 929 | }, { 930 | key: "closeAndRetry", 931 | value: function closeAndRetry() { 932 | this.close(); 933 | this.readyState = SOCKET_STATES.connecting; 934 | } 935 | }, { 936 | key: "ontimeout", 937 | value: function ontimeout() { 938 | this.onerror("timeout"); 939 | this.closeAndRetry(); 940 | } 941 | }, { 942 | key: "poll", 943 | value: function poll() { 944 | var _this8 = this; 945 | 946 | if (!(this.readyState === SOCKET_STATES.open || this.readyState === SOCKET_STATES.connecting)) { 947 | return; 948 | } 949 | 950 | Ajax.request("GET", this.endpointURL(), "application/json", null, this.timeout, this.ontimeout.bind(this), function (resp) { 951 | if (resp) { 952 | var status = resp.status, 953 | token = resp.token, 954 | messages = resp.messages; 955 | 956 | _this8.token = token; 957 | } else { 958 | var status = 0; 959 | } 960 | 961 | switch (status) { 962 | case 200: 963 | messages.forEach(function (msg) { 964 | return _this8.onmessage({ data: JSON.stringify(msg) }); 965 | }); 966 | _this8.poll(); 967 | break; 968 | case 204: 969 | _this8.poll(); 970 | break; 971 | case 410: 972 | _this8.readyState = SOCKET_STATES.open; 973 | _this8.onopen(); 974 | _this8.poll(); 975 | break; 976 | case 0: 977 | case 500: 978 | _this8.onerror(); 979 | _this8.closeAndRetry(); 980 | break; 981 | default: 982 | throw "unhandled poll status " + status; 983 | } 984 | }); 985 | } 986 | }, { 987 | key: "send", 988 | value: function send(body) { 989 | var _this9 = this; 990 | 991 | Ajax.request("POST", this.endpointURL(), "application/json", body, this.timeout, this.onerror.bind(this, "timeout"), function (resp) { 992 | if (!resp || resp.status !== 200) { 993 | _this9.onerror(status); 994 | _this9.closeAndRetry(); 995 | } 996 | }); 997 | } 998 | }, { 999 | key: "close", 1000 | value: function close(code, reason) { 1001 | this.readyState = SOCKET_STATES.closed; 1002 | this.onclose(); 1003 | } 1004 | }]); 1005 | 1006 | return LongPoll; 1007 | }(); 1008 | 1009 | var Ajax = exports.Ajax = function () { 1010 | function Ajax() { 1011 | _classCallCheck(this, Ajax); 1012 | } 1013 | 1014 | _createClass(Ajax, null, [{ 1015 | key: "request", 1016 | value: function request(method, endPoint, accept, body, timeout, ontimeout, callback) { 1017 | if (window.XDomainRequest) { 1018 | var req = new XDomainRequest(); // IE8, IE9 1019 | this.xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback); 1020 | } else { 1021 | var _req = window.XMLHttpRequest ? new XMLHttpRequest() : // IE7+, Firefox, Chrome, Opera, Safari 1022 | new ActiveXObject("Microsoft.XMLHTTP"); // IE6, IE5 1023 | this.xhrRequest(_req, method, endPoint, accept, body, timeout, ontimeout, callback); 1024 | } 1025 | } 1026 | }, { 1027 | key: "xdomainRequest", 1028 | value: function xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback) { 1029 | var _this10 = this; 1030 | 1031 | req.timeout = timeout; 1032 | req.open(method, endPoint); 1033 | req.onload = function () { 1034 | var response = _this10.parseJSON(req.responseText); 1035 | callback && callback(response); 1036 | }; 1037 | if (ontimeout) { 1038 | req.ontimeout = ontimeout; 1039 | } 1040 | 1041 | // Work around bug in IE9 that requires an attached onprogress handler 1042 | req.onprogress = function () {}; 1043 | 1044 | req.send(body); 1045 | } 1046 | }, { 1047 | key: "xhrRequest", 1048 | value: function xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback) { 1049 | var _this11 = this; 1050 | 1051 | req.timeout = timeout; 1052 | req.open(method, endPoint, true); 1053 | req.setRequestHeader("Content-Type", accept); 1054 | req.onerror = function () { 1055 | callback && callback(null); 1056 | }; 1057 | req.onreadystatechange = function () { 1058 | if (req.readyState === _this11.states.complete && callback) { 1059 | var response = _this11.parseJSON(req.responseText); 1060 | callback(response); 1061 | } 1062 | }; 1063 | if (ontimeout) { 1064 | req.ontimeout = ontimeout; 1065 | } 1066 | 1067 | req.send(body); 1068 | } 1069 | }, { 1070 | key: "parseJSON", 1071 | value: function parseJSON(resp) { 1072 | return resp && resp !== "" ? JSON.parse(resp) : null; 1073 | } 1074 | }, { 1075 | key: "serialize", 1076 | value: function serialize(obj, parentKey) { 1077 | var queryStr = []; 1078 | for (var key in obj) { 1079 | if (!obj.hasOwnProperty(key)) { 1080 | continue; 1081 | } 1082 | var paramKey = parentKey ? parentKey + "[" + key + "]" : key; 1083 | var paramVal = obj[key]; 1084 | if ((typeof paramVal === "undefined" ? "undefined" : _typeof(paramVal)) === "object") { 1085 | queryStr.push(this.serialize(paramVal, paramKey)); 1086 | } else { 1087 | queryStr.push(encodeURIComponent(paramKey) + "=" + encodeURIComponent(paramVal)); 1088 | } 1089 | } 1090 | return queryStr.join("&"); 1091 | } 1092 | }, { 1093 | key: "appendParams", 1094 | value: function appendParams(url, params) { 1095 | if (Object.keys(params).length === 0) { 1096 | return url; 1097 | } 1098 | 1099 | var prefix = url.match(/\?/) ? "&" : "?"; 1100 | return "" + url + prefix + this.serialize(params); 1101 | } 1102 | }]); 1103 | 1104 | return Ajax; 1105 | }(); 1106 | 1107 | Ajax.states = { complete: 4 }; 1108 | 1109 | var Presence = exports.Presence = { 1110 | syncState: function syncState(currentState, newState, onJoin, onLeave) { 1111 | var _this12 = this; 1112 | 1113 | var state = this.clone(currentState); 1114 | var joins = {}; 1115 | var leaves = {}; 1116 | 1117 | this.map(state, function (key, presence) { 1118 | if (!newState[key]) { 1119 | leaves[key] = presence; 1120 | } 1121 | }); 1122 | this.map(newState, function (key, newPresence) { 1123 | var currentPresence = state[key]; 1124 | if (currentPresence) { 1125 | var newRefs = newPresence.metas.map(function (m) { 1126 | return m.phx_ref; 1127 | }); 1128 | var curRefs = currentPresence.metas.map(function (m) { 1129 | return m.phx_ref; 1130 | }); 1131 | var joinedMetas = newPresence.metas.filter(function (m) { 1132 | return curRefs.indexOf(m.phx_ref) < 0; 1133 | }); 1134 | var leftMetas = currentPresence.metas.filter(function (m) { 1135 | return newRefs.indexOf(m.phx_ref) < 0; 1136 | }); 1137 | if (joinedMetas.length > 0) { 1138 | joins[key] = newPresence; 1139 | joins[key].metas = joinedMetas; 1140 | } 1141 | if (leftMetas.length > 0) { 1142 | leaves[key] = _this12.clone(currentPresence); 1143 | leaves[key].metas = leftMetas; 1144 | } 1145 | } else { 1146 | joins[key] = newPresence; 1147 | } 1148 | }); 1149 | return this.syncDiff(state, { joins: joins, leaves: leaves }, onJoin, onLeave); 1150 | }, 1151 | syncDiff: function syncDiff(currentState, _ref2, onJoin, onLeave) { 1152 | var joins = _ref2.joins, 1153 | leaves = _ref2.leaves; 1154 | 1155 | var state = this.clone(currentState); 1156 | if (!onJoin) { 1157 | onJoin = function onJoin() {}; 1158 | } 1159 | if (!onLeave) { 1160 | onLeave = function onLeave() {}; 1161 | } 1162 | 1163 | this.map(joins, function (key, newPresence) { 1164 | var currentPresence = state[key]; 1165 | state[key] = newPresence; 1166 | if (currentPresence) { 1167 | var _state$key$metas; 1168 | 1169 | (_state$key$metas = state[key].metas).unshift.apply(_state$key$metas, _toConsumableArray(currentPresence.metas)); 1170 | } 1171 | onJoin(key, currentPresence, newPresence); 1172 | }); 1173 | this.map(leaves, function (key, leftPresence) { 1174 | var currentPresence = state[key]; 1175 | if (!currentPresence) { 1176 | return; 1177 | } 1178 | var refsToRemove = leftPresence.metas.map(function (m) { 1179 | return m.phx_ref; 1180 | }); 1181 | currentPresence.metas = currentPresence.metas.filter(function (p) { 1182 | return refsToRemove.indexOf(p.phx_ref) < 0; 1183 | }); 1184 | onLeave(key, currentPresence, leftPresence); 1185 | if (currentPresence.metas.length === 0) { 1186 | delete state[key]; 1187 | } 1188 | }); 1189 | return state; 1190 | }, 1191 | list: function list(presences, chooser) { 1192 | if (!chooser) { 1193 | chooser = function chooser(key, pres) { 1194 | return pres; 1195 | }; 1196 | } 1197 | 1198 | return this.map(presences, function (key, presence) { 1199 | return chooser(key, presence); 1200 | }); 1201 | }, 1202 | 1203 | 1204 | // private 1205 | 1206 | map: function map(obj, func) { 1207 | return Object.getOwnPropertyNames(obj).map(function (key) { 1208 | return func(key, obj[key]); 1209 | }); 1210 | }, 1211 | clone: function clone(obj) { 1212 | return JSON.parse(JSON.stringify(obj)); 1213 | } 1214 | }; 1215 | 1216 | // Creates a timer that accepts a `timerCalc` function to perform 1217 | // calculated timeout retries, such as exponential backoff. 1218 | // 1219 | // ## Examples 1220 | // 1221 | // let reconnectTimer = new Timer(() => this.connect(), function(tries){ 1222 | // return [1000, 5000, 10000][tries - 1] || 10000 1223 | // }) 1224 | // reconnectTimer.scheduleTimeout() // fires after 1000 1225 | // reconnectTimer.scheduleTimeout() // fires after 5000 1226 | // reconnectTimer.reset() 1227 | // reconnectTimer.scheduleTimeout() // fires after 1000 1228 | // 1229 | 1230 | var Timer = function () { 1231 | function Timer(callback, timerCalc) { 1232 | _classCallCheck(this, Timer); 1233 | 1234 | this.callback = callback; 1235 | this.timerCalc = timerCalc; 1236 | this.timer = null; 1237 | this.tries = 0; 1238 | } 1239 | 1240 | _createClass(Timer, [{ 1241 | key: "reset", 1242 | value: function reset() { 1243 | this.tries = 0; 1244 | clearTimeout(this.timer); 1245 | } 1246 | 1247 | // Cancels any previous scheduleTimeout and schedules callback 1248 | 1249 | }, { 1250 | key: "scheduleTimeout", 1251 | value: function scheduleTimeout() { 1252 | var _this13 = this; 1253 | 1254 | clearTimeout(this.timer); 1255 | 1256 | this.timer = setTimeout(function () { 1257 | _this13.tries = _this13.tries + 1; 1258 | _this13.callback(); 1259 | }, this.timerCalc(this.tries + 1)); 1260 | } 1261 | }]); 1262 | 1263 | return Timer; 1264 | }(); -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require("gulp"); 2 | var babel = require("gulp-babel"); 3 | 4 | gulp.task("default", function () { 5 | return gulp.src("vendor/socket.js") 6 | .pipe(babel()) 7 | .pipe(gulp.dest("dist")); 8 | }); 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phoenix-socket", 3 | "version": "1.2.3", 4 | "description": "Socket API for accessing Phoenix Framework's Channels", 5 | "main": "dist/socket.js", 6 | "jsnext:main": "vendor/socket.js", 7 | "scripts": { 8 | "build": "gulp" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+ssh://git@github.com/mspanc/phoenix_socket.git" 13 | }, 14 | "keywords": [ 15 | "phoenix", 16 | "phoenixframework", 17 | "socket", 18 | "websockets" 19 | ], 20 | "author": "", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/mspanc/phoenix_socket/issues" 24 | }, 25 | "homepage": "https://github.com/mspanc/phoenix_socket#readme", 26 | "devDependencies": { 27 | "babel-preset-es2015": "^6.1.18", 28 | "gulp": "^3.9.0", 29 | "gulp-babel": "^6.1.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /vendor/socket.js: -------------------------------------------------------------------------------- 1 | // Phoenix Channels JavaScript client 2 | // 3 | // ## Socket Connection 4 | // 5 | // A single connection is established to the server and 6 | // channels are multiplexed over the connection. 7 | // Connect to the server using the `Socket` class: 8 | // 9 | // let socket = new Socket("/ws", {params: {userToken: "123"}}) 10 | // socket.connect() 11 | // 12 | // The `Socket` constructor takes the mount point of the socket, 13 | // the authentication params, as well as options that can be found in 14 | // the Socket docs, such as configuring the `LongPoll` transport, and 15 | // heartbeat. 16 | // 17 | // ## Channels 18 | // 19 | // Channels are isolated, concurrent processes on the server that 20 | // subscribe to topics and broker events between the client and server. 21 | // To join a channel, you must provide the topic, and channel params for 22 | // authorization. Here's an example chat room example where `"new_msg"` 23 | // events are listened for, messages are pushed to the server, and 24 | // the channel is joined with ok/error/timeout matches: 25 | // 26 | // let channel = socket.channel("room:123", {token: roomToken}) 27 | // channel.on("new_msg", msg => console.log("Got message", msg) ) 28 | // $input.onEnter( e => { 29 | // channel.push("new_msg", {body: e.target.val}, 10000) 30 | // .receive("ok", (msg) => console.log("created message", msg) ) 31 | // .receive("error", (reasons) => console.log("create failed", reasons) ) 32 | // .receive("timeout", () => console.log("Networking issue...") ) 33 | // }) 34 | // channel.join() 35 | // .receive("ok", ({messages}) => console.log("catching up", messages) ) 36 | // .receive("error", ({reason}) => console.log("failed join", reason) ) 37 | // .receive("timeout", () => console.log("Networking issue. Still waiting...") ) 38 | // 39 | // 40 | // ## Joining 41 | // 42 | // Creating a channel with `socket.channel(topic, params)`, binds the params to 43 | // `channel.params`, which are sent up on `channel.join()`. 44 | // Subsequent rejoins will send up the modified params for 45 | // updating authorization params, or passing up last_message_id information. 46 | // Successful joins receive an "ok" status, while unsuccessful joins 47 | // receive "error". 48 | // 49 | // ## Duplicate Join Subscriptions 50 | // 51 | // While the client may join any number of topics on any number of channels, 52 | // the client may only hold a single subscription for each unique topic at any 53 | // given time. When attempting to create a duplicate subscription, 54 | // the server will close the existing channel, log a warning, and 55 | // spawn a new channel for the topic. The client will have their 56 | // `channel.onClose` callbacks fired for the existing channel, and the new 57 | // channel join will have its receive hooks processed as normal. 58 | // 59 | // ## Pushing Messages 60 | // 61 | // From the previous example, we can see that pushing messages to the server 62 | // can be done with `channel.push(eventName, payload)` and we can optionally 63 | // receive responses from the push. Additionally, we can use 64 | // `receive("timeout", callback)` to abort waiting for our other `receive` hooks 65 | // and take action after some period of waiting. The default timeout is 5000ms. 66 | // 67 | // 68 | // ## Socket Hooks 69 | // 70 | // Lifecycle events of the multiplexed connection can be hooked into via 71 | // `socket.onError()` and `socket.onClose()` events, ie: 72 | // 73 | // socket.onError( () => console.log("there was an error with the connection!") ) 74 | // socket.onClose( () => console.log("the connection dropped") ) 75 | // 76 | // 77 | // ## Channel Hooks 78 | // 79 | // For each joined channel, you can bind to `onError` and `onClose` events 80 | // to monitor the channel lifecycle, ie: 81 | // 82 | // channel.onError( () => console.log("there was an error!") ) 83 | // channel.onClose( () => console.log("the channel has gone away gracefully") ) 84 | // 85 | // ### onError hooks 86 | // 87 | // `onError` hooks are invoked if the socket connection drops, or the channel 88 | // crashes on the server. In either case, a channel rejoin is attempted 89 | // automatically in an exponential backoff manner. 90 | // 91 | // ### onClose hooks 92 | // 93 | // `onClose` hooks are invoked only in two cases. 1) the channel explicitly 94 | // closed on the server, or 2). The client explicitly closed, by calling 95 | // `channel.leave()` 96 | // 97 | // 98 | // ## Presence 99 | // 100 | // The `Presence` object provides features for syncing presence information 101 | // from the server with the client and handling presences joining and leaving. 102 | // 103 | // ### Syncing initial state from the server 104 | // 105 | // `Presence.syncState` is used to sync the list of presences on the server 106 | // with the client's state. An optional `onJoin` and `onLeave` callback can 107 | // be provided to react to changes in the client's local presences across 108 | // disconnects and reconnects with the server. 109 | // 110 | // `Presence.syncDiff` is used to sync a diff of presence join and leave 111 | // events from the server, as they happen. Like `syncState`, `syncDiff` 112 | // accepts optional `onJoin` and `onLeave` callbacks to react to a user 113 | // joining or leaving from a device. 114 | // 115 | // ### Listing Presences 116 | // 117 | // `Presence.list` is used to return a list of presence information 118 | // based on the local state of metadata. By default, all presence 119 | // metadata is returned, but a `listBy` function can be supplied to 120 | // allow the client to select which metadata to use for a given presence. 121 | // For example, you may have a user online from different devices with a 122 | // a metadata status of "online", but they have set themselves to "away" 123 | // on another device. In this case, they app may choose to use the "away" 124 | // status for what appears on the UI. The example below defines a `listBy` 125 | // function which prioritizes the first metadata which was registered for 126 | // each user. This could be the first tab they opened, or the first device 127 | // they came online from: 128 | // 129 | // let state = {} 130 | // state = Presence.syncState(state, stateFromServer) 131 | // let listBy = (id, {metas: [first, ...rest]}) => { 132 | // first.count = rest.length + 1 // count of this user's presences 133 | // first.id = id 134 | // return first 135 | // } 136 | // let onlineUsers = Presence.list(state, listBy) 137 | // 138 | // 139 | // ### Example Usage 140 | // 141 | // // detect if user has joined for the 1st time or from another tab/device 142 | // let onJoin = (id, current, newPres) => { 143 | // if(!current){ 144 | // console.log("user has entered for the first time", newPres) 145 | // } else { 146 | // console.log("user additional presence", newPres) 147 | // } 148 | // } 149 | // // detect if user has left from all tabs/devices, or is still present 150 | // let onLeave = (id, current, leftPres) => { 151 | // if(current.metas.length === 0){ 152 | // console.log("user has left from all devices", leftPres) 153 | // } else { 154 | // console.log("user left from a device", leftPres) 155 | // } 156 | // } 157 | // let presences = {} // client's initial empty presence state 158 | // // receive initial presence data from server, sent after join 159 | // myChannel.on("presences", state => { 160 | // presences = Presence.syncState(presences, state, onJoin, onLeave) 161 | // displayUsers(Presence.list(presences)) 162 | // }) 163 | // // receive "presence_diff" from server, containing join/leave events 164 | // myChannel.on("presence_diff", diff => { 165 | // presences = Presence.syncDiff(presences, diff, onJoin, onLeave) 166 | // this.setState({users: Presence.list(room.presences, listBy)}) 167 | // }) 168 | // 169 | const VSN = "1.0.0" 170 | const SOCKET_STATES = {connecting: 0, open: 1, closing: 2, closed: 3} 171 | const DEFAULT_TIMEOUT = 10000 172 | const CHANNEL_STATES = { 173 | closed: "closed", 174 | errored: "errored", 175 | joined: "joined", 176 | joining: "joining", 177 | leaving: "leaving", 178 | } 179 | const CHANNEL_EVENTS = { 180 | close: "phx_close", 181 | error: "phx_error", 182 | join: "phx_join", 183 | reply: "phx_reply", 184 | leave: "phx_leave" 185 | } 186 | const TRANSPORTS = { 187 | longpoll: "longpoll", 188 | websocket: "websocket" 189 | } 190 | 191 | class Push { 192 | 193 | // Initializes the Push 194 | // 195 | // channel - The Channel 196 | // event - The event, for example `"phx_join"` 197 | // payload - The payload, for example `{user_id: 123}` 198 | // timeout - The push timeout in milliseconds 199 | // 200 | constructor(channel, event, payload, timeout){ 201 | this.channel = channel 202 | this.event = event 203 | this.payload = payload || {} 204 | this.receivedResp = null 205 | this.timeout = timeout 206 | this.timeoutTimer = null 207 | this.recHooks = [] 208 | this.sent = false 209 | } 210 | 211 | resend(timeout){ 212 | this.timeout = timeout 213 | this.cancelRefEvent() 214 | this.ref = null 215 | this.refEvent = null 216 | this.receivedResp = null 217 | this.sent = false 218 | this.send() 219 | } 220 | 221 | send(){ if(this.hasReceived("timeout")){ return } 222 | this.startTimeout() 223 | this.sent = true 224 | this.channel.socket.push({ 225 | topic: this.channel.topic, 226 | event: this.event, 227 | payload: this.payload, 228 | ref: this.ref 229 | }) 230 | } 231 | 232 | receive(status, callback){ 233 | if(this.hasReceived(status)){ 234 | callback(this.receivedResp.response) 235 | } 236 | 237 | this.recHooks.push({status, callback}) 238 | return this 239 | } 240 | 241 | 242 | // private 243 | 244 | matchReceive({status, response, ref}){ 245 | this.recHooks.filter( h => h.status === status ) 246 | .forEach( h => h.callback(response) ) 247 | } 248 | 249 | cancelRefEvent(){ if(!this.refEvent){ return } 250 | this.channel.off(this.refEvent) 251 | } 252 | 253 | cancelTimeout(){ 254 | clearTimeout(this.timeoutTimer) 255 | this.timeoutTimer = null 256 | } 257 | 258 | startTimeout(){ if(this.timeoutTimer){ return } 259 | this.ref = this.channel.socket.makeRef() 260 | this.refEvent = this.channel.replyEventName(this.ref) 261 | 262 | this.channel.on(this.refEvent, payload => { 263 | this.cancelRefEvent() 264 | this.cancelTimeout() 265 | this.receivedResp = payload 266 | this.matchReceive(payload) 267 | }) 268 | 269 | this.timeoutTimer = setTimeout(() => { 270 | this.trigger("timeout", {}) 271 | }, this.timeout) 272 | } 273 | 274 | hasReceived(status){ 275 | return this.receivedResp && this.receivedResp.status === status 276 | } 277 | 278 | trigger(status, response){ 279 | this.channel.trigger(this.refEvent, {status, response}) 280 | } 281 | } 282 | 283 | export class Channel { 284 | constructor(topic, params, socket) { 285 | this.state = CHANNEL_STATES.closed 286 | this.topic = topic 287 | this.params = params || {} 288 | this.socket = socket 289 | this.bindings = [] 290 | this.timeout = this.socket.timeout 291 | this.joinedOnce = false 292 | this.joinPush = new Push(this, CHANNEL_EVENTS.join, this.params, this.timeout) 293 | this.pushBuffer = [] 294 | this.rejoinTimer = new Timer( 295 | () => this.rejoinUntilConnected(), 296 | this.socket.reconnectAfterMs 297 | ) 298 | this.joinPush.receive("ok", () => { 299 | this.state = CHANNEL_STATES.joined 300 | this.rejoinTimer.reset() 301 | this.pushBuffer.forEach( pushEvent => pushEvent.send() ) 302 | this.pushBuffer = [] 303 | }) 304 | this.onClose( () => { 305 | this.rejoinTimer.reset() 306 | this.socket.log("channel", `close ${this.topic} ${this.joinRef()}`) 307 | this.state = CHANNEL_STATES.closed 308 | this.socket.remove(this) 309 | }) 310 | this.onError( reason => { if(this.isLeaving() || this.isClosed()){ return } 311 | this.socket.log("channel", `error ${this.topic}`, reason) 312 | this.state = CHANNEL_STATES.errored 313 | this.rejoinTimer.scheduleTimeout() 314 | }) 315 | this.joinPush.receive("timeout", () => { if(!this.isJoining()){ return } 316 | this.socket.log("channel", `timeout ${this.topic}`, this.joinPush.timeout) 317 | this.state = CHANNEL_STATES.errored 318 | this.rejoinTimer.scheduleTimeout() 319 | }) 320 | this.on(CHANNEL_EVENTS.reply, (payload, ref) => { 321 | this.trigger(this.replyEventName(ref), payload) 322 | }) 323 | } 324 | 325 | rejoinUntilConnected(){ 326 | this.rejoinTimer.scheduleTimeout() 327 | if(this.socket.isConnected()){ 328 | this.rejoin() 329 | } 330 | } 331 | 332 | join(timeout = this.timeout){ 333 | if(this.joinedOnce){ 334 | throw(`tried to join multiple times. 'join' can only be called a single time per channel instance`) 335 | } else { 336 | this.joinedOnce = true 337 | this.rejoin(timeout) 338 | return this.joinPush 339 | } 340 | } 341 | 342 | onClose(callback){ this.on(CHANNEL_EVENTS.close, callback) } 343 | 344 | onError(callback){ 345 | this.on(CHANNEL_EVENTS.error, reason => callback(reason) ) 346 | } 347 | 348 | on(event, callback){ this.bindings.push({event, callback}) } 349 | 350 | off(event){ this.bindings = this.bindings.filter( bind => bind.event !== event ) } 351 | 352 | canPush(){ return this.socket.isConnected() && this.isJoined() } 353 | 354 | push(event, payload, timeout = this.timeout){ 355 | if(!this.joinedOnce){ 356 | throw(`tried to push '${event}' to '${this.topic}' before joining. Use channel.join() before pushing events`) 357 | } 358 | let pushEvent = new Push(this, event, payload, timeout) 359 | if(this.canPush()){ 360 | pushEvent.send() 361 | } else { 362 | pushEvent.startTimeout() 363 | this.pushBuffer.push(pushEvent) 364 | } 365 | 366 | return pushEvent 367 | } 368 | 369 | // Leaves the channel 370 | // 371 | // Unsubscribes from server events, and 372 | // instructs channel to terminate on server 373 | // 374 | // Triggers onClose() hooks 375 | // 376 | // To receive leave acknowledgements, use the a `receive` 377 | // hook to bind to the server ack, ie: 378 | // 379 | // channel.leave().receive("ok", () => alert("left!") ) 380 | // 381 | leave(timeout = this.timeout){ 382 | this.state = CHANNEL_STATES.leaving 383 | let onClose = () => { 384 | this.socket.log("channel", `leave ${this.topic}`) 385 | this.trigger(CHANNEL_EVENTS.close, "leave", this.joinRef()) 386 | } 387 | let leavePush = new Push(this, CHANNEL_EVENTS.leave, {}, timeout) 388 | leavePush.receive("ok", () => onClose() ) 389 | .receive("timeout", () => onClose() ) 390 | leavePush.send() 391 | if(!this.canPush()){ leavePush.trigger("ok", {}) } 392 | 393 | return leavePush 394 | } 395 | 396 | // Overridable message hook 397 | // 398 | // Receives all events for specialized message handling 399 | // before dispatching to the channel callbacks. 400 | // 401 | // Must return the payload, modified or unmodified 402 | onMessage(event, payload, ref){ return payload } 403 | 404 | // private 405 | 406 | isMember(topic){ return this.topic === topic } 407 | 408 | joinRef(){ return this.joinPush.ref } 409 | 410 | sendJoin(timeout){ 411 | this.state = CHANNEL_STATES.joining 412 | this.joinPush.resend(timeout) 413 | } 414 | 415 | rejoin(timeout = this.timeout){ if(this.isLeaving()){ return } 416 | this.sendJoin(timeout) 417 | } 418 | 419 | trigger(event, payload, ref){ 420 | let {close, error, leave, join} = CHANNEL_EVENTS 421 | if(ref && [close, error, leave, join].indexOf(event) >= 0 && ref !== this.joinRef()){ 422 | return 423 | } 424 | let handledPayload = this.onMessage(event, payload, ref) 425 | if(payload && !handledPayload){ throw("channel onMessage callbacks must return the payload, modified or unmodified") } 426 | 427 | this.bindings.filter( bind => bind.event === event) 428 | .map( bind => bind.callback(handledPayload, ref)) 429 | } 430 | 431 | replyEventName(ref){ return `chan_reply_${ref}` } 432 | 433 | isClosed() { return this.state === CHANNEL_STATES.closed } 434 | isErrored(){ return this.state === CHANNEL_STATES.errored } 435 | isJoined() { return this.state === CHANNEL_STATES.joined } 436 | isJoining(){ return this.state === CHANNEL_STATES.joining } 437 | isLeaving(){ return this.state === CHANNEL_STATES.leaving } 438 | } 439 | 440 | export class Socket { 441 | 442 | // Initializes the Socket 443 | // 444 | // endPoint - The string WebSocket endpoint, ie, "ws://example.com/ws", 445 | // "wss://example.com" 446 | // "/ws" (inherited host & protocol) 447 | // opts - Optional configuration 448 | // transport - The Websocket Transport, for example WebSocket or Phoenix.LongPoll. 449 | // Defaults to WebSocket with automatic LongPoll fallback. 450 | // timeout - The default timeout in milliseconds to trigger push timeouts. 451 | // Defaults `DEFAULT_TIMEOUT` 452 | // heartbeatIntervalMs - The millisec interval to send a heartbeat message 453 | // reconnectAfterMs - The optional function that returns the millsec 454 | // reconnect interval. Defaults to stepped backoff of: 455 | // 456 | // function(tries){ 457 | // return [1000, 5000, 10000][tries - 1] || 10000 458 | // } 459 | // 460 | // logger - The optional function for specialized logging, ie: 461 | // `logger: (kind, msg, data) => { console.log(`${kind}: ${msg}`, data) } 462 | // 463 | // longpollerTimeout - The maximum timeout of a long poll AJAX request. 464 | // Defaults to 20s (double the server long poll timer). 465 | // 466 | // params - The optional params to pass when connecting 467 | // 468 | // For IE8 support use an ES5-shim (https://github.com/es-shims/es5-shim) 469 | // 470 | constructor(endPoint, opts = {}){ 471 | this.stateChangeCallbacks = {open: [], close: [], error: [], message: []} 472 | this.channels = [] 473 | this.sendBuffer = [] 474 | this.ref = 0 475 | this.timeout = opts.timeout || DEFAULT_TIMEOUT 476 | this.transport = opts.transport || window.WebSocket || LongPoll 477 | this.heartbeatIntervalMs = opts.heartbeatIntervalMs || 30000 478 | this.reconnectAfterMs = opts.reconnectAfterMs || function(tries){ 479 | return [1000, 2000, 5000, 10000][tries - 1] || 10000 480 | } 481 | this.logger = opts.logger || function(){} // noop 482 | this.longpollerTimeout = opts.longpollerTimeout || 20000 483 | this.params = opts.params || {} 484 | this.endPoint = `${endPoint}/${TRANSPORTS.websocket}` 485 | this.reconnectTimer = new Timer(() => { 486 | this.disconnect(() => this.connect()) 487 | }, this.reconnectAfterMs) 488 | } 489 | 490 | protocol(){ return location.protocol.match(/^https/) ? "wss" : "ws" } 491 | 492 | endPointURL(){ 493 | let uri = Ajax.appendParams( 494 | Ajax.appendParams(this.endPoint, this.params), {vsn: VSN}) 495 | if(uri.charAt(0) !== "/"){ return uri } 496 | if(uri.charAt(1) === "/"){ return `${this.protocol()}:${uri}` } 497 | 498 | return `${this.protocol()}://${location.host}${uri}` 499 | } 500 | 501 | disconnect(callback, code, reason){ 502 | if(this.conn){ 503 | this.conn.onclose = function(){} // noop 504 | if(code){ this.conn.close(code, reason || "") } else { this.conn.close() } 505 | this.conn = null 506 | } 507 | callback && callback() 508 | } 509 | 510 | // params - The params to send when connecting, for example `{user_id: userToken}` 511 | connect(params){ 512 | if(params){ 513 | console && console.log("passing params to connect is deprecated. Instead pass :params to the Socket constructor") 514 | this.params = params 515 | } 516 | if(this.conn){ return } 517 | 518 | this.conn = new this.transport(this.endPointURL()) 519 | this.conn.timeout = this.longpollerTimeout 520 | this.conn.onopen = () => this.onConnOpen() 521 | this.conn.onerror = error => this.onConnError(error) 522 | this.conn.onmessage = event => this.onConnMessage(event) 523 | this.conn.onclose = event => this.onConnClose(event) 524 | } 525 | 526 | // Logs the message. Override `this.logger` for specialized logging. noops by default 527 | log(kind, msg, data){ this.logger(kind, msg, data) } 528 | 529 | // Registers callbacks for connection state change events 530 | // 531 | // Examples 532 | // 533 | // socket.onError(function(error){ alert("An error occurred") }) 534 | // 535 | onOpen (callback){ this.stateChangeCallbacks.open.push(callback) } 536 | onClose (callback){ this.stateChangeCallbacks.close.push(callback) } 537 | onError (callback){ this.stateChangeCallbacks.error.push(callback) } 538 | onMessage (callback){ this.stateChangeCallbacks.message.push(callback) } 539 | 540 | onConnOpen(){ 541 | this.log("transport", `connected to ${this.endPointURL()}`, this.transport.prototype) 542 | this.flushSendBuffer() 543 | this.reconnectTimer.reset() 544 | if(!this.conn.skipHeartbeat){ 545 | clearInterval(this.heartbeatTimer) 546 | this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.heartbeatIntervalMs) 547 | } 548 | this.stateChangeCallbacks.open.forEach( callback => callback() ) 549 | } 550 | 551 | onConnClose(event){ 552 | this.log("transport", "close", event) 553 | this.triggerChanError() 554 | clearInterval(this.heartbeatTimer) 555 | this.reconnectTimer.scheduleTimeout() 556 | this.stateChangeCallbacks.close.forEach( callback => callback(event) ) 557 | } 558 | 559 | onConnError(error){ 560 | this.log("transport", error) 561 | this.triggerChanError() 562 | this.stateChangeCallbacks.error.forEach( callback => callback(error) ) 563 | } 564 | 565 | triggerChanError(){ 566 | this.channels.forEach( channel => channel.trigger(CHANNEL_EVENTS.error) ) 567 | } 568 | 569 | connectionState(){ 570 | switch(this.conn && this.conn.readyState){ 571 | case SOCKET_STATES.connecting: return "connecting" 572 | case SOCKET_STATES.open: return "open" 573 | case SOCKET_STATES.closing: return "closing" 574 | default: return "closed" 575 | } 576 | } 577 | 578 | isConnected(){ return this.connectionState() === "open" } 579 | 580 | remove(channel){ 581 | this.channels = this.channels.filter(c => c.joinRef() !== channel.joinRef()) 582 | } 583 | 584 | channel(topic, chanParams = {}){ 585 | let chan = new Channel(topic, chanParams, this) 586 | this.channels.push(chan) 587 | return chan 588 | } 589 | 590 | push(data){ 591 | let {topic, event, payload, ref} = data 592 | let callback = () => this.conn.send(JSON.stringify(data)) 593 | this.log("push", `${topic} ${event} (${ref})`, payload) 594 | if(this.isConnected()){ 595 | callback() 596 | } 597 | else { 598 | this.sendBuffer.push(callback) 599 | } 600 | } 601 | 602 | // Return the next message ref, accounting for overflows 603 | makeRef(){ 604 | let newRef = this.ref + 1 605 | if(newRef === this.ref){ this.ref = 0 } else { this.ref = newRef } 606 | 607 | return this.ref.toString() 608 | } 609 | 610 | sendHeartbeat(){ if(!this.isConnected()){ return } 611 | this.push({topic: "phoenix", event: "heartbeat", payload: {}, ref: this.makeRef()}) 612 | } 613 | 614 | flushSendBuffer(){ 615 | if(this.isConnected() && this.sendBuffer.length > 0){ 616 | this.sendBuffer.forEach( callback => callback() ) 617 | this.sendBuffer = [] 618 | } 619 | } 620 | 621 | onConnMessage(rawMessage){ 622 | let msg = JSON.parse(rawMessage.data) 623 | let {topic, event, payload, ref} = msg 624 | this.log("receive", `${payload.status || ""} ${topic} ${event} ${ref && "(" + ref + ")" || ""}`, payload) 625 | this.channels.filter( channel => channel.isMember(topic) ) 626 | .forEach( channel => channel.trigger(event, payload, ref) ) 627 | this.stateChangeCallbacks.message.forEach( callback => callback(msg) ) 628 | } 629 | } 630 | 631 | 632 | export class LongPoll { 633 | 634 | constructor(endPoint){ 635 | this.endPoint = null 636 | this.token = null 637 | this.skipHeartbeat = true 638 | this.onopen = function(){} // noop 639 | this.onerror = function(){} // noop 640 | this.onmessage = function(){} // noop 641 | this.onclose = function(){} // noop 642 | this.pollEndpoint = this.normalizeEndpoint(endPoint) 643 | this.readyState = SOCKET_STATES.connecting 644 | 645 | this.poll() 646 | } 647 | 648 | normalizeEndpoint(endPoint){ 649 | return(endPoint 650 | .replace("ws://", "http://") 651 | .replace("wss://", "https://") 652 | .replace(new RegExp("(.*)\/" + TRANSPORTS.websocket), "$1/" + TRANSPORTS.longpoll)) 653 | } 654 | 655 | endpointURL(){ 656 | return Ajax.appendParams(this.pollEndpoint, {token: this.token}) 657 | } 658 | 659 | closeAndRetry(){ 660 | this.close() 661 | this.readyState = SOCKET_STATES.connecting 662 | } 663 | 664 | ontimeout(){ 665 | this.onerror("timeout") 666 | this.closeAndRetry() 667 | } 668 | 669 | poll(){ 670 | if(!(this.readyState === SOCKET_STATES.open || this.readyState === SOCKET_STATES.connecting)){ return } 671 | 672 | Ajax.request("GET", this.endpointURL(), "application/json", null, this.timeout, this.ontimeout.bind(this), (resp) => { 673 | if(resp){ 674 | var {status, token, messages} = resp 675 | this.token = token 676 | } else{ 677 | var status = 0 678 | } 679 | 680 | switch(status){ 681 | case 200: 682 | messages.forEach( msg => this.onmessage({data: JSON.stringify(msg)}) ) 683 | this.poll() 684 | break 685 | case 204: 686 | this.poll() 687 | break 688 | case 410: 689 | this.readyState = SOCKET_STATES.open 690 | this.onopen() 691 | this.poll() 692 | break 693 | case 0: 694 | case 500: 695 | this.onerror() 696 | this.closeAndRetry() 697 | break 698 | default: throw(`unhandled poll status ${status}`) 699 | } 700 | }) 701 | } 702 | 703 | send(body){ 704 | Ajax.request("POST", this.endpointURL(), "application/json", body, this.timeout, this.onerror.bind(this, "timeout"), (resp) => { 705 | if(!resp || resp.status !== 200){ 706 | this.onerror(status) 707 | this.closeAndRetry() 708 | } 709 | }) 710 | } 711 | 712 | close(code, reason){ 713 | this.readyState = SOCKET_STATES.closed 714 | this.onclose() 715 | } 716 | } 717 | 718 | 719 | export class Ajax { 720 | 721 | static request(method, endPoint, accept, body, timeout, ontimeout, callback){ 722 | if(window.XDomainRequest){ 723 | let req = new XDomainRequest() // IE8, IE9 724 | this.xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback) 725 | } else { 726 | let req = window.XMLHttpRequest ? 727 | new XMLHttpRequest() : // IE7+, Firefox, Chrome, Opera, Safari 728 | new ActiveXObject("Microsoft.XMLHTTP") // IE6, IE5 729 | this.xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback) 730 | } 731 | } 732 | 733 | static xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback){ 734 | req.timeout = timeout 735 | req.open(method, endPoint) 736 | req.onload = () => { 737 | let response = this.parseJSON(req.responseText) 738 | callback && callback(response) 739 | } 740 | if(ontimeout){ req.ontimeout = ontimeout } 741 | 742 | // Work around bug in IE9 that requires an attached onprogress handler 743 | req.onprogress = () => {} 744 | 745 | req.send(body) 746 | } 747 | 748 | static xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback){ 749 | req.timeout = timeout 750 | req.open(method, endPoint, true) 751 | req.setRequestHeader("Content-Type", accept) 752 | req.onerror = () => { callback && callback(null) } 753 | req.onreadystatechange = () => { 754 | if(req.readyState === this.states.complete && callback){ 755 | let response = this.parseJSON(req.responseText) 756 | callback(response) 757 | } 758 | } 759 | if(ontimeout){ req.ontimeout = ontimeout } 760 | 761 | req.send(body) 762 | } 763 | 764 | static parseJSON(resp){ 765 | return (resp && resp !== "") ? 766 | JSON.parse(resp) : 767 | null 768 | } 769 | 770 | static serialize(obj, parentKey){ 771 | let queryStr = []; 772 | for(var key in obj){ if(!obj.hasOwnProperty(key)){ continue } 773 | let paramKey = parentKey ? `${parentKey}[${key}]` : key 774 | let paramVal = obj[key] 775 | if(typeof paramVal === "object"){ 776 | queryStr.push(this.serialize(paramVal, paramKey)) 777 | } else { 778 | queryStr.push(encodeURIComponent(paramKey) + "=" + encodeURIComponent(paramVal)) 779 | } 780 | } 781 | return queryStr.join("&") 782 | } 783 | 784 | static appendParams(url, params){ 785 | if(Object.keys(params).length === 0){ return url } 786 | 787 | let prefix = url.match(/\?/) ? "&" : "?" 788 | return `${url}${prefix}${this.serialize(params)}` 789 | } 790 | } 791 | 792 | Ajax.states = {complete: 4} 793 | 794 | 795 | 796 | export var Presence = { 797 | 798 | syncState(currentState, newState, onJoin, onLeave){ 799 | let state = this.clone(currentState) 800 | let joins = {} 801 | let leaves = {} 802 | 803 | this.map(state, (key, presence) => { 804 | if(!newState[key]){ 805 | leaves[key] = presence 806 | } 807 | }) 808 | this.map(newState, (key, newPresence) => { 809 | let currentPresence = state[key] 810 | if(currentPresence){ 811 | let newRefs = newPresence.metas.map(m => m.phx_ref) 812 | let curRefs = currentPresence.metas.map(m => m.phx_ref) 813 | let joinedMetas = newPresence.metas.filter(m => curRefs.indexOf(m.phx_ref) < 0) 814 | let leftMetas = currentPresence.metas.filter(m => newRefs.indexOf(m.phx_ref) < 0) 815 | if(joinedMetas.length > 0){ 816 | joins[key] = newPresence 817 | joins[key].metas = joinedMetas 818 | } 819 | if(leftMetas.length > 0){ 820 | leaves[key] = this.clone(currentPresence) 821 | leaves[key].metas = leftMetas 822 | } 823 | } else { 824 | joins[key] = newPresence 825 | } 826 | }) 827 | return this.syncDiff(state, {joins: joins, leaves: leaves}, onJoin, onLeave) 828 | }, 829 | 830 | syncDiff(currentState, {joins, leaves}, onJoin, onLeave){ 831 | let state = this.clone(currentState) 832 | if(!onJoin){ onJoin = function(){} } 833 | if(!onLeave){ onLeave = function(){} } 834 | 835 | this.map(joins, (key, newPresence) => { 836 | let currentPresence = state[key] 837 | state[key] = newPresence 838 | if(currentPresence){ 839 | state[key].metas.unshift(...currentPresence.metas) 840 | } 841 | onJoin(key, currentPresence, newPresence) 842 | }) 843 | this.map(leaves, (key, leftPresence) => { 844 | let currentPresence = state[key] 845 | if(!currentPresence){ return } 846 | let refsToRemove = leftPresence.metas.map(m => m.phx_ref) 847 | currentPresence.metas = currentPresence.metas.filter(p => { 848 | return refsToRemove.indexOf(p.phx_ref) < 0 849 | }) 850 | onLeave(key, currentPresence, leftPresence) 851 | if(currentPresence.metas.length === 0){ 852 | delete state[key] 853 | } 854 | }) 855 | return state 856 | }, 857 | 858 | list(presences, chooser){ 859 | if(!chooser){ chooser = function(key, pres){ return pres } } 860 | 861 | return this.map(presences, (key, presence) => { 862 | return chooser(key, presence) 863 | }) 864 | }, 865 | 866 | // private 867 | 868 | map(obj, func){ 869 | return Object.getOwnPropertyNames(obj).map(key => func(key, obj[key])) 870 | }, 871 | 872 | clone(obj){ return JSON.parse(JSON.stringify(obj)) } 873 | } 874 | 875 | 876 | // Creates a timer that accepts a `timerCalc` function to perform 877 | // calculated timeout retries, such as exponential backoff. 878 | // 879 | // ## Examples 880 | // 881 | // let reconnectTimer = new Timer(() => this.connect(), function(tries){ 882 | // return [1000, 5000, 10000][tries - 1] || 10000 883 | // }) 884 | // reconnectTimer.scheduleTimeout() // fires after 1000 885 | // reconnectTimer.scheduleTimeout() // fires after 5000 886 | // reconnectTimer.reset() 887 | // reconnectTimer.scheduleTimeout() // fires after 1000 888 | // 889 | class Timer { 890 | constructor(callback, timerCalc){ 891 | this.callback = callback 892 | this.timerCalc = timerCalc 893 | this.timer = null 894 | this.tries = 0 895 | } 896 | 897 | reset(){ 898 | this.tries = 0 899 | clearTimeout(this.timer) 900 | } 901 | 902 | // Cancels any previous scheduleTimeout and schedules callback 903 | scheduleTimeout(){ 904 | clearTimeout(this.timer) 905 | 906 | this.timer = setTimeout(() => { 907 | this.tries = this.tries + 1 908 | this.callback() 909 | }, this.timerCalc(this.tries + 1)) 910 | } 911 | } 912 | --------------------------------------------------------------------------------