@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.
Files changed (49) hide show
  1. package/LICENSE.md +22 -0
  2. package/README.md +122 -0
  3. package/assets/js/phoenix/ajax.js +116 -0
  4. package/assets/js/phoenix/channel.js +331 -0
  5. package/assets/js/phoenix/constants.js +35 -0
  6. package/assets/js/phoenix/index.js +212 -0
  7. package/assets/js/phoenix/longpoll.js +192 -0
  8. package/assets/js/phoenix/presence.js +208 -0
  9. package/assets/js/phoenix/push.js +134 -0
  10. package/assets/js/phoenix/serializer.js +133 -0
  11. package/assets/js/phoenix/socket.js +747 -0
  12. package/assets/js/phoenix/timer.js +48 -0
  13. package/assets/js/phoenix/types.js +184 -0
  14. package/assets/js/phoenix/utils.js +16 -0
  15. package/package.json +58 -0
  16. package/priv/static/favicon.ico +0 -0
  17. package/priv/static/phoenix-orange.png +0 -0
  18. package/priv/static/phoenix.cjs.js +1812 -0
  19. package/priv/static/phoenix.cjs.js.map +7 -0
  20. package/priv/static/phoenix.js +1834 -0
  21. package/priv/static/phoenix.min.js +2 -0
  22. package/priv/static/phoenix.mjs +1789 -0
  23. package/priv/static/phoenix.mjs.map +7 -0
  24. package/priv/static/phoenix.png +0 -0
  25. package/priv/static/types/ajax.d.ts +10 -0
  26. package/priv/static/types/ajax.d.ts.map +1 -0
  27. package/priv/static/types/channel.d.ts +167 -0
  28. package/priv/static/types/channel.d.ts.map +1 -0
  29. package/priv/static/types/constants.d.ts +36 -0
  30. package/priv/static/types/constants.d.ts.map +1 -0
  31. package/priv/static/types/index.d.ts +10 -0
  32. package/priv/static/types/index.d.ts.map +1 -0
  33. package/priv/static/types/longpoll.d.ts +29 -0
  34. package/priv/static/types/longpoll.d.ts.map +1 -0
  35. package/priv/static/types/presence.d.ts +107 -0
  36. package/priv/static/types/presence.d.ts.map +1 -0
  37. package/priv/static/types/push.d.ts +70 -0
  38. package/priv/static/types/push.d.ts.map +1 -0
  39. package/priv/static/types/serializer.d.ts +74 -0
  40. package/priv/static/types/serializer.d.ts.map +1 -0
  41. package/priv/static/types/socket.d.ts +284 -0
  42. package/priv/static/types/socket.d.ts.map +1 -0
  43. package/priv/static/types/timer.d.ts +36 -0
  44. package/priv/static/types/timer.d.ts.map +1 -0
  45. package/priv/static/types/types.d.ts +280 -0
  46. package/priv/static/types/types.d.ts.map +1 -0
  47. package/priv/static/types/utils.d.ts +2 -0
  48. package/priv/static/types/utils.d.ts.map +1 -0
  49. 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
+ }