├── .gitignore ├── .test-setup.js ├── LICENSE ├── README.md ├── app ├── components │ ├── CandleStickChart │ │ └── index.js │ ├── Home │ │ ├── index.js │ │ ├── spec.js │ │ └── style.css │ └── Ticker │ │ ├── index.js │ │ └── style.css ├── index.js ├── js │ └── phoenix.js └── styles │ └── reset.css ├── index.html ├── package.json ├── screen-shot.gif ├── server.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | npm-debug.log 4 | dist 5 | coverage 6 | .nyc_output -------------------------------------------------------------------------------- /.test-setup.js: -------------------------------------------------------------------------------- 1 | const noop = () => {} 2 | const empty = () => ({}) 3 | 4 | require.extensions['.css'] = empty 5 | require.extensions['.ico'] = noop 6 | require.extensions['.png'] = noop 7 | require.extensions['.jpg'] = noop 8 | require.extensions['.svg'] = noop 9 | 10 | var jsdom = require('jsdom').jsdom 11 | 12 | var exposedProperties = ['window', 'navigator', 'document'] 13 | 14 | global.document = jsdom('') 15 | global.window = document.defaultView 16 | Object.keys(document.defaultView).forEach((property) => { 17 | if (typeof global[property] === 'undefined') { 18 | exposedProperties.push(property) 19 | global[property] = document.defaultView[property] 20 | } 21 | }) 22 | 23 | global.navigator = { 24 | userAgent: 'node.js' 25 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Phil Callister 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ticker-react 2 | 3 | **ticker-react** is an example React client which pulls stock quotes from the (defunct but still available) Google Finance API by using the [ticker-phoenix](https://github.com/philcallister/ticker-phoenix) Elixir Phoenix app. This React client app subsribes to Phoenix Channels with stock ticker symbols and is periodically notified of updates. 4 | 5 | To see the **ticker-react** app in action, head over to 6 | - [ticker-elixir](https://github.com/philcallister/ticker-elixir) Elixir OTP app 7 | - [ticker-phoenix] (https://github.com/philcallister/ticker-phoenix) Elixir Phoenix app 8 | 9 | ##### Example screenshot of the three applications being used together 10 | ![Stock Ticker](/screen-shot.gif?raw=true "Stock Ticker Example") 11 | 12 | ## Environment 13 | 14 | The sample was developed using the following 15 | 16 | - OS X El Capitan (10.11) 17 | 18 | ## Setup 19 | 20 | Clone Repo 21 | ```bash 22 | git clone https://github.com/philcallister/ticker-react.git 23 | ``` 24 | 25 | ##### Dependencies 26 | You'll need [Node](https://nodejs.org/en/) to run the JavaScript server and have access to [NPM](https://www.npmjs.com/). To install on OS X with [Homebrew](http://brew.sh/), run the following brew command 27 | ```bash 28 | brew install node 29 | ``` 30 | 31 | ## Run It 32 | 33 | Start the server 34 | 35 | ```bash 36 | npm install && npm start 37 | ``` 38 | You'll want to make sure the Elixir Phoenix app is also running. If you haven't done so, follow the instructions for getting it installed and running at [ticker-phoenix](https://github.com/philcallister/ticker-phoenix). 39 | 40 | ## See It 41 | With both the Phoenix and React apps started, you can see it in action from your browser by visiting ```http://localhost:3000``` 42 | 43 | ## License 44 | 45 | [MIT License](http://www.opensource.org/licenses/MIT) 46 | 47 | **Free Software, Hell Yeah!** 48 | -------------------------------------------------------------------------------- /app/components/CandleStickChart/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | import { format } from "d3-format"; 5 | import { timeFormat } from "d3-time-format"; 6 | import { scaleTime } from "d3-scale"; 7 | 8 | 9 | import { ChartCanvas, Chart, series, scale, coordinates, tooltip, axes, annotation, indicator, helper } from "react-stockcharts"; 10 | 11 | var { CandlestickSeries, BarSeries, LineSeries, AreaSeries } = series; 12 | var { discontinuousTimeScaleProvider } = scale; 13 | 14 | var { EdgeIndicator } = coordinates; 15 | var { CrossHairCursor, MouseCoordinateX, MouseCoordinateY, CurrentCoordinate } = coordinates; 16 | var { Annotate, LabelAnnotation, Label } = annotation; 17 | 18 | var { OHLCTooltip, MovingAverageTooltip } = tooltip; 19 | var { XAxis, YAxis } = axes; 20 | var { ema, sma } = indicator; 21 | var { fitWidth } = helper; 22 | 23 | class CandleStickChart extends React.Component { 24 | constructor(props) { 25 | super(props); 26 | this.socket = props.socket; 27 | this.symbol = props.symbol; 28 | this.state = {data: props.data, interval: props.interval}; 29 | } 30 | 31 | componentDidMount() { 32 | this.resetChannel(this.state.data, this.state.interval); 33 | } 34 | 35 | componentWillUnmount() { 36 | this.channel.leave(); 37 | } 38 | 39 | componentWillReceiveProps(props) { 40 | if (props.interval != this.state.interval) { 41 | this.setState({data: [], interval: props.interval}); 42 | this.resetChannel(props.data, props.interval); 43 | } 44 | } 45 | 46 | resetChannel(data, interval) { 47 | if(this.channel) { 48 | this.channel.leave(); 49 | } 50 | let that = this; 51 | this.channel = this.socket.channel(`frame:symbol:${this.symbol}:${interval}`); 52 | this.channel.join(); 53 | 54 | // No frames loaded -- go get latest historical 55 | if (!data.length) { 56 | this.channel.push('all_frames', {symbol: this.symbol, interval: interval}).receive("ok", function(reply){ 57 | if (!!reply.frames.length) { 58 | let elements = reply.frames.map(frame => that.frameToElement(frame)); 59 | that.setState({data: elements}); 60 | } 61 | }); 62 | } 63 | 64 | // Receive new frame 65 | this.channel.on('frame', function(frame){ 66 | let element = that.frameToElement(frame); 67 | let current_data = that.state.data; 68 | current_data.push(element); 69 | that.setState({data: current_data}); 70 | }); 71 | } 72 | 73 | frameToElement(frame) { 74 | let date = new Date(frame.close.lt_dts); 75 | date.setSeconds(0,0); 76 | return({date: date, open: +frame.open.l, high: +frame.high.l, low: +frame.low.l, close: +frame.close.l, volume: 0}) 77 | } 78 | 79 | render() { 80 | var { type, width, ratio } = this.props; 81 | var data = this.state.data; 82 | 83 | var ema20 = ema() 84 | .id(0) 85 | .windowSize(20) 86 | .merge((d, c) => {d.ema20 = c}) 87 | .accessor(d => d.ema20); 88 | 89 | var ema50 = ema() 90 | .id(2) 91 | .windowSize(50) 92 | .merge((d, c) => {d.ema50 = c}) 93 | .accessor(d => d.ema50); 94 | 95 | var margin = { left: 80, right: 80, top: 30, bottom: 50 }; 96 | var height = 400; 97 | 98 | var [yAxisLabelX, yAxisLabelY] = [width - margin.left - 40, margin.top + (height - margin.top - margin.bottom) / 2] 99 | if (data.length > 1) { 100 | let label = `${this.state.interval} minutes`; 101 | return ( 102 | d.date} 107 | xScale={scaleTime()}> 108 | 109 | 146 | ); 147 | } 148 | else { 149 | return null; 150 | } 151 | } 152 | } 153 | 154 | CandleStickChart.propTypes = { 155 | data: React.PropTypes.array.isRequired, 156 | width: React.PropTypes.number.isRequired, 157 | ratio: React.PropTypes.number.isRequired, 158 | type: React.PropTypes.oneOf(["svg", "hybrid"]).isRequired, 159 | }; 160 | 161 | CandleStickChart.defaultProps = { 162 | type: "hybrid", 163 | }; 164 | 165 | CandleStickChart = fitWidth(CandleStickChart); 166 | 167 | export default CandleStickChart; -------------------------------------------------------------------------------- /app/components/Home/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import cssModules from "react-css-modules" 3 | import style from "./style.css" 4 | 5 | import { default as Ticker } from "../Ticker" 6 | 7 | export class Home extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | } 11 | 12 | render() { 13 | return ( 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | ) 25 | } 26 | } 27 | 28 | export default cssModules(Home, style) -------------------------------------------------------------------------------- /app/components/Home/spec.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import expect from "expect" 3 | import { shallow } from "enzyme" 4 | 5 | import { Home } from "./" 6 | 7 | describe("", () => { 8 | it("should render", () => { 9 | const renderedComponent = shallow( 10 | 11 | ) 12 | expect(renderedComponent.is("div")).toEqual(true) 13 | }) 14 | }) -------------------------------------------------------------------------------- /app/components/Home/style.css: -------------------------------------------------------------------------------- 1 | .tickerColumns { 2 | display: flex; 3 | flex-wrap: wrap; 4 | } -------------------------------------------------------------------------------- /app/components/Ticker/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import cssModules from "react-css-modules"; 4 | import classNames from 'classnames/bind'; 5 | import style from "./style.css"; 6 | 7 | import { default as CandleStickChart } from "../CandleStickChart" 8 | 9 | let cx = classNames.bind(style); 10 | 11 | export class Ticker extends React.Component { 12 | constructor(props) { 13 | super(props); 14 | this.socket = props.socket; 15 | this.symbol = props.symbol; 16 | 17 | let fakeTick = {l: "##", c: "##", cp: "##"}; 18 | this.state = {currentInterval: 1, currentTick: fakeTick}; 19 | } 20 | 21 | componentDidMount() { 22 | let that = this; 23 | this.channel = this.socket.channel(`quote:symbol:${this.symbol}`); 24 | this.channel.join(); 25 | this.channel.on('quote', function(tick){ 26 | that.setState({currentTick: tick}) 27 | }); 28 | } 29 | 30 | componentWillUnmount() { 31 | this.channel.leave(); 32 | } 33 | 34 | render() { 35 | return ( 36 |
37 |
38 |
39 |

{this.symbol}

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