├── .gitignore ├── LICENSE ├── README.md ├── elm-package.json ├── js └── setup_phoenix_socket_ports.js └── src └── JSPhoenix.elm /.gitignore: -------------------------------------------------------------------------------- 1 | # elm-package generated files 2 | elm-stuff/ 3 | # elm-repl generated files 4 | repl-temp-* 5 | 6 | tmp/ 7 | 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 OvermindDL1 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 | # elm-jsphoenix 2 | Elm Port wrapper around Phoenix Socket/Presence javascript library to allow compatibility with existing javascript websocket usage 3 | 4 | ## Installation 5 | Include the JSPhoenix.elm module however you wish. 6 | 7 | Add the javascript file to your Phoenix brunch (or whatever you've replaced it with) build system to include in your Phoenix project (generally it should go in `web/static/js` or some subdirectory there-of, adjust the include below for where it is put, but it needs to be babel-transpiled). 8 | 9 | In the javascript for your Phoenix project any Elm app that you instance just call the `setup_phoenix_socket_ports` from the setup_phoenix_socket_ports javascript module, such as by: 10 | ```javascript 11 | import socket from './socket' 12 | import Elm from './elm' // Assuming your Elm mains compile to a ./elm.js in the same directory as your app.js 13 | import setup_phoenix_socket_ports from 'setup_phoenix_socket_ports' // Or from where-ever 14 | 15 | 16 | let socket_setup_once = false 17 | socket.onOpen(() => { 18 | $(document).ready(function(){ 19 | if(socket_setup_once) return 20 | socket_setup_once = true 21 | 22 | // Here setup all your usual javascript that also needs to use the websocket 23 | 24 | let app = Elm.Main.embed(document.querySelector('#my-elm-container')) 25 | setup_phoenix_socket_ports(app, socket) 26 | } 27 | } 28 | ``` 29 | 30 | Even if you do not have any other javascript code using the socket this still ensures full compatibility of your Elm app with the Phoenix socket interface, heartbeats, recovery, and all. 31 | 32 | 33 | ## Usage 34 | The bindings are not absolutely complete to the javascript code, but rather a useful interface is exposed to Elm within its type system. 35 | 36 | This is a brand new project and the documentation is not entirely complete, however the code is short and should be readable. 37 | 38 | ### General usage is as follows 39 | 40 | Connect to a channel, specify presence sync ports and some message ports and how to use: 41 | ```elm 42 | import JSPhoenix exposing (ChannelEventMsg, ChanExitCB) 43 | 44 | -- Currently this next line does not work due to Elm bugs... 45 | --type alias RoomSyncState = List (JSPhoenix.PresenceObject {} {online_at : JSPhoenix.TimexDateTime, nick : String}) 46 | -- So instead you have to do this mess for each of your presence sync object types because of Elm port bugs: 47 | type alias RoomSyncMeta = 48 | { phx_ref : String -- Until Elm bug is fixed, phx_ref should 'generally' always be here 49 | , loc : String 50 | , online_at : JSPhoenix.TimexDateTime 51 | , nick : String 52 | } 53 | 54 | type alias RoomSyncState = 55 | List ( String, { metas : List RoomSyncMeta } ) -- Until Elm bug is fixed 'metas' must *always* be here with a List of records 56 | 57 | 58 | -- Same bug issue as above, you should be able to do: 59 | --type alias RoomSyncEvent = JSPhoenix.PresenceObject {} {online_at : JSPhoenix.TimexDateTime, nick : String} 60 | -- Instead you have to do this mess for each presence sync event type 61 | type alias RoomSyncEvent = 62 | { id : String 63 | , old : Maybe { String, List RoomSyncMeta } 64 | , new : List ( String, List RoomSyncMeta ) 65 | } 66 | 67 | -- Custom messages, whatever fits your Phoenix app: 68 | type alias RoomMessage = 69 | { room_id : Int 70 | , uid : Int 71 | , inserted_at : JSPhoenix.TimexDateTime 72 | , updated_at : JSPhoenix.TimexDateTime 73 | , nick : String 74 | , msg : String 75 | } 76 | 77 | type alias RoomMessages = 78 | { msgs : List RoomMessage } 79 | 80 | port onRoomConnect : (ChannelEventMsg {} Int -> msg) -> Sub msg 81 | port onRoomInfo : (ChannelEventMsg {} Int -> msg) -> Sub msg 82 | port onRoomMsgsInit : (ChannelEventMsg RoomMessages Int -> msg) -> Sub msg 83 | port onRoomMsgsAdd : (ChannelEventMsg RoomMessages Int -> msg) -> Sub msg 84 | port onRoomSyncState : (ChannelEventMsg RoomSyncState Int -> msg) -> Sub msg 85 | port onRoomSyncJoin : (ChannelEventMsg RoomSyncEvent Int -> msg) -> Sub msg 86 | port onRoomSyncLeave : (ChannelEventMsg RoomSyncEvent Int -> msg) -> Sub msg 87 | 88 | connect_room rid = 89 | JSPhoenix.connect 90 | { topic = room_id_to_topic rid 91 | , timeout_ms = Nothing -- Just 10000 -- Default value is 10000 if Nothing is used 92 | , chanCloseCB = Nothing 93 | , chanErrorCB = Nothing 94 | , syncState = Just { portName = "onRoomSyncState", cb_data = (int rid) } 95 | , syncJoin = Just { portName = "onRoomSyncJoin", cb_data = (int rid) } 96 | , syncLeave = Just { portName = "onRoomSyncLeave", cb_data = (int rid) } 97 | , joinData = null 98 | , joinEvents = 99 | [ { portName = "onRoomConnect", msgID = "ok", cb_data = (int rid) } 100 | ] 101 | , onPorts = 102 | [ { portName = "onRoomInfo", msgID = "room:info", cb_data = (int rid) } 103 | , { portName = "onRoomMsgsInit", msgID = "msgs:init", cb_data = (int rid) } 104 | , { portName = "onRoomMsgsAdd", msgID = "msgs:add", cb_data = (int rid) } 105 | ] 106 | } 107 | 108 | update : Msg -> Model -> ( Model, Cmd Msg ) 109 | update msg model = 110 | case msg of 111 | -- ... other messages 112 | 113 | MyConnectMessage rid -> 114 | ( model 115 | , connect_room rid -- You can use the JSPhoenix.connect like any normal command 116 | ) 117 | 118 | subscriptions : Model -> Sub Msg 119 | subscriptions model = 120 | Sub.batch -- Subscribe to your port events to get their messages 121 | [ onRoomConnect (\{ msg, cb_data } -> MyRoomConnectMsg msg cb_data) -- Example to show you the structure of the data 122 | , onRoomInfo MyRoomInfoMsg 123 | , onRoomMsgsInit MyRoomMsgsInitMsg 124 | , onRoomMsgsAdd MyRoomMsgsAddMsg 125 | , onRoomSyncState MyRoomSyncStateMsg 126 | , onRoomSyncJoin MyRoomSyncJoinMsg 127 | , onRoomSyncLeave MyRoomSyncLeaveMsg 128 | ] 129 | ``` 130 | -------------------------------------------------------------------------------- /elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "Elm Port wrapper around Phoenix Socket/Presence javascript library to allow compatibility with existing javascript websocket usage", 4 | "repository": "https://github.com/OvermindDL1/elm-jsphoenix", 5 | "license": "MIT", 6 | "source-directories": [ 7 | "src" 8 | ], 9 | "exposed-modules": [ 10 | "JSPhoenix" 11 | ], 12 | "dependencies": { 13 | "elm-lang/core": "4.0.0 <= v < 5.0.0", 14 | "justinmimbs/elm-date-extra": "2.0.0 <= v < 3.0.0", 15 | }, 16 | "elm-version": "0.17.0 <= v < 0.18.0" 17 | } 18 | -------------------------------------------------------------------------------- /js/setup_phoenix_socket_ports.js: -------------------------------------------------------------------------------- 1 | 2 | import {Presence} from "phoenix" 3 | 4 | const ps_debug = false 5 | 6 | const add_event_handler_to_channel = (app, channel, msgID, portName, cb_data) => { 7 | if(ps_debug) console.log("JSPhoenix:JS:add_event_handler_to_channel", channel, msgID, portName) 8 | channel.on(msgID, msg => { 9 | app.ports[portName].send({ 10 | topic: channel.topic, 11 | msgID: msgID, 12 | msg: msg, 13 | cb_data: cb_data 14 | }) 15 | }) 16 | } 17 | 18 | 19 | const set_on_receive = (app, channel, joined, portName, msgID, cb_data) => { 20 | if(ps_debug) console.log("JSPhoenix:JS:set_on_receive", joined, msgID, portName) 21 | joined.receive(msgID, msg => { 22 | app.ports[portName].send({ 23 | topic: channel.topic, 24 | msgID: msgID, 25 | msg: msg, 26 | cb_data: cb_data 27 | }) 28 | }) 29 | } 30 | 31 | 32 | const convert_presence_objects_dict_to_elm = (objects) => { 33 | let arr = []; 34 | 35 | for (var key in objects) { 36 | if (objects.hasOwnProperty(key)) { 37 | arr.push([key, objects[key]]); 38 | } 39 | } 40 | 41 | return arr 42 | } 43 | 44 | 45 | const setup_phoenix_socket_ports = (app, socket) => { 46 | 47 | let channels = {} 48 | let presences = {} 49 | 50 | if(app.ports.jsphoenix_connect != undefined) { 51 | app.ports.jsphoenix_connect.subscribe( 52 | ({topic, timeout_ms, chanCloseCB, chanErrorCB, syncState, syncJoin, syncLeave, joinData, joinEvents, onPorts}) => { 53 | if(ps_debug) console.log("JSPhoenix:JS:connect", topic, timeout_ms, chanCloseCB, chanErrorCB, syncState, syncJoin, syncLeave, joinData, joinEvents, onPorts) 54 | 55 | let channel = channels[topic] 56 | if(channel !== undefined) { 57 | console.log("Attempted joining a topic multiple times", topic) 58 | return 59 | } 60 | 61 | 62 | if(ps_debug) console.log("JSPhoenix:JS:Joining", topic, joinData || {}) 63 | channel = socket.channel(topic, joinData || {}) 64 | channels[topic] = channel 65 | 66 | 67 | const onSyncJoin = (id, current, newPres) => { 68 | if(ps_debug) console.log("JSPhoenix:JS:onSyncJoin", id, current, newPres, syncState, syncJoin, syncLeave) 69 | app.ports[syncJoin.portName].send({ 70 | topic: topic, 71 | msgID: "presence_join", 72 | msg: {id: id, old: convert_presence_objects_dict_to_elm(current), new: convert_presence_objects_dict_to_elm(newPres)}, 73 | cb_data: syncJoin.cb_data 74 | }) 75 | } 76 | const onSyncLeave = (id, current, newPres) => { 77 | if(ps_debug) console.log("JSPhoenix:JS:onSyncLeave", id, current, newPres, syncState, syncJoin, syncLeave) 78 | app.ports[syncLeave.portName].send({ 79 | topic: topic, 80 | msgID: "presence_leave", 81 | msg: {id: id, old: convert_presence_objects_dict_to_elm(current), new: convert_presence_objects_dict_to_elm(newPres)}, 82 | cb_data: syncLeave.cb_data 83 | }) 84 | } 85 | 86 | channel.on("presence_state", state => { 87 | if(ps_debug) console.log("JSPhoenix:JS:presence_state", state, syncState, syncJoin, syncLeave, presences[topic]) 88 | if(presences[topic] === undefined) presences[topic] = {} 89 | presences[topic] = Presence.syncState(presences[topic], state, onSyncJoin, onSyncLeave) 90 | if(syncState) app.ports[syncState.portName].send({ 91 | topic: topic, 92 | msgID: "presence_state", 93 | msg: convert_presence_objects_dict_to_elm(presences[topic]), 94 | cb_data: syncState.cb_data 95 | }) 96 | }) 97 | 98 | channel.on("presence_diff", state => { 99 | if(ps_debug) console.log("JSPhoenix:JS:presence_diff", state, syncState, syncJoin, syncLeave, presences[topic]) 100 | if(presences[topic] === undefined) presences[topic] = {} 101 | presences[topic] = Presence.syncDiff(presences[topic], state, onSyncJoin, onSyncLeave) 102 | if(syncState) app.ports[syncState.portName].send({ 103 | topic: topic, 104 | msgID: "presence_diff", 105 | msg: convert_presence_objects_dict_to_elm(presences[topic]), 106 | cb_data: syncState.cb_data 107 | }) 108 | }) 109 | 110 | 111 | onPorts.map(({msgID, portName, cb_data}) => { 112 | add_event_handler_to_channel(app, channel, msgID, portName, cb_data) 113 | }) 114 | 115 | 116 | const cleanup = (msg) => { 117 | channels[channel.topic] = undefined 118 | } 119 | channel.onClose(cleanup) 120 | 121 | if(chanCloseCB) channel.onClose(msg => { 122 | app.ports[chanCloseCB].send({topic: topic, msg: msg}) 123 | }) 124 | 125 | if(chanErrorCB) channel.onError(msg => { 126 | app.ports[chanErrorCB].send({topic:topic, msg: msg}) 127 | }) 128 | 129 | const timeout = timeout_ms || 10000 130 | let joined = channel.join(timeout) 131 | if(ps_debug) joined.receive('ok', (msg) => console.log("JSPhoenix:JS:Joined", channel, msg)) 132 | joinEvents.map(({portName, msgID, cb_data}) => { 133 | set_on_receive(app, channel, joined, portName, msgID, cb_data) 134 | }) 135 | 136 | if(ps_debug) console.log("JSPhoenix:JS:SentJoined", channel) 137 | }) 138 | 139 | app.ports.jsphoenix_disconnect.subscribe(({topic, chanLeavingCB}) => { 140 | if(ps_debug) console.log("JSPhoenix:JS:disconnect", topic, chanLeavingCB) 141 | let channel = channels[topic] 142 | if(channel === undefined) { 143 | console.log("Attempted disconnect a topic multiple times", topic) 144 | return 145 | } 146 | let leaving = channel.leave() 147 | if(chanLeavingCB) leaving.receive("ok", (msg) => { 148 | app.ports[chanLeavingCB].send(topic, msg) 149 | }) 150 | }) 151 | 152 | app.ports.jsphoenix_push.subscribe(({topic, mid, msg, pushEvents}) => { 153 | if(ps_debug) console.log("JSPhoenix:JS:push", topic, mid, msg) 154 | let channel = channels[topic] 155 | if(channel === undefined) { 156 | console.log("Attempted to push to an unconnected channel", topic, mid) 157 | return 158 | } 159 | let pushed = channel.push(mid, msg) 160 | pushEvents.map(({portName, msgID, cb_data}) => { 161 | set_on_receive(app, channel, pushed, portName, msgID, cb_data) 162 | }) 163 | }) 164 | } 165 | 166 | } 167 | 168 | export default setup_phoenix_socket_ports 169 | -------------------------------------------------------------------------------- /src/JSPhoenix.elm: -------------------------------------------------------------------------------- 1 | port module JSPhoenix exposing (..) 2 | 3 | import Json.Encode 4 | import Json.Decode 5 | import Date exposing (..) 6 | import Date exposing (Date) 7 | import Date.Extra as Date 8 | import Date.Extra.Facts exposing (monthFromMonthNumber) 9 | 10 | 11 | -- Phoenix channel message receiver handling 12 | 13 | 14 | type alias CallbackPortMsg = 15 | { portName : String 16 | , msgID : String 17 | , cb_data : Json.Encode.Value 18 | } 19 | 20 | 21 | type alias CallbackPortEvent = 22 | { portName : String 23 | , cb_data : Json.Encode.Value 24 | } 25 | 26 | 27 | type alias ChanExitCB msg = 28 | { topic : String 29 | , msg : msg 30 | } 31 | 32 | 33 | 34 | -- Connecting 35 | 36 | 37 | type alias Connect = 38 | { topic : String 39 | , timeout_ms : Maybe Int {- Just 10000 -- Default value -} 40 | , chanCloseCB : Maybe CallbackPortEvent 41 | , chanErrorCB : Maybe CallbackPortEvent 42 | , syncState : Maybe CallbackPortEvent 43 | , syncJoin : Maybe CallbackPortEvent 44 | , syncLeave : Maybe CallbackPortEvent 45 | , joinData : Json.Encode.Value 46 | , joinEvents : List CallbackPortMsg 47 | , onPorts : List CallbackPortMsg 48 | } 49 | 50 | 51 | port jsphoenix_connect : Connect -> Cmd msg 52 | 53 | 54 | connect : Connect -> Cmd msg 55 | connect connect_struct = 56 | jsphoenix_connect connect_struct 57 | 58 | 59 | 60 | -- Disconnecting 61 | 62 | 63 | type alias Disconnect = 64 | { topic : String 65 | , chanLeavingCB : Maybe String 66 | } 67 | 68 | 69 | port jsphoenix_disconnect : Disconnect -> Cmd msg 70 | 71 | 72 | disconnect : Disconnect -> Cmd msg 73 | disconnect disconnect_struct = 74 | jsphoenix_disconnect disconnect_struct 75 | 76 | 77 | 78 | -- Pushing 79 | 80 | 81 | type alias Push = 82 | { topic : String 83 | , mid : String 84 | , msg : Json.Encode.Value 85 | , pushEvents : List CallbackPortMsg 86 | } 87 | 88 | 89 | port jsphoenix_push : Push -> Cmd msg 90 | 91 | 92 | push : Push -> Cmd msg 93 | push push_struct = 94 | jsphoenix_push push_struct 95 | 96 | 97 | 98 | -- Receiving 99 | 100 | 101 | type alias ChannelEventMsg msg_type cb_type = 102 | { topic : String 103 | , msgID : String 104 | , msg : msg_type 105 | , cb_data : cb_type 106 | } 107 | 108 | 109 | type alias ChannelGenericEventMsg = 110 | ChannelEventMsg Json.Decode.Value Json.Decode.Value 111 | 112 | 113 | 114 | -- Phoenix.Presence 115 | 116 | 117 | type alias PresenceMeta metaUserType = 118 | { metaUserType 119 | | phx_ref : String 120 | } 121 | 122 | 123 | type alias PresenceMetas metaUserType = 124 | List (PresenceMeta metaUserType) 125 | 126 | 127 | type alias PresenceObject msgUserType metaUserType = 128 | { msgUserType 129 | | metas : PresenceMetas metaUserType 130 | } 131 | 132 | 133 | type alias PresenceObjects msgUserType metaUserType = 134 | List ( String, PresenceObject msgUserType metaUserType ) 135 | 136 | 137 | type alias PresenceDiff msgUserType metaUserType = 138 | { leaves : PresenceObject msgUserType metaUserType 139 | , joins : PresenceObject msgUserType metaUserType 140 | } 141 | 142 | 143 | type alias PresenceDiffChange msgUserType metaUserType = 144 | { id : String 145 | , old : PresenceObject msgUserType metaUserType 146 | , new : PresenceObject msgUserType metaUserType 147 | } 148 | 149 | 150 | 151 | -- Various Integration helpers 152 | 153 | 154 | type alias TimexDateTime = 155 | { year : Int 156 | , timezone : 157 | { until : String 158 | , offset_utc : Int 159 | , offset_std : Int 160 | , full_name : String 161 | , from : String 162 | , abbreviation : String 163 | } 164 | , second : Int 165 | , month : Int 166 | , minute : Int 167 | , millisecond : Int 168 | , hour : Int 169 | , day : Int 170 | , calendar : String 171 | } 172 | 173 | 174 | emptyTimexDateTime : TimexDateTime 175 | emptyTimexDateTime = 176 | { year = 0 177 | , timezone = 178 | { until = "" 179 | , offset_utc = 0 180 | , offset_std = 0 181 | , full_name = "" 182 | , from = "" 183 | , abbreviation = "" 184 | } 185 | , second = 0 186 | , month = 1 187 | , minute = 0 188 | , millisecond = 0 189 | , hour = 0 190 | , day = 0 191 | , calendar = "" 192 | } 193 | 194 | 195 | convertTimexDateToElmDate : TimexDateTime -> Date 196 | convertTimexDateToElmDate { year, month, day, hour, minute, second, millisecond, timezone } = 197 | Date.fromSpec 198 | (Date.offset <| timezone.offset_utc + timezone.offset_std) 199 | (Date.atTime hour minute second millisecond) 200 | (Date.calendarDate year (monthFromMonthNumber (month)) day) 201 | --------------------------------------------------------------------------------