@supabase/phoenix 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +22 -0
- package/README.md +122 -0
- package/assets/js/phoenix/ajax.js +116 -0
- package/assets/js/phoenix/channel.js +331 -0
- package/assets/js/phoenix/constants.js +35 -0
- package/assets/js/phoenix/index.js +212 -0
- package/assets/js/phoenix/longpoll.js +192 -0
- package/assets/js/phoenix/presence.js +208 -0
- package/assets/js/phoenix/push.js +134 -0
- package/assets/js/phoenix/serializer.js +133 -0
- package/assets/js/phoenix/socket.js +747 -0
- package/assets/js/phoenix/timer.js +48 -0
- package/assets/js/phoenix/types.js +184 -0
- package/assets/js/phoenix/utils.js +16 -0
- package/package.json +58 -0
- package/priv/static/favicon.ico +0 -0
- package/priv/static/phoenix-orange.png +0 -0
- package/priv/static/phoenix.cjs.js +1812 -0
- package/priv/static/phoenix.cjs.js.map +7 -0
- package/priv/static/phoenix.js +1834 -0
- package/priv/static/phoenix.min.js +2 -0
- package/priv/static/phoenix.mjs +1789 -0
- package/priv/static/phoenix.mjs.map +7 -0
- package/priv/static/phoenix.png +0 -0
- package/priv/static/types/ajax.d.ts +10 -0
- package/priv/static/types/ajax.d.ts.map +1 -0
- package/priv/static/types/channel.d.ts +167 -0
- package/priv/static/types/channel.d.ts.map +1 -0
- package/priv/static/types/constants.d.ts +36 -0
- package/priv/static/types/constants.d.ts.map +1 -0
- package/priv/static/types/index.d.ts +10 -0
- package/priv/static/types/index.d.ts.map +1 -0
- package/priv/static/types/longpoll.d.ts +29 -0
- package/priv/static/types/longpoll.d.ts.map +1 -0
- package/priv/static/types/presence.d.ts +107 -0
- package/priv/static/types/presence.d.ts.map +1 -0
- package/priv/static/types/push.d.ts +70 -0
- package/priv/static/types/push.d.ts.map +1 -0
- package/priv/static/types/serializer.d.ts +74 -0
- package/priv/static/types/serializer.d.ts.map +1 -0
- package/priv/static/types/socket.d.ts +284 -0
- package/priv/static/types/socket.d.ts.map +1 -0
- package/priv/static/types/timer.d.ts +36 -0
- package/priv/static/types/timer.d.ts.map +1 -0
- package/priv/static/types/types.d.ts +280 -0
- package/priv/static/types/types.d.ts.map +1 -0
- package/priv/static/types/utils.d.ts +2 -0
- package/priv/static/types/utils.d.ts.map +1 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phoenix Channels JavaScript client
|
|
3
|
+
*
|
|
4
|
+
* ## Socket Connection
|
|
5
|
+
*
|
|
6
|
+
* A single connection is established to the server and
|
|
7
|
+
* channels are multiplexed over the connection.
|
|
8
|
+
* Connect to the server using the `Socket` class:
|
|
9
|
+
*
|
|
10
|
+
* ```javascript
|
|
11
|
+
* let socket = new Socket("/socket", {params: {userToken: "123"}})
|
|
12
|
+
* socket.connect()
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* The `Socket` constructor takes the mount point of the socket,
|
|
16
|
+
* the authentication params, as well as options that can be found in
|
|
17
|
+
* the Socket docs, such as configuring the `LongPoll` transport, and
|
|
18
|
+
* heartbeat.
|
|
19
|
+
*
|
|
20
|
+
* ## Channels
|
|
21
|
+
*
|
|
22
|
+
* Channels are isolated, concurrent processes on the server that
|
|
23
|
+
* subscribe to topics and broker events between the client and server.
|
|
24
|
+
* To join a channel, you must provide the topic, and channel params for
|
|
25
|
+
* authorization. Here's an example chat room example where `"new_msg"`
|
|
26
|
+
* events are listened for, messages are pushed to the server, and
|
|
27
|
+
* the channel is joined with ok/error/timeout matches:
|
|
28
|
+
*
|
|
29
|
+
* ```
|
|
30
|
+
* let channel = socket.channel("room:123", {token: roomToken})
|
|
31
|
+
* channel.on("new_msg", msg => console.log("Got message", msg) )
|
|
32
|
+
* $input.onEnter( e => {
|
|
33
|
+
* channel.push("new_msg", {body: e.target.val}, 10000)
|
|
34
|
+
* .receive("ok", (msg) => console.log("created message", msg) )
|
|
35
|
+
* .receive("error", (reasons) => console.log("create failed", reasons) )
|
|
36
|
+
* .receive("timeout", () => console.log("Networking issue...") )
|
|
37
|
+
* })
|
|
38
|
+
*
|
|
39
|
+
* channel.join()
|
|
40
|
+
* .receive("ok", ({messages}) => console.log("catching up", messages) )
|
|
41
|
+
* .receive("error", ({reason}) => console.log("failed join", reason) )
|
|
42
|
+
* .receive("timeout", () => console.log("Networking issue. Still waiting..."))
|
|
43
|
+
*```
|
|
44
|
+
*
|
|
45
|
+
* ## Joining
|
|
46
|
+
*
|
|
47
|
+
* Creating a channel with `socket.channel(topic, params)`, binds the params to
|
|
48
|
+
* `channel.params`, which are sent up on `channel.join()`.
|
|
49
|
+
* Subsequent rejoins will send up the modified params for
|
|
50
|
+
* updating authorization params, or passing up last_message_id information.
|
|
51
|
+
* Successful joins receive an "ok" status, while unsuccessful joins
|
|
52
|
+
* receive "error".
|
|
53
|
+
*
|
|
54
|
+
* With the default serializers and WebSocket transport, JSON text frames are
|
|
55
|
+
* used for pushing a JSON object literal. If an `ArrayBuffer` instance is provided,
|
|
56
|
+
* binary encoding will be used and the message will be sent with the binary
|
|
57
|
+
* opcode.
|
|
58
|
+
*
|
|
59
|
+
* *Note*: binary messages are only supported on the WebSocket transport.
|
|
60
|
+
*
|
|
61
|
+
* ## Duplicate Join Subscriptions
|
|
62
|
+
*
|
|
63
|
+
* While the client may join any number of topics on any number of channels,
|
|
64
|
+
* the client may only hold a single subscription for each unique topic at any
|
|
65
|
+
* given time. When attempting to create a duplicate subscription,
|
|
66
|
+
* the server will close the existing channel, log a warning, and
|
|
67
|
+
* spawn a new channel for the topic. The client will have their
|
|
68
|
+
* `channel.onClose` callbacks fired for the existing channel, and the new
|
|
69
|
+
* channel join will have its receive hooks processed as normal.
|
|
70
|
+
*
|
|
71
|
+
* ## Pushing Messages
|
|
72
|
+
*
|
|
73
|
+
* From the previous example, we can see that pushing messages to the server
|
|
74
|
+
* can be done with `channel.push(eventName, payload)` and we can optionally
|
|
75
|
+
* receive responses from the push. Additionally, we can use
|
|
76
|
+
* `receive("timeout", callback)` to abort waiting for our other `receive` hooks
|
|
77
|
+
* and take action after some period of waiting. The default timeout is 10000ms.
|
|
78
|
+
*
|
|
79
|
+
*
|
|
80
|
+
* ## Socket Hooks
|
|
81
|
+
*
|
|
82
|
+
* Lifecycle events of the multiplexed connection can be hooked into via
|
|
83
|
+
* `socket.onError()` and `socket.onClose()` events, ie:
|
|
84
|
+
*
|
|
85
|
+
* ```
|
|
86
|
+
* socket.onError( () => console.log("there was an error with the connection!") )
|
|
87
|
+
* socket.onClose( () => console.log("the connection dropped") )
|
|
88
|
+
* ```
|
|
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
|
+
* ```
|
|
97
|
+
* channel.onError( () => console.log("there was an error!") )
|
|
98
|
+
* channel.onClose( () => console.log("the channel has gone away gracefully") )
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* ### onError hooks
|
|
102
|
+
*
|
|
103
|
+
* `onError` hooks are invoked if the socket connection drops, or the channel
|
|
104
|
+
* crashes on the server. In either case, a channel rejoin is attempted
|
|
105
|
+
* automatically in an exponential backoff manner.
|
|
106
|
+
*
|
|
107
|
+
* ### onClose hooks
|
|
108
|
+
*
|
|
109
|
+
* `onClose` hooks are invoked only in two cases. 1) the channel explicitly
|
|
110
|
+
* closed on the server, or 2). The client explicitly closed, by calling
|
|
111
|
+
* `channel.leave()`
|
|
112
|
+
*
|
|
113
|
+
*
|
|
114
|
+
* ## Presence
|
|
115
|
+
*
|
|
116
|
+
* The `Presence` object provides features for syncing presence information
|
|
117
|
+
* from the server with the client and handling presences joining and leaving.
|
|
118
|
+
*
|
|
119
|
+
* ### Syncing state from the server
|
|
120
|
+
*
|
|
121
|
+
* To sync presence state from the server, first instantiate an object and
|
|
122
|
+
* pass your channel in to track lifecycle events:
|
|
123
|
+
*
|
|
124
|
+
* ```
|
|
125
|
+
* let channel = socket.channel("some:topic")
|
|
126
|
+
* let presence = new Presence(channel)
|
|
127
|
+
* ```
|
|
128
|
+
*
|
|
129
|
+
* Next, use the `presence.onSync` callback to react to state changes
|
|
130
|
+
* from the server. For example, to render the list of users every time
|
|
131
|
+
* the list changes, you could write:
|
|
132
|
+
*
|
|
133
|
+
* ```
|
|
134
|
+
* presence.onSync(() => {
|
|
135
|
+
* myRenderUsersFunction(presence.list())
|
|
136
|
+
* })
|
|
137
|
+
* ```
|
|
138
|
+
*
|
|
139
|
+
* ### Listing Presences
|
|
140
|
+
*
|
|
141
|
+
* `presence.list` is used to return a list of presence information
|
|
142
|
+
* based on the local state of metadata. By default, all presence
|
|
143
|
+
* metadata is returned, but a `listBy` function can be supplied to
|
|
144
|
+
* allow the client to select which metadata to use for a given presence.
|
|
145
|
+
* For example, you may have a user online from different devices with
|
|
146
|
+
* a metadata status of "online", but they have set themselves to "away"
|
|
147
|
+
* on another device. In this case, the app may choose to use the "away"
|
|
148
|
+
* status for what appears on the UI. The example below defines a `listBy`
|
|
149
|
+
* function which prioritizes the first metadata which was registered for
|
|
150
|
+
* each user. This could be the first tab they opened, or the first device
|
|
151
|
+
* they came online from:
|
|
152
|
+
*
|
|
153
|
+
* ```
|
|
154
|
+
* let listBy = (id, {metas: [first, ...rest]}) => {
|
|
155
|
+
* first.count = rest.length + 1 // count of this user's presences
|
|
156
|
+
* first.id = id
|
|
157
|
+
* return first
|
|
158
|
+
* }
|
|
159
|
+
* let onlineUsers = presence.list(listBy)
|
|
160
|
+
* ```
|
|
161
|
+
*
|
|
162
|
+
* ### Handling individual presence join and leave events
|
|
163
|
+
*
|
|
164
|
+
* The `presence.onJoin` and `presence.onLeave` callbacks can be used to
|
|
165
|
+
* react to individual presences joining and leaving the app. For example:
|
|
166
|
+
*
|
|
167
|
+
* ```
|
|
168
|
+
* let presence = new Presence(channel)
|
|
169
|
+
*
|
|
170
|
+
* // detect if user has joined for the 1st time or from another tab/device
|
|
171
|
+
* presence.onJoin((id, current, newPres) => {
|
|
172
|
+
* if(!current){
|
|
173
|
+
* console.log("user has entered for the first time", newPres)
|
|
174
|
+
* } else {
|
|
175
|
+
* console.log("user additional presence", newPres)
|
|
176
|
+
* }
|
|
177
|
+
* })
|
|
178
|
+
*
|
|
179
|
+
* // detect if user has left from all tabs/devices, or is still present
|
|
180
|
+
* presence.onLeave((id, current, leftPres) => {
|
|
181
|
+
* if(current.metas.length === 0){
|
|
182
|
+
* console.log("user has left from all devices", leftPres)
|
|
183
|
+
* } else {
|
|
184
|
+
* console.log("user left from a device", leftPres)
|
|
185
|
+
* }
|
|
186
|
+
* })
|
|
187
|
+
* // receive presence data from server
|
|
188
|
+
* presence.onSync(() => {
|
|
189
|
+
* displayUsers(presence.list())
|
|
190
|
+
* })
|
|
191
|
+
* ```
|
|
192
|
+
* @module phoenix
|
|
193
|
+
*/
|
|
194
|
+
|
|
195
|
+
import Channel from "./channel"
|
|
196
|
+
import LongPoll from "./longpoll"
|
|
197
|
+
import Presence from "./presence"
|
|
198
|
+
import Serializer from "./serializer"
|
|
199
|
+
import Socket from "./socket"
|
|
200
|
+
import Timer from "./timer"
|
|
201
|
+
import Push from "./push"
|
|
202
|
+
export * from "./types"
|
|
203
|
+
|
|
204
|
+
export {
|
|
205
|
+
Channel,
|
|
206
|
+
LongPoll,
|
|
207
|
+
Presence,
|
|
208
|
+
Push,
|
|
209
|
+
Serializer,
|
|
210
|
+
Socket,
|
|
211
|
+
Timer
|
|
212
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SOCKET_STATES,
|
|
3
|
+
TRANSPORTS,
|
|
4
|
+
AUTH_TOKEN_PREFIX
|
|
5
|
+
} from "./constants"
|
|
6
|
+
|
|
7
|
+
import Ajax from "./ajax"
|
|
8
|
+
|
|
9
|
+
let arrayBufferToBase64 = (buffer) => {
|
|
10
|
+
let binary = ""
|
|
11
|
+
let bytes = new Uint8Array(buffer)
|
|
12
|
+
let len = bytes.byteLength
|
|
13
|
+
for(let i = 0; i < len; i++){ binary += String.fromCharCode(bytes[i]) }
|
|
14
|
+
return btoa(binary)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default class LongPoll {
|
|
18
|
+
|
|
19
|
+
constructor(endPoint, protocols){
|
|
20
|
+
// we only support subprotocols for authToken
|
|
21
|
+
// ["phoenix", "base64url.bearer.phx.BASE64_ENCODED_TOKEN"]
|
|
22
|
+
if(protocols && protocols.length === 2 && protocols[1].startsWith(AUTH_TOKEN_PREFIX)){
|
|
23
|
+
this.authToken = atob(protocols[1].slice(AUTH_TOKEN_PREFIX.length))
|
|
24
|
+
}
|
|
25
|
+
this.endPoint = null
|
|
26
|
+
this.token = null
|
|
27
|
+
this.skipHeartbeat = true
|
|
28
|
+
this.reqs = new Set()
|
|
29
|
+
this.awaitingBatchAck = false
|
|
30
|
+
this.currentBatch = null
|
|
31
|
+
this.currentBatchTimer = null
|
|
32
|
+
this.batchBuffer = []
|
|
33
|
+
this.onopen = function (){ } // noop
|
|
34
|
+
this.onerror = function (){ } // noop
|
|
35
|
+
this.onmessage = function (){ } // noop
|
|
36
|
+
this.onclose = function (){ } // noop
|
|
37
|
+
this.pollEndpoint = this.normalizeEndpoint(endPoint)
|
|
38
|
+
this.readyState = SOCKET_STATES.connecting
|
|
39
|
+
// we must wait for the caller to finish setting up our callbacks and timeout properties
|
|
40
|
+
setTimeout(() => this.poll(), 0)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
normalizeEndpoint(endPoint){
|
|
44
|
+
return (endPoint
|
|
45
|
+
.replace("ws://", "http://")
|
|
46
|
+
.replace("wss://", "https://")
|
|
47
|
+
.replace(new RegExp("(.*)\/" + TRANSPORTS.websocket), "$1/" + TRANSPORTS.longpoll))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
endpointURL(){
|
|
51
|
+
return Ajax.appendParams(this.pollEndpoint, {token: this.token})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
closeAndRetry(code, reason, wasClean){
|
|
55
|
+
this.close(code, reason, wasClean)
|
|
56
|
+
this.readyState = SOCKET_STATES.connecting
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
ontimeout(){
|
|
60
|
+
this.onerror("timeout")
|
|
61
|
+
this.closeAndRetry(1005, "timeout", false)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
isActive(){ return this.readyState === SOCKET_STATES.open || this.readyState === SOCKET_STATES.connecting }
|
|
65
|
+
|
|
66
|
+
poll(){
|
|
67
|
+
const headers = {"Accept": "application/json"}
|
|
68
|
+
if(this.authToken){
|
|
69
|
+
headers["X-Phoenix-AuthToken"] = this.authToken
|
|
70
|
+
}
|
|
71
|
+
this.ajax("GET", headers, null, () => this.ontimeout(), resp => {
|
|
72
|
+
if(resp){
|
|
73
|
+
var {status, token, messages} = resp
|
|
74
|
+
if(status === 410 && this.token !== null){
|
|
75
|
+
// In case we already have a token, this means that our existing session
|
|
76
|
+
// is gone. We fail so that the client rejoins its channels.
|
|
77
|
+
this.onerror(410)
|
|
78
|
+
this.closeAndRetry(3410, "session_gone", false)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
this.token = token
|
|
82
|
+
} else {
|
|
83
|
+
status = 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
switch(status){
|
|
87
|
+
case 200:
|
|
88
|
+
messages.forEach(msg => {
|
|
89
|
+
// Tasks are what things like event handlers, setTimeout callbacks,
|
|
90
|
+
// promise resolves and more are run within.
|
|
91
|
+
// In modern browsers, there are two different kinds of tasks,
|
|
92
|
+
// microtasks and macrotasks.
|
|
93
|
+
// Microtasks are mainly used for Promises, while macrotasks are
|
|
94
|
+
// used for everything else.
|
|
95
|
+
// Microtasks always have priority over macrotasks. If the JS engine
|
|
96
|
+
// is looking for a task to run, it will always try to empty the
|
|
97
|
+
// microtask queue before attempting to run anything from the
|
|
98
|
+
// macrotask queue.
|
|
99
|
+
//
|
|
100
|
+
// For the WebSocket transport, messages always arrive in their own
|
|
101
|
+
// event. This means that if any promises are resolved from within,
|
|
102
|
+
// their callbacks will always finish execution by the time the
|
|
103
|
+
// next message event handler is run.
|
|
104
|
+
//
|
|
105
|
+
// In order to emulate this behaviour, we need to make sure each
|
|
106
|
+
// onmessage handler is run within its own macrotask.
|
|
107
|
+
setTimeout(() => this.onmessage({data: msg}), 0)
|
|
108
|
+
})
|
|
109
|
+
this.poll()
|
|
110
|
+
break
|
|
111
|
+
case 204:
|
|
112
|
+
this.poll()
|
|
113
|
+
break
|
|
114
|
+
case 410:
|
|
115
|
+
this.readyState = SOCKET_STATES.open
|
|
116
|
+
this.onopen({})
|
|
117
|
+
this.poll()
|
|
118
|
+
break
|
|
119
|
+
case 403:
|
|
120
|
+
this.onerror(403)
|
|
121
|
+
this.close(1008, "forbidden", false)
|
|
122
|
+
break
|
|
123
|
+
case 0:
|
|
124
|
+
case 500:
|
|
125
|
+
this.onerror(500)
|
|
126
|
+
this.closeAndRetry(1011, "internal server error", 500)
|
|
127
|
+
break
|
|
128
|
+
default: throw new Error(`unhandled poll status ${status}`)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// we collect all pushes within the current event loop by
|
|
134
|
+
// setTimeout 0, which optimizes back-to-back procedural
|
|
135
|
+
// pushes against an empty buffer
|
|
136
|
+
|
|
137
|
+
send(body){
|
|
138
|
+
if(typeof(body) !== "string"){ body = arrayBufferToBase64(body) }
|
|
139
|
+
if(this.currentBatch){
|
|
140
|
+
this.currentBatch.push(body)
|
|
141
|
+
} else if(this.awaitingBatchAck){
|
|
142
|
+
this.batchBuffer.push(body)
|
|
143
|
+
} else {
|
|
144
|
+
this.currentBatch = [body]
|
|
145
|
+
this.currentBatchTimer = setTimeout(() => {
|
|
146
|
+
this.batchSend(this.currentBatch)
|
|
147
|
+
this.currentBatch = null
|
|
148
|
+
}, 0)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
batchSend(messages){
|
|
153
|
+
this.awaitingBatchAck = true
|
|
154
|
+
this.ajax("POST", {"Content-Type": "application/x-ndjson"}, messages.join("\n"), () => this.onerror("timeout"), resp => {
|
|
155
|
+
this.awaitingBatchAck = false
|
|
156
|
+
if(!resp || resp.status !== 200){
|
|
157
|
+
this.onerror(resp && resp.status)
|
|
158
|
+
this.closeAndRetry(1011, "internal server error", false)
|
|
159
|
+
} else if(this.batchBuffer.length > 0){
|
|
160
|
+
this.batchSend(this.batchBuffer)
|
|
161
|
+
this.batchBuffer = []
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
close(code, reason, wasClean){
|
|
167
|
+
for(let req of this.reqs){ req.abort() }
|
|
168
|
+
this.readyState = SOCKET_STATES.closed
|
|
169
|
+
let opts = Object.assign({code: 1000, reason: undefined, wasClean: true}, {code, reason, wasClean})
|
|
170
|
+
this.batchBuffer = []
|
|
171
|
+
clearTimeout(this.currentBatchTimer)
|
|
172
|
+
this.currentBatchTimer = null
|
|
173
|
+
if(typeof(CloseEvent) !== "undefined"){
|
|
174
|
+
this.onclose(new CloseEvent("close", opts))
|
|
175
|
+
} else {
|
|
176
|
+
this.onclose(opts)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
ajax(method, headers, body, onCallerTimeout, callback){
|
|
181
|
+
let req
|
|
182
|
+
let ontimeout = () => {
|
|
183
|
+
this.reqs.delete(req)
|
|
184
|
+
onCallerTimeout()
|
|
185
|
+
}
|
|
186
|
+
req = Ajax.request(method, this.endpointURL(), headers, body, this.timeout, ontimeout, resp => {
|
|
187
|
+
this.reqs.delete(req)
|
|
188
|
+
if(this.isActive()){ callback(resp) }
|
|
189
|
+
})
|
|
190
|
+
this.reqs.add(req)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import Channel from "./channel"
|
|
3
|
+
* @import { PresenceEvents, PresenceOnJoin, PresenceOnLeave, PresenceOnSync, PresenceState, PresenceDiff, PresenceOptions } from "./types"
|
|
4
|
+
*/
|
|
5
|
+
export default class Presence {
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Initializes the Presence
|
|
9
|
+
* @param {Channel} channel - The Channel
|
|
10
|
+
* @param {PresenceOptions} [opts] - The options, for example `{events: {state: "state", diff: "diff"}}`
|
|
11
|
+
*/
|
|
12
|
+
constructor(channel, opts = {}){
|
|
13
|
+
let events = opts.events || /** @type {PresenceEvents} */ ({state: "presence_state", diff: "presence_diff"})
|
|
14
|
+
/** @type{Record<string, PresenceState>} */
|
|
15
|
+
this.state = {}
|
|
16
|
+
/** @type{PresenceDiff[]} */
|
|
17
|
+
this.pendingDiffs = []
|
|
18
|
+
/** @type{Channel} */
|
|
19
|
+
this.channel = channel
|
|
20
|
+
/** @type{?number} */
|
|
21
|
+
this.joinRef = null
|
|
22
|
+
/** @type{({ onJoin: PresenceOnJoin; onLeave: PresenceOnLeave; onSync: PresenceOnSync })} */
|
|
23
|
+
this.caller = {
|
|
24
|
+
onJoin: function (){ },
|
|
25
|
+
onLeave: function (){ },
|
|
26
|
+
onSync: function (){ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
this.channel.on(events.state, newState => {
|
|
30
|
+
let {onJoin, onLeave, onSync} = this.caller
|
|
31
|
+
|
|
32
|
+
this.joinRef = this.channel.joinRef()
|
|
33
|
+
this.state = Presence.syncState(this.state, newState, onJoin, onLeave)
|
|
34
|
+
|
|
35
|
+
this.pendingDiffs.forEach(diff => {
|
|
36
|
+
this.state = Presence.syncDiff(this.state, diff, onJoin, onLeave)
|
|
37
|
+
})
|
|
38
|
+
this.pendingDiffs = []
|
|
39
|
+
onSync()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
this.channel.on(events.diff, diff => {
|
|
43
|
+
let {onJoin, onLeave, onSync} = this.caller
|
|
44
|
+
|
|
45
|
+
if(this.inPendingSyncState()){
|
|
46
|
+
this.pendingDiffs.push(diff)
|
|
47
|
+
} else {
|
|
48
|
+
this.state = Presence.syncDiff(this.state, diff, onJoin, onLeave)
|
|
49
|
+
onSync()
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {PresenceOnJoin} callback
|
|
56
|
+
*/
|
|
57
|
+
onJoin(callback){ this.caller.onJoin = callback }
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {PresenceOnLeave} callback
|
|
61
|
+
*/
|
|
62
|
+
onLeave(callback){ this.caller.onLeave = callback }
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {PresenceOnSync} callback
|
|
66
|
+
*/
|
|
67
|
+
onSync(callback){ this.caller.onSync = callback }
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Returns the array of presences, with selected metadata.
|
|
71
|
+
*
|
|
72
|
+
* @template [T=PresenceState]
|
|
73
|
+
* @param {((key: string, obj: PresenceState) => T)} [by]
|
|
74
|
+
*
|
|
75
|
+
* @returns {T[]}
|
|
76
|
+
*/
|
|
77
|
+
list(by){ return Presence.list(this.state, by) }
|
|
78
|
+
|
|
79
|
+
inPendingSyncState(){
|
|
80
|
+
return !this.joinRef || (this.joinRef !== this.channel.joinRef())
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// lower-level public static API
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Used to sync the list of presences on the server
|
|
87
|
+
* with the client's state. An optional `onJoin` and `onLeave` callback can
|
|
88
|
+
* be provided to react to changes in the client's local presences across
|
|
89
|
+
* disconnects and reconnects with the server.
|
|
90
|
+
*
|
|
91
|
+
* @param {Record<string, PresenceState>} currentState
|
|
92
|
+
* @param {Record<string, PresenceState>} newState
|
|
93
|
+
* @param {PresenceOnJoin} onJoin
|
|
94
|
+
* @param {PresenceOnLeave} onLeave
|
|
95
|
+
*
|
|
96
|
+
* @returns {Record<string, PresenceState>}
|
|
97
|
+
*/
|
|
98
|
+
static syncState(currentState, newState, onJoin, onLeave){
|
|
99
|
+
let state = this.clone(currentState)
|
|
100
|
+
let joins = {}
|
|
101
|
+
let leaves = {}
|
|
102
|
+
|
|
103
|
+
this.map(state, (key, presence) => {
|
|
104
|
+
if(!newState[key]){
|
|
105
|
+
leaves[key] = presence
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
this.map(newState, (key, newPresence) => {
|
|
109
|
+
let currentPresence = state[key]
|
|
110
|
+
if(currentPresence){
|
|
111
|
+
let newRefs = newPresence.metas.map(m => m.phx_ref)
|
|
112
|
+
let curRefs = currentPresence.metas.map(m => m.phx_ref)
|
|
113
|
+
let joinedMetas = newPresence.metas.filter(m => curRefs.indexOf(m.phx_ref) < 0)
|
|
114
|
+
let leftMetas = currentPresence.metas.filter(m => newRefs.indexOf(m.phx_ref) < 0)
|
|
115
|
+
if(joinedMetas.length > 0){
|
|
116
|
+
joins[key] = newPresence
|
|
117
|
+
joins[key].metas = joinedMetas
|
|
118
|
+
}
|
|
119
|
+
if(leftMetas.length > 0){
|
|
120
|
+
leaves[key] = this.clone(currentPresence)
|
|
121
|
+
leaves[key].metas = leftMetas
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
joins[key] = newPresence
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
return this.syncDiff(state, {joins: joins, leaves: leaves}, onJoin, onLeave)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
*
|
|
132
|
+
* Used to sync a diff of presence join and leave
|
|
133
|
+
* events from the server, as they happen. Like `syncState`, `syncDiff`
|
|
134
|
+
* accepts optional `onJoin` and `onLeave` callbacks to react to a user
|
|
135
|
+
* joining or leaving from a device.
|
|
136
|
+
*
|
|
137
|
+
* @param {Record<string, PresenceState>} state
|
|
138
|
+
* @param {PresenceDiff} diff
|
|
139
|
+
* @param {PresenceOnJoin} onJoin
|
|
140
|
+
* @param {PresenceOnLeave} onLeave
|
|
141
|
+
*
|
|
142
|
+
* @returns {Record<string, PresenceState>}
|
|
143
|
+
*/
|
|
144
|
+
static syncDiff(state, diff, onJoin, onLeave){
|
|
145
|
+
let {joins, leaves} = this.clone(diff)
|
|
146
|
+
if(!onJoin){ onJoin = function (){ } }
|
|
147
|
+
if(!onLeave){ onLeave = function (){ } }
|
|
148
|
+
|
|
149
|
+
this.map(joins, (key, newPresence) => {
|
|
150
|
+
let currentPresence = state[key]
|
|
151
|
+
state[key] = this.clone(newPresence)
|
|
152
|
+
if(currentPresence){
|
|
153
|
+
let joinedRefs = state[key].metas.map(m => m.phx_ref)
|
|
154
|
+
let curMetas = currentPresence.metas.filter(m => joinedRefs.indexOf(m.phx_ref) < 0)
|
|
155
|
+
state[key].metas.unshift(...curMetas)
|
|
156
|
+
}
|
|
157
|
+
onJoin(key, currentPresence, newPresence)
|
|
158
|
+
})
|
|
159
|
+
this.map(leaves, (key, leftPresence) => {
|
|
160
|
+
let currentPresence = state[key]
|
|
161
|
+
if(!currentPresence){ return }
|
|
162
|
+
let refsToRemove = leftPresence.metas.map(m => m.phx_ref)
|
|
163
|
+
currentPresence.metas = currentPresence.metas.filter(p => {
|
|
164
|
+
return refsToRemove.indexOf(p.phx_ref) < 0
|
|
165
|
+
})
|
|
166
|
+
onLeave(key, currentPresence, leftPresence)
|
|
167
|
+
if(currentPresence.metas.length === 0){
|
|
168
|
+
delete state[key]
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
return state
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Returns the array of presences, with selected metadata.
|
|
176
|
+
*
|
|
177
|
+
* @template [T=PresenceState]
|
|
178
|
+
* @param {Record<string, PresenceState>} presences
|
|
179
|
+
* @param {((key: string, obj: PresenceState) => T)} [chooser]
|
|
180
|
+
*
|
|
181
|
+
* @returns {T[]}
|
|
182
|
+
*/
|
|
183
|
+
static list(presences, chooser){
|
|
184
|
+
if(!chooser){ chooser = function (key, pres){ return pres } }
|
|
185
|
+
|
|
186
|
+
return this.map(presences, (key, presence) => {
|
|
187
|
+
return chooser(key, presence)
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// private
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @template T
|
|
195
|
+
* @param {Record<string, PresenceState>} obj
|
|
196
|
+
* @param {(key: string, obj: PresenceState) => T} func
|
|
197
|
+
*/
|
|
198
|
+
static map(obj, func){
|
|
199
|
+
return Object.getOwnPropertyNames(obj).map(key => func(key, obj[key]))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @template T
|
|
204
|
+
* @param {T} obj
|
|
205
|
+
* @returns {T}
|
|
206
|
+
*/
|
|
207
|
+
static clone(obj){ return JSON.parse(JSON.stringify(obj)) }
|
|
208
|
+
}
|