This commit is contained in:
MarcWieland
2026-07-26 23:57:23 +02:00
commit 8b613ec0bc
6300 changed files with 1257646 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
import {
global,
XHR_STATES
} from "./constants"
export default class Ajax {
static request(method, endPoint, headers, body, timeout, ontimeout, callback){
if(global.XDomainRequest){
let req = new global.XDomainRequest() // IE8, IE9
return this.xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback)
} else if(global.XMLHttpRequest){
let req = new global.XMLHttpRequest() // IE7+, Firefox, Chrome, Opera, Safari
return this.xhrRequest(req, method, endPoint, headers, body, timeout, ontimeout, callback)
} else if(global.fetch && global.AbortController){
// Fetch with AbortController for modern browsers
return this.fetchRequest(method, endPoint, headers, body, timeout, ontimeout, callback)
} else {
throw new Error("No suitable XMLHttpRequest implementation found")
}
}
static fetchRequest(method, endPoint, headers, body, timeout, ontimeout, callback){
let options = {
method,
headers,
body,
}
let controller = null
if(timeout){
controller = new AbortController()
const _timeoutId = setTimeout(() => controller.abort(), timeout)
options.signal = controller.signal
}
global.fetch(endPoint, options)
.then(response => response.text())
.then(data => this.parseJSON(data))
.then(data => callback && callback(data))
.catch(err => {
if(err.name === "AbortError" && ontimeout){
ontimeout()
} else {
callback && callback(null)
}
})
return controller
}
static xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback){
req.timeout = timeout
req.open(method, endPoint)
req.onload = () => {
let response = this.parseJSON(req.responseText)
callback && callback(response)
}
if(ontimeout){ req.ontimeout = ontimeout }
// Work around bug in IE9 that requires an attached onprogress handler
req.onprogress = () => { }
req.send(body)
return req
}
static xhrRequest(req, method, endPoint, headers, body, timeout, ontimeout, callback){
req.open(method, endPoint, true)
req.timeout = timeout
for(let [key, value] of Object.entries(headers)){
req.setRequestHeader(key, value)
}
req.onerror = () => callback && callback(null)
req.onreadystatechange = () => {
if(req.readyState === XHR_STATES.complete && callback){
let response = this.parseJSON(req.responseText)
callback(response)
}
}
if(ontimeout){ req.ontimeout = ontimeout }
req.send(body)
return req
}
static parseJSON(resp){
if(!resp || resp === ""){ return null }
try {
return JSON.parse(resp)
} catch {
console && console.log("failed to parse JSON response", resp)
return null
}
}
static serialize(obj, parentKey){
let queryStr = []
for(var key in obj){
if(!Object.prototype.hasOwnProperty.call(obj, key)){ continue }
let paramKey = parentKey ? `${parentKey}[${key}]` : key
let paramVal = obj[key]
if(typeof paramVal === "object"){
queryStr.push(this.serialize(paramVal, paramKey))
} else {
queryStr.push(encodeURIComponent(paramKey) + "=" + encodeURIComponent(paramVal))
}
}
return queryStr.join("&")
}
static appendParams(url, params){
if(Object.keys(params).length === 0){ return url }
let prefix = url.match(/\?/) ? "&" : "?"
return `${url}${prefix}${this.serialize(params)}`
}
}
+331
View File
@@ -0,0 +1,331 @@
import {closure} from "./utils"
import {
CHANNEL_EVENTS,
CHANNEL_STATES,
} from "./constants"
import Push from "./push"
import Timer from "./timer"
/**
* @import Socket from "./socket"
* @import { ChannelState, Params, ChannelBindingCallback, ChannelOnMessage, ChannelFilterBindings, ChannelOnErrorCallback, ChannelBinding } from "./types"
*/
export default class Channel {
/**
* @param {string} topic
* @param {Params | (() => Params)} params
* @param {Socket} socket
*/
constructor(topic, params, socket){
/** @type{ChannelState} */
this.state = CHANNEL_STATES.closed
/** @type{string} */
this.topic = topic
/** @type{() => Params} */
this.params = closure(params || {})
/** @type {Socket} */
this.socket = socket
/** @type{ChannelBinding[]} */
this.bindings = []
/** @type{number} */
this.bindingRef = 0
/** @type{number} */
this.timeout = this.socket.timeout
/** @type{boolean} */
this.joinedOnce = false
/** @type{Push} */
this.joinPush = new Push(this, CHANNEL_EVENTS.join, this.params, this.timeout)
/** @type{Push[]} */
this.pushBuffer = []
/** @type{string[]} */
this.stateChangeRefs = []
/** @type{Timer} */
this.rejoinTimer = new Timer(() => {
if(this.socket.isConnected()){ this.rejoin() }
}, this.socket.rejoinAfterMs)
this.stateChangeRefs.push(this.socket.onError(() => this.rejoinTimer.reset()))
this.stateChangeRefs.push(this.socket.onOpen(() => {
this.rejoinTimer.reset()
if(this.isErrored()){ this.rejoin() }
})
)
this.joinPush.receive("ok", () => {
this.state = CHANNEL_STATES.joined
this.rejoinTimer.reset()
this.pushBuffer.forEach(pushEvent => pushEvent.send())
this.pushBuffer = []
})
this.joinPush.receive("error", (reason) => {
this.state = CHANNEL_STATES.errored
if(this.socket.hasLogger()) this.socket.log("channel", `error ${this.topic}`, reason)
if(this.socket.isConnected()){ this.rejoinTimer.scheduleTimeout() }
})
this.onClose(() => {
this.rejoinTimer.reset()
if(this.socket.hasLogger()) this.socket.log("channel", `close ${this.topic}`)
this.state = CHANNEL_STATES.closed
this.socket.remove(this)
})
this.onError(reason => {
if(this.socket.hasLogger()) this.socket.log("channel", `error ${this.topic}`, reason)
if(this.isJoining()){ this.joinPush.reset() }
this.state = CHANNEL_STATES.errored
if(this.socket.isConnected()){ this.rejoinTimer.scheduleTimeout() }
})
this.joinPush.receive("timeout", () => {
if(this.socket.hasLogger()) this.socket.log("channel", `timeout ${this.topic}`, this.joinPush.timeout)
let leavePush = new Push(this, CHANNEL_EVENTS.leave, closure({}), this.timeout)
leavePush.send()
this.state = CHANNEL_STATES.errored
this.joinPush.reset()
if(this.socket.isConnected()){ this.rejoinTimer.scheduleTimeout() }
})
this.on(CHANNEL_EVENTS.reply, (payload, ref) => {
this.trigger(this.replyEventName(ref), payload)
})
}
/**
* Join the channel
* @param {number} timeout
* @returns {Push}
*/
join(timeout = this.timeout){
if(this.joinedOnce){
throw new Error("tried to join multiple times. 'join' can only be called a single time per channel instance")
} else {
this.timeout = timeout
this.joinedOnce = true
this.rejoin()
return this.joinPush
}
}
/**
* Teardown the channel.
*
* Destroys and stops related timers.
*/
teardown(){
this.pushBuffer.forEach((push) => push.destroy())
this.pushBuffer = []
this.rejoinTimer.reset()
this.joinPush.destroy()
this.state = CHANNEL_STATES.closed
this.bindings = []
}
/**
* Hook into channel close
* @param {ChannelBindingCallback} callback
*/
onClose(callback){
this.on(CHANNEL_EVENTS.close, callback)
}
/**
* Hook into channel errors
* @param {ChannelOnErrorCallback} callback
* @return {number}
*/
onError(callback){
return this.on(CHANNEL_EVENTS.error, reason => callback(reason))
}
/**
* Subscribes on channel events
*
* Subscription returns a ref counter, which can be used later to
* unsubscribe the exact event listener
*
* @example
* const ref1 = channel.on("event", do_stuff)
* const ref2 = channel.on("event", do_other_stuff)
* channel.off("event", ref1)
* // Since unsubscription, do_stuff won't fire,
* // while do_other_stuff will keep firing on the "event"
*
* @param {string} event
* @param {ChannelBindingCallback} callback
* @returns {number} ref
*/
on(event, callback){
let ref = this.bindingRef++
this.bindings.push({event, ref, callback})
return ref
}
/**
* Unsubscribes off of channel events
*
* Use the ref returned from a channel.on() to unsubscribe one
* handler, or pass nothing for the ref to unsubscribe all
* handlers for the given event.
*
* @example
* // Unsubscribe the do_stuff handler
* const ref1 = channel.on("event", do_stuff)
* channel.off("event", ref1)
*
* // Unsubscribe all handlers from event
* channel.off("event")
*
* @param {string} event
* @param {number} [ref]
*/
off(event, ref){
this.bindings = this.bindings.filter((bind) => {
return !(bind.event === event && (typeof ref === "undefined" || ref === bind.ref))
})
}
/**
* @private
*/
canPush(){ return this.socket.isConnected() && this.isJoined() }
/**
* Sends a message `event` to phoenix with the payload `payload`.
* Phoenix receives this in the `handle_in(event, payload, socket)`
* function. if phoenix replies or it times out (default 10000ms),
* then optionally the reply can be received.
*
* @example
* channel.push("event")
* .receive("ok", payload => console.log("phoenix replied:", payload))
* .receive("error", err => console.log("phoenix errored", err))
* .receive("timeout", () => console.log("timed out pushing"))
* @param {string} event
* @param {Object} payload
* @param {number} [timeout]
* @returns {Push}
*/
push(event, payload, timeout = this.timeout){
payload = payload || {}
if(!this.joinedOnce){
throw new Error(`tried to push '${event}' to '${this.topic}' before joining. Use channel.join() before pushing events`)
}
let pushEvent = new Push(this, event, function (){ return payload }, timeout)
if(this.canPush()){
pushEvent.send()
} else {
pushEvent.startTimeout()
this.pushBuffer.push(pushEvent)
}
return pushEvent
}
/** Leaves the channel
*
* Unsubscribes from server events, and
* instructs channel to terminate on server
*
* Triggers onClose() hooks
*
* To receive leave acknowledgements, use the `receive`
* hook to bind to the server ack, ie:
*
* @example
* channel.leave().receive("ok", () => alert("left!") )
*
* @param {number} timeout
* @returns {Push}
*/
leave(timeout = this.timeout){
this.rejoinTimer.reset()
this.joinPush.cancelTimeout()
this.state = CHANNEL_STATES.leaving
let onClose = () => {
if(this.socket.hasLogger()) this.socket.log("channel", `leave ${this.topic}`)
this.trigger(CHANNEL_EVENTS.close, "leave")
}
let leavePush = new Push(this, CHANNEL_EVENTS.leave, closure({}), timeout)
leavePush.receive("ok", () => onClose())
.receive("timeout", () => onClose())
leavePush.send()
if(!this.canPush()){ leavePush.trigger("ok", {}) }
return leavePush
}
/**
* Overridable message hook
*
* Receives all events for specialized message handling
* before dispatching to the channel callbacks.
*
* Must return the payload, modified or unmodified
* @type{ChannelOnMessage}
*/
onMessage(_event, payload, _ref){ return payload }
/**
* Overridable filter hook
*
* If this function returns `true`, `binding`'s callback will be called.
*
* @type{ChannelFilterBindings}
*/
filterBindings(_binding, _payload, _ref){ return true }
isMember(topic, event, payload, joinRef){
if(this.topic !== topic){ return false }
if(joinRef && joinRef !== this.joinRef()){
if(this.socket.hasLogger()) this.socket.log("channel", "dropping outdated message", {topic, event, payload, joinRef})
return false
} else {
return true
}
}
joinRef(){ return this.joinPush.ref }
/**
* @private
*/
rejoin(timeout = this.timeout){
if(this.isLeaving()){ return }
this.socket.leaveOpenTopic(this.topic)
this.state = CHANNEL_STATES.joining
this.joinPush.resend(timeout)
}
/**
* @param {string} event
* @param {unknown} [payload]
* @param {?string} [ref]
* @param {?string} [joinRef]
*/
trigger(event, payload, ref, joinRef){
let handledPayload = this.onMessage(event, payload, ref, joinRef)
if(payload && !handledPayload){ throw new Error("channel onMessage callbacks must return the payload, modified or unmodified") }
let eventBindings = this.bindings.filter(bind => bind.event === event && this.filterBindings(bind, payload, ref))
for(let i = 0; i < eventBindings.length; i++){
let bind = eventBindings[i]
bind.callback(handledPayload, ref, joinRef || this.joinRef())
}
}
/**
* @param {string} ref
*/
replyEventName(ref){ return `chan_reply_${ref}` }
isClosed(){ return this.state === CHANNEL_STATES.closed }
isErrored(){ return this.state === CHANNEL_STATES.errored }
isJoined(){ return this.state === CHANNEL_STATES.joined }
isJoining(){ return this.state === CHANNEL_STATES.joining }
isLeaving(){ return this.state === CHANNEL_STATES.leaving }
}
+36
View File
@@ -0,0 +1,36 @@
export const globalSelf = typeof self !== "undefined" ? self : null
export const phxWindow = typeof window !== "undefined" ? window : null
export const global = globalSelf || phxWindow || globalThis
export const DEFAULT_VSN = "2.0.0"
export const DEFAULT_TIMEOUT = 10000
export const WS_CLOSE_NORMAL = 1000
export const MAX_LONGPOLL_BATCH_SIZE = 100
export const SOCKET_STATES = /** @type {const} */ ({connecting: 0, open: 1, closing: 2, closed: 3})
export const CHANNEL_STATES = /** @type {const} */ ({
closed: "closed",
errored: "errored",
joined: "joined",
joining: "joining",
leaving: "leaving",
})
export const CHANNEL_EVENTS = /** @type {const} */ ({
close: "phx_close",
error: "phx_error",
join: "phx_join",
reply: "phx_reply",
leave: "phx_leave"
})
export const TRANSPORTS = /** @type {const} */ ({
longpoll: "longpoll",
websocket: "websocket"
})
export const XHR_STATES = /** @type {const} */ ({
complete: 4
})
export const AUTH_TOKEN_PREFIX = "base64url.bearer.phx."
+212
View File
@@ -0,0 +1,212 @@
/**
* Phoenix Channels JavaScript client
*
* ## Socket Connection
*
* A single connection is established to the server and
* channels are multiplexed over the connection.
* Connect to the server using the `Socket` class:
*
* ```javascript
* let socket = new Socket("/socket", {params: {userToken: "123"}})
* socket.connect()
* ```
*
* The `Socket` constructor takes the mount point of the socket,
* the authentication params, as well as options that can be found in
* the Socket docs, such as configuring the `LongPoll` transport, and
* heartbeat.
*
* ## Channels
*
* Channels are isolated, concurrent processes on the server that
* subscribe to topics and broker events between the client and server.
* To join a channel, you must provide the topic, and channel params for
* authorization. Here's an example chat room example where `"new_msg"`
* events are listened for, messages are pushed to the server, and
* the channel is joined with ok/error/timeout matches:
*
* ```
* let channel = socket.channel("room:123", {token: roomToken})
* channel.on("new_msg", msg => console.log("Got message", msg) )
* $input.onEnter( e => {
* channel.push("new_msg", {body: e.target.val}, 10000)
* .receive("ok", (msg) => console.log("created message", msg) )
* .receive("error", (reasons) => console.log("create failed", reasons) )
* .receive("timeout", () => console.log("Networking issue...") )
* })
*
* channel.join()
* .receive("ok", ({messages}) => console.log("catching up", messages) )
* .receive("error", ({reason}) => console.log("failed join", reason) )
* .receive("timeout", () => console.log("Networking issue. Still waiting..."))
*```
*
* ## Joining
*
* Creating a channel with `socket.channel(topic, params)`, binds the params to
* `channel.params`, which are sent up on `channel.join()`.
* Subsequent rejoins will send up the modified params for
* updating authorization params, or passing up last_message_id information.
* Successful joins receive an "ok" status, while unsuccessful joins
* receive "error".
*
* With the default serializers and WebSocket transport, JSON text frames are
* used for pushing a JSON object literal. If an `ArrayBuffer` instance is provided,
* binary encoding will be used and the message will be sent with the binary
* opcode.
*
* *Note*: binary messages are only supported on the WebSocket transport.
*
* ## Duplicate Join Subscriptions
*
* While the client may join any number of topics on any number of channels,
* the client may only hold a single subscription for each unique topic at any
* given time. When attempting to create a duplicate subscription,
* the server will close the existing channel, log a warning, and
* spawn a new channel for the topic. The client will have their
* `channel.onClose` callbacks fired for the existing channel, and the new
* channel join will have its receive hooks processed as normal.
*
* ## Pushing Messages
*
* From the previous example, we can see that pushing messages to the server
* can be done with `channel.push(eventName, payload)` and we can optionally
* receive responses from the push. Additionally, we can use
* `receive("timeout", callback)` to abort waiting for our other `receive` hooks
* and take action after some period of waiting. The default timeout is 10000ms.
*
*
* ## Socket Hooks
*
* Lifecycle events of the multiplexed connection can be hooked into via
* `socket.onError()` and `socket.onClose()` events, ie:
*
* ```
* socket.onError( () => console.log("there was an error with the connection!") )
* socket.onClose( () => console.log("the connection dropped") )
* ```
*
*
* ## Channel Hooks
*
* For each joined channel, you can bind to `onError` and `onClose` events
* to monitor the channel lifecycle, ie:
*
* ```
* channel.onError( () => console.log("there was an error!") )
* channel.onClose( () => console.log("the channel has gone away gracefully") )
* ```
*
* ### onError hooks
*
* `onError` hooks are invoked if the socket connection drops, or the channel
* crashes on the server. In either case, a channel rejoin is attempted
* automatically in an exponential backoff manner.
*
* ### onClose hooks
*
* `onClose` hooks are invoked only in two cases. 1) the channel explicitly
* closed on the server, or 2). The client explicitly closed, by calling
* `channel.leave()`
*
*
* ## Presence
*
* The `Presence` object provides features for syncing presence information
* from the server with the client and handling presences joining and leaving.
*
* ### Syncing state from the server
*
* To sync presence state from the server, first instantiate an object and
* pass your channel in to track lifecycle events:
*
* ```
* let channel = socket.channel("some:topic")
* let presence = new Presence(channel)
* ```
*
* Next, use the `presence.onSync` callback to react to state changes
* from the server. For example, to render the list of users every time
* the list changes, you could write:
*
* ```
* presence.onSync(() => {
* myRenderUsersFunction(presence.list())
* })
* ```
*
* ### Listing Presences
*
* `presence.list` is used to return a list of presence information
* based on the local state of metadata. By default, all presence
* metadata is returned, but a `listBy` function can be supplied to
* allow the client to select which metadata to use for a given presence.
* For example, you may have a user online from different devices with
* a metadata status of "online", but they have set themselves to "away"
* on another device. In this case, the app may choose to use the "away"
* status for what appears on the UI. The example below defines a `listBy`
* function which prioritizes the first metadata which was registered for
* each user. This could be the first tab they opened, or the first device
* they came online from:
*
* ```
* let listBy = (id, {metas: [first, ...rest]}) => {
* first.count = rest.length + 1 // count of this user's presences
* first.id = id
* return first
* }
* let onlineUsers = presence.list(listBy)
* ```
*
* ### Handling individual presence join and leave events
*
* The `presence.onJoin` and `presence.onLeave` callbacks can be used to
* react to individual presences joining and leaving the app. For example:
*
* ```
* let presence = new Presence(channel)
*
* // detect if user has joined for the 1st time or from another tab/device
* presence.onJoin((id, current, newPres) => {
* if(!current){
* console.log("user has entered for the first time", newPres)
* } else {
* console.log("user additional presence", newPres)
* }
* })
*
* // detect if user has left from all tabs/devices, or is still present
* presence.onLeave((id, current, leftPres) => {
* if(current.metas.length === 0){
* console.log("user has left from all devices", leftPres)
* } else {
* console.log("user left from a device", leftPres)
* }
* })
* // receive presence data from server
* presence.onSync(() => {
* displayUsers(presence.list())
* })
* ```
* @module phoenix
*/
import Channel from "./channel"
import LongPoll from "./longpoll"
import Presence from "./presence"
import Serializer from "./serializer"
import Socket from "./socket"
import Timer from "./timer"
import Push from "./push"
export * from "./types"
export {
Channel,
LongPoll,
Presence,
Push,
Serializer,
Socket,
Timer
}
+199
View File
@@ -0,0 +1,199 @@
import {
SOCKET_STATES,
TRANSPORTS,
AUTH_TOKEN_PREFIX,
MAX_LONGPOLL_BATCH_SIZE
} from "./constants"
import Ajax from "./ajax"
let arrayBufferToBase64 = (buffer) => {
let binary = ""
let bytes = new Uint8Array(buffer)
let len = bytes.byteLength
for(let i = 0; i < len; i++){ binary += String.fromCharCode(bytes[i]) }
return btoa(binary)
}
export default class LongPoll {
constructor(endPoint, protocols){
// we only support subprotocols for authToken
// ["phoenix", "base64url.bearer.phx.BASE64_ENCODED_TOKEN"]
if(protocols && protocols.length === 2 && protocols[1].startsWith(AUTH_TOKEN_PREFIX)){
this.authToken = atob(protocols[1].slice(AUTH_TOKEN_PREFIX.length))
}
this.endPoint = null
this.token = null
this.skipHeartbeat = true
this.reqs = new Set()
this.awaitingBatchAck = false
this.currentBatch = null
this.currentBatchTimer = null
this.batchBuffer = []
this.onopen = function (){ } // noop
this.onerror = function (){ } // noop
this.onmessage = function (){ } // noop
this.onclose = function (){ } // noop
this.pollEndpoint = this.normalizeEndpoint(endPoint)
this.readyState = SOCKET_STATES.connecting
// we must wait for the caller to finish setting up our callbacks and timeout properties
setTimeout(() => this.poll(), 0)
}
normalizeEndpoint(endPoint){
return (endPoint
.replace("ws://", "http://")
.replace("wss://", "https://")
.replace(new RegExp("(.*)\/" + TRANSPORTS.websocket), "$1/" + TRANSPORTS.longpoll))
}
endpointURL(){
return Ajax.appendParams(this.pollEndpoint, {token: this.token})
}
closeAndRetry(code, reason, wasClean){
this.close(code, reason, wasClean)
this.readyState = SOCKET_STATES.connecting
}
ontimeout(){
this.onerror("timeout")
this.closeAndRetry(1005, "timeout", false)
}
isActive(){ return this.readyState === SOCKET_STATES.open || this.readyState === SOCKET_STATES.connecting }
poll(){
const headers = {"Accept": "application/json"}
if(this.authToken){
headers["X-Phoenix-AuthToken"] = this.authToken
}
this.ajax("GET", headers, null, () => this.ontimeout(), resp => {
if(resp){
var {status, token, messages} = resp
if(status === 410 && this.token !== null){
// In case we already have a token, this means that our existing session
// is gone. We fail so that the client rejoins its channels.
this.onerror(410)
this.closeAndRetry(3410, "session_gone", false)
return
}
this.token = token
} else {
status = 0
}
switch(status){
case 200:
messages.forEach(msg => {
// Tasks are what things like event handlers, setTimeout callbacks,
// promise resolves and more are run within.
// In modern browsers, there are two different kinds of tasks,
// microtasks and macrotasks.
// Microtasks are mainly used for Promises, while macrotasks are
// used for everything else.
// Microtasks always have priority over macrotasks. If the JS engine
// is looking for a task to run, it will always try to empty the
// microtask queue before attempting to run anything from the
// macrotask queue.
//
// For the WebSocket transport, messages always arrive in their own
// event. This means that if any promises are resolved from within,
// their callbacks will always finish execution by the time the
// next message event handler is run.
//
// In order to emulate this behaviour, we need to make sure each
// onmessage handler is run within its own macrotask.
setTimeout(() => this.onmessage({data: msg}), 0)
})
this.poll()
break
case 204:
this.poll()
break
case 410:
this.readyState = SOCKET_STATES.open
this.onopen({})
this.poll()
break
case 403:
this.onerror(403)
this.close(1008, "forbidden", false)
break
case 0:
case 500:
this.onerror(500)
this.closeAndRetry(1011, "internal server error", 500)
break
default: throw new Error(`unhandled poll status ${status}`)
}
})
}
// we collect all pushes within the current event loop by
// setTimeout 0, which optimizes back-to-back procedural
// pushes against an empty buffer
send(body){
if(typeof(body) !== "string"){ body = arrayBufferToBase64(body) }
if(this.currentBatch){
this.currentBatch.push(body)
} else if(this.awaitingBatchAck){
this.batchBuffer.push(body)
} else {
this.currentBatch = [body]
this.currentBatchTimer = setTimeout(() => {
this.batchSend(this.currentBatch)
this.currentBatch = null
}, 0)
}
}
batchSend(messages, offset = 0){
this.awaitingBatchAck = true
const next = offset + MAX_LONGPOLL_BATCH_SIZE
const batch = messages.slice(offset, next)
this.ajax("POST", {"Content-Type": "application/x-ndjson"}, batch.join("\n"), () => this.onerror("timeout"), resp => {
if(!resp || resp.status !== 200){
this.awaitingBatchAck = false
this.onerror(resp && resp.status)
this.closeAndRetry(1011, "internal server error", false)
} else if(next < messages.length){
this.batchSend(messages, next)
} else if(this.batchBuffer.length > 0){
this.batchSend(this.batchBuffer)
this.batchBuffer = []
} else {
this.awaitingBatchAck = false
}
})
}
close(code, reason, wasClean){
for(let req of this.reqs){ req.abort() }
this.readyState = SOCKET_STATES.closed
let opts = Object.assign({code: 1000, reason: undefined, wasClean: true}, {code, reason, wasClean})
this.batchBuffer = []
clearTimeout(this.currentBatchTimer)
this.currentBatchTimer = null
if(typeof(CloseEvent) !== "undefined"){
this.onclose(new CloseEvent("close", opts))
} else {
this.onclose(opts)
}
}
ajax(method, headers, body, onCallerTimeout, callback){
let req
let ontimeout = () => {
this.reqs.delete(req)
onCallerTimeout()
}
req = Ajax.request(method, this.endpointURL(), headers, body, this.timeout, ontimeout, resp => {
this.reqs.delete(req)
if(this.isActive()){ callback(resp) }
})
this.reqs.add(req)
}
}
+225
View File
@@ -0,0 +1,225 @@
/**
* @import Channel from "./channel"
* @import { PresenceEvents, PresenceOnJoin, PresenceOnLeave, PresenceOnSync, PresenceState, PresenceDiff, PresenceOptions } from "./types"
*/
export default class Presence {
/**
* Initializes the Presence
* @param {Channel} channel - The Channel
* @param {PresenceOptions} [opts] - The options, for example `{events: {state: "state", diff: "diff"}}`
*/
constructor(channel, opts = {}){
let events = opts.events || /** @type {PresenceEvents} */ ({state: "presence_state", diff: "presence_diff"})
/** @type{Record<string, PresenceState>} */
this.state = Object.create(null)
/** @type{PresenceDiff[]} */
this.pendingDiffs = []
/** @type{Channel} */
this.channel = channel
/** @type{?number} */
this.joinRef = null
/** @type{({ onJoin: PresenceOnJoin; onLeave: PresenceOnLeave; onSync: PresenceOnSync })} */
this.caller = {
onJoin: function (){ },
onLeave: function (){ },
onSync: function (){ }
}
this.channel.on(events.state, newState => {
let {onJoin, onLeave, onSync} = this.caller
this.joinRef = this.channel.joinRef()
this.state = Presence.syncState(this.state, newState, onJoin, onLeave)
this.pendingDiffs.forEach(diff => {
this.state = Presence.syncDiff(this.state, diff, onJoin, onLeave)
})
this.pendingDiffs = []
onSync()
})
this.channel.on(events.diff, diff => {
let {onJoin, onLeave, onSync} = this.caller
if(this.inPendingSyncState()){
this.pendingDiffs.push(diff)
} else {
this.state = Presence.syncDiff(this.state, diff, onJoin, onLeave)
onSync()
}
})
}
/**
* @param {PresenceOnJoin} callback
*/
onJoin(callback){ this.caller.onJoin = callback }
/**
* @param {PresenceOnLeave} callback
*/
onLeave(callback){ this.caller.onLeave = callback }
/**
* @param {PresenceOnSync} callback
*/
onSync(callback){ this.caller.onSync = callback }
/**
* Returns the array of presences, with selected metadata.
*
* @template [T=PresenceState]
* @param {((key: string, obj: PresenceState) => T)} [by]
*
* @returns {T[]}
*/
list(by){ return Presence.list(this.state, by) }
inPendingSyncState(){
return !this.joinRef || (this.joinRef !== this.channel.joinRef())
}
// lower-level public static API
/**
* Used to sync the list of presences on the server
* with the client's state. An optional `onJoin` and `onLeave` callback can
* be provided to react to changes in the client's local presences across
* disconnects and reconnects with the server.
*
* @param {Record<string, PresenceState>} currentState
* @param {Record<string, PresenceState>} newState
* @param {PresenceOnJoin} onJoin
* @param {PresenceOnLeave} onLeave
*
* @returns {Record<string, PresenceState>}
*/
static syncState(currentState, newState, onJoin, onLeave){
let state = this.toNullProtoObj(this.clone(currentState))
newState = this.toNullProtoObj(newState)
let joins = Object.create(null)
let leaves = Object.create(null)
this.map(state, (key, presence) => {
if(!newState[key]){
leaves[key] = presence
}
})
this.map(newState, (key, newPresence) => {
let currentPresence = state[key]
if(currentPresence){
let newRefs = newPresence.metas.map(m => m.phx_ref)
let curRefs = currentPresence.metas.map(m => m.phx_ref)
let joinedMetas = newPresence.metas.filter(m => curRefs.indexOf(m.phx_ref) < 0)
let leftMetas = currentPresence.metas.filter(m => newRefs.indexOf(m.phx_ref) < 0)
if(joinedMetas.length > 0){
joins[key] = newPresence
joins[key].metas = joinedMetas
}
if(leftMetas.length > 0){
leaves[key] = this.clone(currentPresence)
leaves[key].metas = leftMetas
}
} else {
joins[key] = newPresence
}
})
return this.syncDiff(state, {joins: joins, leaves: leaves}, onJoin, onLeave)
}
/**
*
* Used to sync a diff of presence join and leave
* events from the server, as they happen. Like `syncState`, `syncDiff`
* accepts optional `onJoin` and `onLeave` callbacks to react to a user
* joining or leaving from a device.
*
* @param {Record<string, PresenceState>} state
* @param {PresenceDiff} diff
* @param {PresenceOnJoin} onJoin
* @param {PresenceOnLeave} onLeave
*
* @returns {Record<string, PresenceState>}
*/
static syncDiff(state, diff, onJoin, onLeave){
state = this.toNullProtoObj(state)
let {joins, leaves} = this.clone(diff)
if(!onJoin){ onJoin = function (){ } }
if(!onLeave){ onLeave = function (){ } }
this.map(joins, (key, newPresence) => {
let currentPresence = state[key]
state[key] = this.clone(newPresence)
if(currentPresence){
let joinedRefs = state[key].metas.map(m => m.phx_ref)
let curMetas = currentPresence.metas.filter(m => joinedRefs.indexOf(m.phx_ref) < 0)
state[key].metas.unshift(...curMetas)
}
onJoin(key, currentPresence, newPresence)
})
this.map(leaves, (key, leftPresence) => {
let currentPresence = state[key]
if(!currentPresence){ return }
let refsToRemove = leftPresence.metas.map(m => m.phx_ref)
currentPresence.metas = currentPresence.metas.filter(p => {
return refsToRemove.indexOf(p.phx_ref) < 0
})
onLeave(key, currentPresence, leftPresence)
if(currentPresence.metas.length === 0){
delete state[key]
}
})
return state
}
/**
* Returns the array of presences, with selected metadata.
*
* @template [T=PresenceState]
* @param {Record<string, PresenceState>} presences
* @param {((key: string, obj: PresenceState) => T)} [chooser]
*
* @returns {T[]}
*/
static list(presences, chooser){
if(!chooser){ chooser = function (key, pres){ return pres } }
return this.map(presences, (key, presence) => {
return chooser(key, presence)
})
}
// private
/**
* @template T
* @param {Record<string, PresenceState>} obj
* @param {(key: string, obj: PresenceState) => T} func
*/
static map(obj, func){
return Object.getOwnPropertyNames(obj).map(key => func(key, obj[key]))
}
// Presence keys are chosen on the server and may collide with
// Object.prototype properties ("__proto__", "constructor", ...), so any
// object indexed by presence key must not have a prototype chain
//
// TODO: replace the null-prototype objects with Maps in Phoenix 2.0
// (breaking change for the lower-level static API)
static toNullProtoObj(obj){
if(Object.getPrototypeOf(obj) === null){ return obj }
let cleaned = Object.create(null)
Object.getOwnPropertyNames(obj).forEach(key => {
cleaned[key] = obj[key]
})
return cleaned
}
/**
* @template T
* @param {T} obj
* @returns {T}
*/
static clone(obj){ return JSON.parse(JSON.stringify(obj)) }
}
+134
View File
@@ -0,0 +1,134 @@
/**
* @import Channel from "./channel"
* @import { ChannelEvent } from "./types"
*/
export default class Push {
/**
* Initializes the Push
* @param {Channel} channel - The Channel
* @param {ChannelEvent} event - The event, for example `"phx_join"`
* @param {() => Record<string, unknown>} payload - The payload, for example `{user_id: 123}`
* @param {number} timeout - The push timeout in milliseconds
*/
constructor(channel, event, payload, timeout){
/** @type{Channel} */
this.channel = channel
/** @type{ChannelEvent} */
this.event = event
/** @type{() => Record<string, unknown>} */
this.payload = payload || function (){ return {} }
this.receivedResp = null
/** @type{number} */
this.timeout = timeout
/** @type{(ReturnType<typeof setTimeout>) | null} */
this.timeoutTimer = null
/** @type{{status: string; callback: (response: any) => void}[]} */
this.recHooks = []
/** @type{boolean} */
this.sent = false
/** @type{string | null | undefined} */
this.ref = undefined
}
/**
*
* @param {number} timeout
*/
resend(timeout){
this.timeout = timeout
this.reset()
this.send()
}
/**
*
*/
send(){
if(this.hasReceived("timeout")){ return }
this.startTimeout()
this.sent = true
this.channel.socket.push({
topic: this.channel.topic,
event: this.event,
payload: this.payload(),
ref: this.ref,
join_ref: this.channel.joinRef()
})
}
/**
*
* @param {string} status
* @param {(response: any) => void} callback
*/
receive(status, callback){
if(this.hasReceived(status)){
callback(this.receivedResp.response)
}
this.recHooks.push({status, callback})
return this
}
reset(){
this.cancelRefEvent()
this.ref = null
this.refEvent = null
this.receivedResp = null
this.sent = false
}
destroy(){
this.cancelRefEvent()
this.cancelTimeout()
}
/**
* @private
*/
matchReceive({status, response, _ref}){
this.recHooks.filter(h => h.status === status)
.forEach(h => h.callback(response))
}
/**
* @private
*/
cancelRefEvent(){
if(!this.refEvent){ return }
this.channel.off(this.refEvent)
}
cancelTimeout(){
clearTimeout(this.timeoutTimer)
this.timeoutTimer = null
}
startTimeout(){
if(this.timeoutTimer){ this.cancelTimeout() }
this.ref = this.channel.socket.makeRef()
this.refEvent = this.channel.replyEventName(this.ref)
this.channel.on(this.refEvent, payload => {
this.cancelRefEvent()
this.cancelTimeout()
this.receivedResp = payload
this.matchReceive(payload)
})
this.timeoutTimer = setTimeout(() => {
this.trigger("timeout", {})
}, this.timeout)
}
/**
* @private
*/
hasReceived(status){
return this.receivedResp && this.receivedResp.status === status
}
trigger(status, response){
this.channel.trigger(this.refEvent, {status, response})
}
}
+151
View File
@@ -0,0 +1,151 @@
/* The default serializer for encoding and decoding messages */
import {
CHANNEL_EVENTS
} from "./constants"
/**
* @import { Message } from "./types"
*/
export default {
HEADER_LENGTH: 1,
META_LENGTH: 4,
KINDS: {push: 0, reply: 1, broadcast: 2},
/**
* @template T
* @param {Message<Record<string, any>>} msg
* @param {(msg: ArrayBuffer | string) => T} callback
* @returns {T}
*/
encode(msg, callback){
if(msg.payload.constructor === ArrayBuffer){
return callback(this.binaryEncode(msg))
} else {
let payload = [msg.join_ref, msg.ref, msg.topic, msg.event, msg.payload]
return callback(JSON.stringify(payload))
}
},
/**
* @template T
* @param {ArrayBuffer | string} rawPayload
* @param {(msg: Message<unknown>) => T} callback
* @returns {T}
*/
decode(rawPayload, callback){
if(rawPayload.constructor === ArrayBuffer){
return callback(this.binaryDecode(rawPayload))
} else {
let [join_ref, ref, topic, event, payload] = JSON.parse(rawPayload)
return callback({join_ref, ref, topic, event, payload})
}
},
/** @private */
binaryEncode(message){
let {join_ref, ref, event, topic, payload} = message
let encoder = new TextEncoder()
let joinRefBytes = encoder.encode(join_ref)
let refBytes = encoder.encode(ref)
let topicBytes = encoder.encode(topic)
let eventBytes = encoder.encode(event)
this.assertFieldSize(joinRefBytes.byteLength, "join_ref")
this.assertFieldSize(refBytes.byteLength, "ref")
this.assertFieldSize(topicBytes.byteLength, "topic")
this.assertFieldSize(eventBytes.byteLength, "event")
let metaLength = this.META_LENGTH + joinRefBytes.byteLength + refBytes.byteLength + topicBytes.byteLength + eventBytes.byteLength
let header = new ArrayBuffer(this.HEADER_LENGTH + metaLength)
let headerBytes = new Uint8Array(header)
let view = new DataView(header)
let offset = 0
view.setUint8(offset++, this.KINDS.push) // kind
view.setUint8(offset++, joinRefBytes.byteLength)
view.setUint8(offset++, refBytes.byteLength)
view.setUint8(offset++, topicBytes.byteLength)
view.setUint8(offset++, eventBytes.byteLength)
headerBytes.set(joinRefBytes, offset); offset += joinRefBytes.byteLength
headerBytes.set(refBytes, offset); offset += refBytes.byteLength
headerBytes.set(topicBytes, offset); offset += topicBytes.byteLength
headerBytes.set(eventBytes, offset); offset += eventBytes.byteLength
var combined = new Uint8Array(header.byteLength + payload.byteLength)
combined.set(headerBytes, 0)
combined.set(new Uint8Array(payload), header.byteLength)
return combined.buffer
},
assertFieldSize(size, name){
if(size > 255){
throw new Error(`unable to convert ${name} to binary: must be less than or equal to 255 bytes, but is ${size} bytes`)
}
},
/**
* @private
*/
binaryDecode(buffer){
let view = new DataView(buffer)
let kind = view.getUint8(0)
let decoder = new TextDecoder()
switch(kind){
case this.KINDS.push: return this.decodePush(buffer, view, decoder)
case this.KINDS.reply: return this.decodeReply(buffer, view, decoder)
case this.KINDS.broadcast: return this.decodeBroadcast(buffer, view, decoder)
}
},
/** @private */
decodePush(buffer, view, decoder){
let joinRefSize = view.getUint8(1)
let topicSize = view.getUint8(2)
let eventSize = view.getUint8(3)
let offset = this.HEADER_LENGTH + this.META_LENGTH - 1 // pushes have no ref
let joinRef = decoder.decode(buffer.slice(offset, offset + joinRefSize))
offset = offset + joinRefSize
let topic = decoder.decode(buffer.slice(offset, offset + topicSize))
offset = offset + topicSize
let event = decoder.decode(buffer.slice(offset, offset + eventSize))
offset = offset + eventSize
let data = buffer.slice(offset, buffer.byteLength)
return {join_ref: joinRef, ref: null, topic: topic, event: event, payload: data}
},
/** @private */
decodeReply(buffer, view, decoder){
let joinRefSize = view.getUint8(1)
let refSize = view.getUint8(2)
let topicSize = view.getUint8(3)
let eventSize = view.getUint8(4)
let offset = this.HEADER_LENGTH + this.META_LENGTH
let joinRef = decoder.decode(buffer.slice(offset, offset + joinRefSize))
offset = offset + joinRefSize
let ref = decoder.decode(buffer.slice(offset, offset + refSize))
offset = offset + refSize
let topic = decoder.decode(buffer.slice(offset, offset + topicSize))
offset = offset + topicSize
let event = decoder.decode(buffer.slice(offset, offset + eventSize))
offset = offset + eventSize
let data = buffer.slice(offset, buffer.byteLength)
let payload = {status: event, response: data}
return {join_ref: joinRef, ref: ref, topic: topic, event: CHANNEL_EVENTS.reply, payload: payload}
},
/** @private */
decodeBroadcast(buffer, view, decoder){
let topicSize = view.getUint8(1)
let eventSize = view.getUint8(2)
let offset = this.HEADER_LENGTH + 2
let topic = decoder.decode(buffer.slice(offset, offset + topicSize))
offset = offset + topicSize
let event = decoder.decode(buffer.slice(offset, offset + eventSize))
offset = offset + eventSize
let data = buffer.slice(offset, buffer.byteLength)
return {join_ref: null, ref: null, topic: topic, event: event, payload: data}
}
}
+756
View File
@@ -0,0 +1,756 @@
import {
global,
phxWindow,
CHANNEL_EVENTS,
DEFAULT_TIMEOUT,
DEFAULT_VSN,
SOCKET_STATES,
TRANSPORTS,
WS_CLOSE_NORMAL,
AUTH_TOKEN_PREFIX
} from "./constants"
import {
closure
} from "./utils"
import Ajax from "./ajax"
import Channel from "./channel"
import LongPoll from "./longpoll"
import Serializer from "./serializer"
import Timer from "./timer"
/**
* @import { Encode, Decode, Message, Vsn, SocketTransport, Params, SocketOnOpen, SocketOnClose, SocketOnError, SocketOnMessage, SocketOptions, SocketStateChangeCallbacks, HeartbeatCallback } from "./types"
*/
export default class Socket {
/** Initializes the Socket *
*
* For IE8 support use an ES5-shim (https://github.com/es-shims/es5-shim)
*
* @constructor
* @param {string} endPoint - The string WebSocket endpoint, ie, `"ws://example.com/socket"`,
* `"wss://example.com"`
* `"/socket"` (inherited host & protocol)
* @param {SocketOptions} [opts] - Optional configuration
*/
constructor(endPoint, opts = {}){
/** @type{SocketStateChangeCallbacks} */
this.stateChangeCallbacks = {open: [], close: [], error: [], message: []}
/** @type{Channel[]} */
this.channels = []
/** @type{(() => void)[]} */
this.sendBuffer = []
/** @type{number} */
this.ref = 0
/** @type{?string} */
this.fallbackRef = null
/** @type{number} */
this.timeout = opts.timeout || DEFAULT_TIMEOUT
/** @type{SocketTransport} */
this.transport = opts.transport || global.WebSocket || LongPoll
/** @type{InstanceType<SocketTransport> | undefined | null} */
this.conn = undefined
/** @type{boolean} */
this.primaryPassedHealthCheck = false
/** @type{number | undefined} */
this.longPollFallbackMs = opts.longPollFallbackMs
/** @type{ReturnType<typeof setTimeout>} */
this.fallbackTimer = null
// In some environments (sandboxed iframes without `allow-same-origin`,
// in-app webviews, "block third-party storage" privacy modes), reading
// `global.sessionStorage` throws SecurityError at the property-access level.
// Wrap the read so the Socket constructor cannot throw synchronously.
let envSessionStorage = null
try { envSessionStorage = global && global.sessionStorage } catch {}
/** @type{Storage} */
this.sessionStore = opts.sessionStorage || envSessionStorage
/** @type{number} */
this.establishedConnections = 0
/** @type{Encode<void>} */
this.defaultEncoder = Serializer.encode.bind(Serializer)
/** @type{Decode<void>} */
this.defaultDecoder = Serializer.decode.bind(Serializer)
// We start with closeWasClean true to avoid the visibility change
// logic from connecting if the socket was never connected in the first place.
// transportConnect sets it to false on open.
/** @type{boolean} */
this.closeWasClean = true
/** @type{boolean} */
this.disconnecting = false
/** @type{BinaryType} */
this.binaryType = opts.binaryType || "arraybuffer"
/** @type{number} */
this.connectClock = 1
/** @type{boolean} */
this.pageHidden = false
/** @type{Encode<void>} */
this.encode = undefined
/** @type{Decode<void>} */
this.decode = undefined
if(this.transport !== LongPoll){
this.encode = opts.encode || this.defaultEncoder
this.decode = opts.decode || this.defaultDecoder
} else {
this.encode = this.defaultEncoder
this.decode = this.defaultDecoder
}
/** @type{number | null} */
let awaitingConnectionOnPageShow = null
if(phxWindow && phxWindow.addEventListener){
phxWindow.addEventListener("pagehide", _e => {
if(this.conn){
this.disconnect()
awaitingConnectionOnPageShow = this.connectClock
}
})
phxWindow.addEventListener("pageshow", _e => {
if(awaitingConnectionOnPageShow === this.connectClock){
awaitingConnectionOnPageShow = null
this.connect()
}
})
phxWindow.addEventListener("visibilitychange", () => {
if(document.visibilityState === "hidden"){
this.pageHidden = true
} else {
this.pageHidden = false
// reconnect immediately
if(!this.isConnected() && !this.closeWasClean){
this.teardown(() => this.connect())
}
}
})
}
/** @type{number} */
this.heartbeatIntervalMs = opts.heartbeatIntervalMs || 30000
/** @type{boolean} */
this.autoSendHeartbeat = opts.autoSendHeartbeat ?? true
/** @type{HeartbeatCallback} */
this.heartbeatCallback = opts.heartbeatCallback ?? (() => {})
/** @type{(tries: number) => number} */
this.rejoinAfterMs = (tries) => {
if(opts.rejoinAfterMs){
return opts.rejoinAfterMs(tries)
} else {
return [1000, 2000, 5000][tries - 1] || 10000
}
}
/** @type{(tries: number) => number} */
this.reconnectAfterMs = (tries) => {
if(opts.reconnectAfterMs){
return opts.reconnectAfterMs(tries)
} else {
return [10, 50, 100, 150, 200, 250, 500, 1000, 2000][tries - 1] || 5000
}
}
/** @type{((kind: string, msg: string, data: any) => void) | null} */
this.logger = opts.logger || null
if(!this.logger && opts.debug){
this.logger = (kind, msg, data) => { console.log(`${kind}: ${msg}`, data) }
}
/** @type{number} */
this.longpollerTimeout = opts.longpollerTimeout || 20000
/** @type{() => Params} */
this.params = closure(opts.params || {})
/** @type{string} */
this.endPoint = `${endPoint}/${TRANSPORTS.websocket}`
/** @type{Vsn} */
this.vsn = opts.vsn || DEFAULT_VSN
/** @type{ReturnType<typeof setTimeout>} */
this.heartbeatTimeoutTimer = null
/** @type{ReturnType<typeof setTimeout>} */
this.heartbeatTimer = null
/** @type{number | null} */
this.heartbeatSentAt = null
/** @type{?string} */
this.pendingHeartbeatRef = null
/** @type{Timer} */
this.reconnectTimer = new Timer( () => {
if(this.pageHidden){
this.log("Not reconnecting as page is hidden!")
this.teardown()
return
}
this.teardown(async () => {
if(opts.beforeReconnect) await opts.beforeReconnect()
this.connect()
})
}, this.reconnectAfterMs)
/** @type{(() => string) | undefined} */
this.authToken = opts.authToken && closure(opts.authToken)
}
/**
* Returns the LongPoll transport reference
*/
getLongPollTransport(){ return LongPoll }
/**
* Disconnects and replaces the active transport
*
* @param {SocketTransport} newTransport - The new transport class to instantiate
*
*/
replaceTransport(newTransport){
this.connectClock++
this.closeWasClean = true
clearTimeout(this.fallbackTimer)
this.reconnectTimer.reset()
if(this.conn){
this.conn.close()
this.conn = null
}
this.transport = newTransport
}
/**
* Returns the socket protocol
*
* @returns {"wss" | "ws"}
*/
protocol(){ return location.protocol.match(/^https/) ? "wss" : "ws" }
/**
* The fully qualified socket url
*
* @returns {string}
*/
endPointURL(){
let uri = Ajax.appendParams(
Ajax.appendParams(this.endPoint, this.params()), {vsn: this.vsn})
if(uri.charAt(0) !== "/"){ return uri }
if(uri.charAt(1) === "/"){ return `${this.protocol()}:${uri}` }
return `${this.protocol()}://${location.host}${uri}`
}
/**
* Disconnects the socket
*
* See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent#Status_codes for valid status codes.
*
* @param {() => void} [callback] - Optional callback which is called after socket is disconnected.
* @param {number} [code] - A status code for disconnection (Optional).
* @param {string} [reason] - A textual description of the reason to disconnect. (Optional)
*/
disconnect(callback, code, reason){
this.connectClock++
this.disconnecting = true
this.closeWasClean = true
clearTimeout(this.fallbackTimer)
this.reconnectTimer.reset()
this.teardown(() => {
this.disconnecting = false
callback && callback()
}, code, reason)
}
/**
* @param {Params} [params] - [DEPRECATED] The params to send when connecting, for example `{user_id: userToken}`
*
* Passing params to connect is deprecated; pass them in the Socket constructor instead:
* `new Socket("/socket", {params: {user_id: userToken}})`.
*/
connect(params){
if(params){
console && console.log("passing params to connect is deprecated. Instead pass :params to the Socket constructor")
this.params = closure(params)
}
if(this.conn && !this.disconnecting){ return }
if(this.longPollFallbackMs && this.transport !== LongPoll){
this.connectWithFallback(LongPoll, this.longPollFallbackMs)
} else {
this.transportConnect()
}
}
/**
* Logs the message. Override `this.logger` for specialized logging. noops by default
* @param {string} kind
* @param {string} msg
* @param {Object} data
*/
log(kind, msg, data){ this.logger && this.logger(kind, msg, data) }
/**
* Returns true if a logger has been set on this socket.
*/
hasLogger(){ return this.logger !== null }
/**
* Registers callbacks for connection open events
*
* @example socket.onOpen(function(){ console.info("the socket was opened") })
*
* @param {SocketOnOpen} callback
*/
onOpen(callback){
let ref = this.makeRef()
this.stateChangeCallbacks.open.push([ref, callback])
return ref
}
/**
* Registers callbacks for connection close events
* @param {SocketOnClose} callback
* @returns {string}
*/
onClose(callback){
let ref = this.makeRef()
this.stateChangeCallbacks.close.push([ref, callback])
return ref
}
/**
* Registers callbacks for connection error events
*
* @example socket.onError(function(error){ alert("An error occurred") })
*
* @param {SocketOnError} callback
* @returns {string}
*/
onError(callback){
let ref = this.makeRef()
this.stateChangeCallbacks.error.push([ref, callback])
return ref
}
/**
* Registers callbacks for connection message events
* @param {SocketOnMessage} callback
* @returns {string}
*/
onMessage(callback){
let ref = this.makeRef()
this.stateChangeCallbacks.message.push([ref, callback])
return ref
}
/**
* Sets a callback that receives lifecycle events for internal heartbeat messages.
* Useful for instrumenting connection health (e.g. sent/ok/timeout/disconnected).
* @param {HeartbeatCallback} callback
*/
onHeartbeat(callback){
this.heartbeatCallback = callback
}
/**
* Pings the server and invokes the callback with the RTT in milliseconds
* @param {(timeDelta: number) => void} callback
*
* Returns true if the ping was pushed or false if unable to be pushed.
*/
ping(callback){
if(!this.isConnected()){ return false }
let ref = this.makeRef()
let startTime = Date.now()
this.push({topic: "phoenix", event: "heartbeat", payload: {}, ref: ref})
let onMsgRef = this.onMessage(msg => {
if(msg.ref === ref){
this.off([onMsgRef])
callback(Date.now() - startTime)
}
})
return true
}
/**
* @private
*
* @param {Function}
*/
transportName(transport){
// JavaScript minification, enabled by default in production in Phoenix
// projects, renames symbols to reduce code size.
// See https://esbuild.github.io/api/#keep-names.
// This helper ensures we return the correct name for the LongPoll transport
// even after minification. The other common transport is WebSocket, which
// is native to browsers and does not need special handling.
switch(transport){
case LongPoll: return "LongPoll"
default: return transport.name
}
}
/**
* @private
*/
transportConnect(){
this.connectClock++
this.closeWasClean = false
let protocols = undefined
// Sec-WebSocket-Protocol based token
// (longpoll uses Authorization header instead)
if(this.authToken){
protocols = ["phoenix", `${AUTH_TOKEN_PREFIX}${btoa(this.authToken()).replace(/=/g, "")}`]
}
this.conn = new this.transport(this.endPointURL(), protocols)
this.conn.binaryType = this.binaryType
this.conn.timeout = this.longpollerTimeout
this.conn.onopen = () => this.onConnOpen()
this.conn.onerror = error => this.onConnError(error)
this.conn.onmessage = event => this.onConnMessage(event)
this.conn.onclose = event => this.onConnClose(event)
}
getSession(key){ return this.sessionStore && this.sessionStore.getItem(key) }
storeSession(key, val){ this.sessionStore && this.sessionStore.setItem(key, val) }
connectWithFallback(fallbackTransport, fallbackThreshold = 2500){
clearTimeout(this.fallbackTimer)
let established = false
let primaryTransport = true
let openRef, errorRef
let fallbackTransportName = this.transportName(fallbackTransport)
let fallback = (reason) => {
this.log("transport", `falling back to ${fallbackTransportName}...`, reason)
this.off([openRef, errorRef])
primaryTransport = false
this.replaceTransport(fallbackTransport)
this.transportConnect()
}
if(this.getSession(`phx:fallback:${fallbackTransportName}`)){ return fallback("memorized") }
this.fallbackTimer = setTimeout(fallback, fallbackThreshold)
errorRef = this.onError(reason => {
this.log("transport", "error", reason)
if(primaryTransport && !established){
clearTimeout(this.fallbackTimer)
fallback(reason)
}
})
if(this.fallbackRef){
this.off([this.fallbackRef])
}
this.fallbackRef = this.onOpen(() => {
established = true
if(!primaryTransport){
let fallbackTransportName = this.transportName(fallbackTransport)
// only memorize LP if we never connected to primary
if(!this.primaryPassedHealthCheck){ this.storeSession(`phx:fallback:${fallbackTransportName}`, "true") }
return this.log("transport", `established ${fallbackTransportName} fallback`)
}
// if we've established primary, give the fallback a new period to attempt ping
clearTimeout(this.fallbackTimer)
this.fallbackTimer = setTimeout(fallback, fallbackThreshold)
this.ping(rtt => {
this.log("transport", "connected to primary after", rtt)
this.primaryPassedHealthCheck = true
clearTimeout(this.fallbackTimer)
})
})
this.transportConnect()
}
clearHeartbeats(){
clearTimeout(this.heartbeatTimer)
clearTimeout(this.heartbeatTimeoutTimer)
}
onConnOpen(){
if(this.hasLogger()) this.log("transport", `connected to ${this.endPointURL()}`)
this.closeWasClean = false
this.disconnecting = false
this.establishedConnections++
this.flushSendBuffer()
this.reconnectTimer.reset()
if(this.autoSendHeartbeat){
this.resetHeartbeat()
}
this.triggerStateCallbacks("open")
}
/**
* @private
*/
heartbeatTimeout(){
if(this.pendingHeartbeatRef){
this.pendingHeartbeatRef = null
this.heartbeatSentAt = null
if(this.hasLogger()){ this.log("transport", "heartbeat timeout. Attempting to re-establish connection") }
try {
this.heartbeatCallback("timeout")
} catch (e){
this.log("error", "error in heartbeat callback", e)
}
this.triggerChanError(new Error("heartbeat timeout"))
this.closeWasClean = false
this.teardown(() => this.reconnectTimer.scheduleTimeout(), WS_CLOSE_NORMAL, "heartbeat timeout")
}
}
resetHeartbeat(){
if(this.conn && this.conn.skipHeartbeat){ return }
this.pendingHeartbeatRef = null
this.clearHeartbeats()
this.heartbeatTimer = setTimeout(() => this.sendHeartbeat(), this.heartbeatIntervalMs)
}
teardown(callback, code, reason){
if(!this.conn){
return callback && callback()
}
// If someone calls connect before we finish tearing down,
// we create a new connection, but we still want to finish tearing down the old one.
const connToClose = this.conn
this.waitForBufferDone(connToClose, () => {
if(code){ connToClose.close(code, reason || "") } else { connToClose.close() }
this.waitForSocketClosed(connToClose, () => {
if(this.conn === connToClose){
this.conn.onopen = function (){ } // noop
this.conn.onerror = function (){ } // noop
this.conn.onmessage = function (){ } // noop
this.conn.onclose = function (){ } // noop
this.conn = null
}
callback && callback()
})
})
}
waitForBufferDone(conn, callback, tries = 1){
if(tries === 5 || !conn.bufferedAmount){
callback()
return
}
setTimeout(() => {
this.waitForBufferDone(conn, callback, tries + 1)
}, 150 * tries)
}
waitForSocketClosed(conn, callback, tries = 1){
if(tries === 5 || conn.readyState === SOCKET_STATES.closed){
callback()
return
}
setTimeout(() => {
this.waitForSocketClosed(conn, callback, tries + 1)
}, 150 * tries)
}
/**
* @param {CloseEvent} event
*/
onConnClose(event){
if(this.conn) this.conn.onclose = () => {} // noop to prevent recursive calls in teardown
if(this.hasLogger()) this.log("transport", "close", event)
this.triggerChanError(event)
this.clearHeartbeats()
if(!this.closeWasClean){
this.reconnectTimer.scheduleTimeout()
}
this.triggerStateCallbacks("close", event)
}
/**
* @private
* @param {Event} error
*/
onConnError(error){
if(this.hasLogger()) this.log("transport", "error", error)
let transportBefore = this.transport
let establishedBefore = this.establishedConnections
this.triggerStateCallbacks("error", error, transportBefore, establishedBefore)
if(transportBefore === this.transport || establishedBefore > 0){
this.triggerChanError(error)
}
}
/**
* @private
* @param {unknown} [reason] underlying close/error event forwarded to channel error listeners
*/
triggerChanError(reason){
this.channels.forEach(channel => {
if(!(channel.isErrored() || channel.isLeaving() || channel.isClosed())){
channel.trigger(CHANNEL_EVENTS.error, reason)
}
})
}
/**
* @returns {string}
*/
connectionState(){
switch(this.conn && this.conn.readyState){
case SOCKET_STATES.connecting: return "connecting"
case SOCKET_STATES.open: return "open"
case SOCKET_STATES.closing: return "closing"
default: return "closed"
}
}
/**
* @returns {boolean}
*/
isConnected(){ return this.connectionState() === "open" }
/**
*
* @param {Channel} channel
*/
remove(channel){
this.off(channel.stateChangeRefs)
this.channels = this.channels.filter(c => c !== channel)
}
/**
* Removes `onOpen`, `onClose`, `onError,` and `onMessage` registrations.
*
* @param {string[]} refs - list of refs returned by calls to
* `onOpen`, `onClose`, `onError,` and `onMessage`
*/
off(refs){
for(let key in this.stateChangeCallbacks){
this.stateChangeCallbacks[key] = this.stateChangeCallbacks[key].filter(([ref]) => {
return refs.indexOf(ref) === -1
})
}
}
/**
* Initiates a new channel for the given topic
*
* @param {string} topic
* @param {Params | (() => Params)} [chanParams]- Parameters for the channel
* @returns {Channel}
*/
channel(topic, chanParams = {}){
let chan = new Channel(topic, chanParams, this)
this.channels.push(chan)
return chan
}
/**
* @param {Message<Record<string, any>>} data
*/
push(data){
if(this.hasLogger()){
let {topic, event, payload, ref, join_ref} = data
this.log("push", `${topic} ${event} (${join_ref}, ${ref})`, payload)
}
if(this.isConnected()){
this.encode(data, result => this.conn.send(result))
} else {
this.sendBuffer.push(() => this.encode(data, result => this.conn.send(result)))
}
}
/**
* Return the next message ref, accounting for overflows
* @returns {string}
*/
makeRef(){
let newRef = this.ref + 1
if(newRef === this.ref){ this.ref = 0 } else { this.ref = newRef }
return this.ref.toString()
}
sendHeartbeat(){
if(!this.isConnected()){
try {
this.heartbeatCallback("disconnected")
} catch (e){
this.log("error", "error in heartbeat callback", e)
}
return
}
if(this.pendingHeartbeatRef){
this.heartbeatTimeout()
return
}
this.pendingHeartbeatRef = this.makeRef()
this.heartbeatSentAt = Date.now()
this.push({topic: "phoenix", event: "heartbeat", payload: {}, ref: this.pendingHeartbeatRef})
try {
this.heartbeatCallback("sent")
} catch (e){
this.log("error", "error in heartbeat callback", e)
}
this.heartbeatTimeoutTimer = setTimeout(() => this.heartbeatTimeout(), this.heartbeatIntervalMs)
}
flushSendBuffer(){
if(this.isConnected() && this.sendBuffer.length > 0){
this.sendBuffer.forEach(callback => callback())
this.sendBuffer = []
}
}
/**
* @param {MessageEvent<any>} rawMessage
*/
onConnMessage(rawMessage){
this.decode(rawMessage.data, msg => {
let {topic, event, payload, ref, join_ref} = msg
if(ref && ref === this.pendingHeartbeatRef){
const latency = this.heartbeatSentAt ? Date.now() - this.heartbeatSentAt : undefined
this.clearHeartbeats()
try {
this.heartbeatCallback(payload.status === "ok" ? "ok" : "error", latency)
} catch (e){
this.log("error", "error in heartbeat callback", e)
}
this.pendingHeartbeatRef = null
this.heartbeatSentAt = null
if(this.autoSendHeartbeat){
this.heartbeatTimer = setTimeout(() => this.sendHeartbeat(), this.heartbeatIntervalMs)
}
}
if(this.hasLogger()) this.log("receive", `${payload.status || ""} ${topic} ${event} ${ref && "(" + ref + ")" || ""}`.trim(), payload)
for(let i = 0; i < this.channels.length; i++){
const channel = this.channels[i]
if(!channel.isMember(topic, event, payload, join_ref)){ continue }
channel.trigger(event, payload, ref, join_ref)
}
this.triggerStateCallbacks("message", msg)
})
}
/**
* @private
* @template {keyof SocketStateChangeCallbacks} K
* @param {K} event
* @param {...Parameters<SocketStateChangeCallbacks[K][number][1]>} args
* @returns {void}
*/
triggerStateCallbacks(event, ...args){
try {
this.stateChangeCallbacks[event].forEach(([_, callback]) => {
try {
callback(...args)
} catch (e){
this.log("error", `error in ${event} callback`, e)
}
})
} catch (e){
this.log("error", `error triggering ${event} callbacks`, e)
}
}
leaveOpenTopic(topic){
let dupChannel = this.channels.find(c => c.topic === topic && (c.isJoined() || c.isJoining()))
if(dupChannel){
if(this.hasLogger()) this.log("transport", `leaving duplicate topic "${topic}"`)
dupChannel.leave()
}
}
}
+48
View File
@@ -0,0 +1,48 @@
/**
*
* Creates a timer that accepts a `timerCalc` function to perform
* calculated timeout retries, such as exponential backoff.
*
* @example
* let reconnectTimer = new Timer(() => this.connect(), function(tries){
* return [1000, 5000, 10000][tries - 1] || 10000
* })
* reconnectTimer.scheduleTimeout() // fires after 1000
* reconnectTimer.scheduleTimeout() // fires after 5000
* reconnectTimer.reset()
* reconnectTimer.scheduleTimeout() // fires after 1000
*
*/
export default class Timer {
/**
* @param {() => void} callback
* @param {(tries: number) => number} timerCalc
*/
constructor(callback, timerCalc){
/** @type {() => void} */
this.callback = callback
/** @type {(tries: number) => number} */
this.timerCalc = timerCalc
/** @type {ReturnType<typeof setTimeout> | undefined} */
this.timer = undefined
/** @type {number} */
this.tries = 0
}
reset(){
this.tries = 0
clearTimeout(this.timer)
}
/**
* Cancels any previous scheduleTimeout and schedules callback
*/
scheduleTimeout(){
clearTimeout(this.timer)
this.timer = setTimeout(() => {
this.tries = this.tries + 1
this.callback()
}, this.timerCalc(this.tries + 1))
}
}
+184
View File
@@ -0,0 +1,184 @@
/**
* @import LongPoll from "./longpoll"
*/
/**
* MISC
* @typedef {Record<string, unknown>} Params
*/
/**
* @template T
* @typedef {T | (() => T)} Closure
*
*/
/**
* CHANNEL
* @typedef {(payload: unknown, ref: string | null | undefined, joinRef: string) => void} ChannelBindingCallback
* @typedef {(reason: unknown) => void} ChannelOnErrorCallback
* @typedef {({event: string, ref: number, callback: ChannelBindingCallback})} ChannelBinding
* @typedef {(event: string, payload?: unknown, ref?: ?string, joinRef?: ?string) => unknown} ChannelOnMessage
* @typedef {(binding: ChannelBinding, payload: unknown, ref?: ?string) => boolean} ChannelFilterBindings
*/
/**
* CONSTANTS
* @import {SOCKET_STATES, CHANNEL_STATES, CHANNEL_EVENTS, TRANSPORTS, XHR_STATES} from "./constants"
* @typedef {"1.0.0" | "2.0.0"} Vsn
* @typedef {typeof SOCKET_STATES[keyof typeof SOCKET_STATES]} SocketState
* @typedef {typeof CHANNEL_STATES[keyof typeof CHANNEL_STATES]} ChannelState
* @typedef {typeof CHANNEL_EVENTS[keyof typeof CHANNEL_EVENTS]} ChannelEvent
* @typedef {typeof TRANSPORTS[keyof typeof TRANSPORTS]} Transport
* @typedef {typeof XHR_STATES[keyof typeof XHR_STATES]} XhrState
*/
/**
* PRESENCE
* @typedef {{state: string, diff: string}} PresenceEvents
* @typedef {(key: string, currentPresence: PresenceState, newPresence: PresenceState) => void} PresenceOnJoin
* @typedef {(key: string, currentPresence: PresenceState, leftPresence: PresenceState) => void} PresenceOnLeave
* @typedef {() => void} PresenceOnSync
* @typedef {({joins: PresenceState, leaves: PresenceState})} PresenceDiff
* @typedef {(
* {
* metas: {
* phx_ref?: string
* phx_ref_prev?: string
* [key: string]: any
* }[]
* }
*)} PresenceState
*
* @typedef {Object} PresenceOptions
* @property {PresenceEvents} [events]
*/
/**
* SERIALIZER
* @template T
* @typedef {({
* join_ref?: string | null;
* ref?: string | null;
* event: string;
* topic: string;
* payload: T;
* })} Message
*/
/**
* @template T
* @typedef {(msg: Message<Record<string, any>>, callback: (result: ArrayBuffer | string) => T) => T} Encode
*/
/**
* @template T
* @typedef {(rawPayload: ArrayBuffer | string, callback: (msg: Message<unknown>) => T) => T} Decode
*/
/**
* SOCKET
* @typedef {(typeof WebSocket | typeof LongPoll)} SocketTransport
* @typedef {() => void} SocketOnOpen
* @typedef {(event: CloseEvent) => void} SocketOnClose
* @typedef {(error: Event, transportBefore: SocketTransport, establishedBefore: number) => void} SocketOnError
* @typedef {(rawMessage: Message<unknown>) => void} SocketOnMessage
* @typedef {({
* open: [string, SocketOnOpen][]
* close: [string, SocketOnClose][]
* error: [string, SocketOnError][]
* message: [string, SocketOnMessage][]
* })} SocketStateChangeCallbacks
* @typedef {'sent' | 'ok' | 'error' | 'timeout' | 'disconnected'} HeartbeatStatus
* @typedef {(status: HeartbeatStatus, latency?: number) => void} HeartbeatCallback
*
*
*
* @typedef {Object} SocketOptions
* @property {SocketTransport} [transport] - The Websocket Transport, for example WebSocket or Phoenix.LongPoll.
*
* @property {number} [longPollFallbackMs] - The millisecond time to attempt the primary transport
* before falling back to the LongPoll transport. Disabled by default.
*
* @property {number} [longpollerTimeout] - The millisecond time before LongPoll transport times out. Default 20000.
*
* @property {boolean} [debug] - When true, enables debug logging. Default false.
*
* @property {Encode<void>} [encode] - The function to encode outgoing messages.
* Defaults to JSON encoder.
*
* @property {Decode<void>} [decode] - The function to decode incoming messages.
* Defaults to JSON:
*
* ```javascript
* (payload, callback) => callback(JSON.parse(payload))
* ```
*
* @property {number} [timeout] - The default timeout in milliseconds to trigger push timeouts.
* Defaults `DEFAULT_TIMEOUT`
*
* @property {number} [heartbeatIntervalMs] - The millisec interval to send a heartbeat message
*
* @property {boolean} [autoSendHeartbeat] - Whether to automatically send heartbeats after
* connection is established.
*
* Defaults to true.
*
* @property {HeartbeatCallback} [heartbeatCallback] - The optional function to handle heartbeat status and latency.
*
* @property {(tries: number) => number} [reconnectAfterMs] - The optional function that returns the
* socket reconnect interval, in milliseconds.
*
* Defaults to stepped backoff of:
*
* ```javascript
* function(tries){
* return [10, 50, 100, 150, 200, 250, 500, 1000, 2000][tries - 1] || 5000
* }
* ````
*
* @property {(tries: number) => number} [rejoinAfterMs] - The optional function that returns the millisec
* rejoin interval for individual channels.
*
* ```javascript
* function(tries){
* return [1000, 2000, 5000][tries - 1] || 10000
* }
* ````
*
* @property {(kind: string, msg: string, data: any) => void} [logger] - The optional function for specialized logging, ie:
*
* ```javascript
* function(kind, msg, data) {
* console.log(`${kind}: ${msg}`, data)
* }
* ```
*
* @property {Closure<Params>} [params] - The optional params to pass when connecting
*
* @property {Closure<string>} [authToken] - the optional authentication token to be exposed on the server
* under the `:auth_token` connect_info key. Can be a string or a function that returns a string.
*
* @property {BinaryType} [binaryType] - The binary type to use for binary WebSocket frames.
*
* Defaults to "arraybuffer"
*
* @property {Vsn} [vsn] - The serializer's protocol version to send on connect.
*
* Defaults to DEFAULT_VSN.
*
* @property {Storage} [sessionStorage] - An optional Storage compatible object
* Phoenix uses sessionStorage for longpoll fallback history. Overriding the store is
* useful when Phoenix won't have access to `sessionStorage`. For example, This could
* happen if a site loads a cross-domain channel in an iframe. Example usage:
*
* class InMemoryStorage {
* constructor() { this.storage = {} }
* getItem(keyName) { return this.storage[keyName] || null }
* removeItem(keyName) { delete this.storage[keyName] }
* setItem(keyName, keyValue) { this.storage[keyName] = keyValue }
* }
*
* @property {() => Promise<void>} [beforeReconnect] - Callback ran before socket tries to reconnect.
*
*/
export {}
+16
View File
@@ -0,0 +1,16 @@
/**
*
* Wraps value in closure or returns closure
*
* @template T
* @param {T | (() => T)} value
* @returns {() => T}
*/
export let closure = (value) => {
if(typeof value === "function"){
return /** @type {() => T} */ (value)
} else {
let closure = function (){ return value }
return closure
}
}