mirror of https://github.com/rsp2k/rtc_demo.git
468 lines
14 KiB
JavaScript
468 lines
14 KiB
JavaScript
|
/*
|
||
|
WebSockets Extension
|
||
|
============================
|
||
|
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
|
||
|
*/
|
||
|
|
||
|
(function() {
|
||
|
/** @type {import("../htmx").HtmxInternalApi} */
|
||
|
var api
|
||
|
|
||
|
htmx.defineExtension('ws', {
|
||
|
|
||
|
/**
|
||
|
* init is called once, when this extension is first registered.
|
||
|
* @param {import("../htmx").HtmxInternalApi} apiRef
|
||
|
*/
|
||
|
init: function(apiRef) {
|
||
|
// Store reference to internal API
|
||
|
api = apiRef
|
||
|
|
||
|
// Default function for creating new EventSource objects
|
||
|
if (!htmx.createWebSocket) {
|
||
|
htmx.createWebSocket = createWebSocket
|
||
|
}
|
||
|
|
||
|
// Default setting for reconnect delay
|
||
|
if (!htmx.config.wsReconnectDelay) {
|
||
|
htmx.config.wsReconnectDelay = 'full-jitter'
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* onEvent handles all events passed to this extension.
|
||
|
*
|
||
|
* @param {string} name
|
||
|
* @param {Event} evt
|
||
|
*/
|
||
|
onEvent: function(name, evt) {
|
||
|
var parent = evt.target || evt.detail.elt
|
||
|
switch (name) {
|
||
|
// Try to close the socket when elements are removed
|
||
|
case 'htmx:beforeCleanupElement':
|
||
|
|
||
|
var internalData = api.getInternalData(parent)
|
||
|
|
||
|
if (internalData.webSocket) {
|
||
|
internalData.webSocket.close()
|
||
|
}
|
||
|
return
|
||
|
|
||
|
// Try to create websockets when elements are processed
|
||
|
case 'htmx:beforeProcessNode':
|
||
|
|
||
|
forEach(queryAttributeOnThisOrChildren(parent, 'ws-connect'), function(child) {
|
||
|
ensureWebSocket(child)
|
||
|
})
|
||
|
forEach(queryAttributeOnThisOrChildren(parent, 'ws-send'), function(child) {
|
||
|
ensureWebSocketSend(child)
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
})
|
||
|
|
||
|
function splitOnWhitespace(trigger) {
|
||
|
return trigger.trim().split(/\s+/)
|
||
|
}
|
||
|
|
||
|
function getLegacyWebsocketURL(elt) {
|
||
|
var legacySSEValue = api.getAttributeValue(elt, 'hx-ws')
|
||
|
if (legacySSEValue) {
|
||
|
var values = splitOnWhitespace(legacySSEValue)
|
||
|
for (var i = 0; i < values.length; i++) {
|
||
|
var value = values[i].split(/:(.+)/)
|
||
|
if (value[0] === 'connect') {
|
||
|
return value[1]
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* ensureWebSocket creates a new WebSocket on the designated element, using
|
||
|
* the element's "ws-connect" attribute.
|
||
|
* @param {HTMLElement} socketElt
|
||
|
* @returns
|
||
|
*/
|
||
|
function ensureWebSocket(socketElt) {
|
||
|
// If the element containing the WebSocket connection no longer exists, then
|
||
|
// do not connect/reconnect the WebSocket.
|
||
|
if (!api.bodyContains(socketElt)) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
// Get the source straight from the element's value
|
||
|
var wssSource = api.getAttributeValue(socketElt, 'ws-connect')
|
||
|
|
||
|
if (wssSource == null || wssSource === '') {
|
||
|
var legacySource = getLegacyWebsocketURL(socketElt)
|
||
|
if (legacySource == null) {
|
||
|
return
|
||
|
} else {
|
||
|
wssSource = legacySource
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Guarantee that the wssSource value is a fully qualified URL
|
||
|
if (wssSource.indexOf('/') === 0) {
|
||
|
var base_part = location.hostname + (location.port ? ':' + location.port : '')
|
||
|
if (location.protocol === 'https:') {
|
||
|
wssSource = 'wss://' + base_part + wssSource
|
||
|
} else if (location.protocol === 'http:') {
|
||
|
wssSource = 'ws://' + base_part + wssSource
|
||
|
}
|
||
|
}
|
||
|
|
||
|
var socketWrapper = createWebsocketWrapper(socketElt, function() {
|
||
|
return htmx.createWebSocket(wssSource)
|
||
|
})
|
||
|
|
||
|
socketWrapper.addEventListener('message', function(event) {
|
||
|
if (maybeCloseWebSocketSource(socketElt)) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
var response = event.data
|
||
|
if (!api.triggerEvent(socketElt, 'htmx:wsBeforeMessage', {
|
||
|
message: response,
|
||
|
socketWrapper: socketWrapper.publicInterface
|
||
|
})) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
api.withExtensions(socketElt, function(extension) {
|
||
|
response = extension.transformResponse(response, null, socketElt)
|
||
|
})
|
||
|
|
||
|
var settleInfo = api.makeSettleInfo(socketElt)
|
||
|
var fragment = api.makeFragment(response)
|
||
|
|
||
|
if (fragment.children.length) {
|
||
|
var children = Array.from(fragment.children)
|
||
|
for (var i = 0; i < children.length; i++) {
|
||
|
api.oobSwap(api.getAttributeValue(children[i], 'hx-swap-oob') || 'true', children[i], settleInfo)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
api.settleImmediately(settleInfo.tasks)
|
||
|
api.triggerEvent(socketElt, 'htmx:wsAfterMessage', { message: response, socketWrapper: socketWrapper.publicInterface })
|
||
|
})
|
||
|
|
||
|
// Put the WebSocket into the HTML Element's custom data.
|
||
|
api.getInternalData(socketElt).webSocket = socketWrapper
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @typedef {Object} WebSocketWrapper
|
||
|
* @property {WebSocket} socket
|
||
|
* @property {Array<{message: string, sendElt: Element}>} messageQueue
|
||
|
* @property {number} retryCount
|
||
|
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
|
||
|
* @property {(message: string, sendElt: Element) => void} send
|
||
|
* @property {(event: string, handler: Function) => void} addEventListener
|
||
|
* @property {() => void} handleQueuedMessages
|
||
|
* @property {() => void} init
|
||
|
* @property {() => void} close
|
||
|
*/
|
||
|
/**
|
||
|
*
|
||
|
* @param socketElt
|
||
|
* @param socketFunc
|
||
|
* @returns {WebSocketWrapper}
|
||
|
*/
|
||
|
function createWebsocketWrapper(socketElt, socketFunc) {
|
||
|
var wrapper = {
|
||
|
socket: null,
|
||
|
messageQueue: [],
|
||
|
retryCount: 0,
|
||
|
|
||
|
/** @type {Object<string, Function[]>} */
|
||
|
events: {},
|
||
|
|
||
|
addEventListener: function(event, handler) {
|
||
|
if (this.socket) {
|
||
|
this.socket.addEventListener(event, handler)
|
||
|
}
|
||
|
|
||
|
if (!this.events[event]) {
|
||
|
this.events[event] = []
|
||
|
}
|
||
|
|
||
|
this.events[event].push(handler)
|
||
|
},
|
||
|
|
||
|
sendImmediately: function(message, sendElt) {
|
||
|
if (!this.socket) {
|
||
|
api.triggerErrorEvent()
|
||
|
}
|
||
|
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
|
||
|
message,
|
||
|
socketWrapper: this.publicInterface
|
||
|
})) {
|
||
|
this.socket.send(message)
|
||
|
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
|
||
|
message,
|
||
|
socketWrapper: this.publicInterface
|
||
|
})
|
||
|
}
|
||
|
},
|
||
|
|
||
|
send: function(message, sendElt) {
|
||
|
if (this.socket.readyState !== this.socket.OPEN) {
|
||
|
this.messageQueue.push({ message, sendElt })
|
||
|
} else {
|
||
|
this.sendImmediately(message, sendElt)
|
||
|
}
|
||
|
},
|
||
|
|
||
|
handleQueuedMessages: function() {
|
||
|
while (this.messageQueue.length > 0) {
|
||
|
var queuedItem = this.messageQueue[0]
|
||
|
if (this.socket.readyState === this.socket.OPEN) {
|
||
|
this.sendImmediately(queuedItem.message, queuedItem.sendElt)
|
||
|
this.messageQueue.shift()
|
||
|
} else {
|
||
|
break
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
|
||
|
init: function() {
|
||
|
if (this.socket && this.socket.readyState === this.socket.OPEN) {
|
||
|
// Close discarded socket
|
||
|
this.socket.close()
|
||
|
}
|
||
|
|
||
|
// Create a new WebSocket and event handlers
|
||
|
/** @type {WebSocket} */
|
||
|
var socket = socketFunc()
|
||
|
|
||
|
// The event.type detail is added for interface conformance with the
|
||
|
// other two lifecycle events (open and close) so a single handler method
|
||
|
// can handle them polymorphically, if required.
|
||
|
api.triggerEvent(socketElt, 'htmx:wsConnecting', { event: { type: 'connecting' } })
|
||
|
|
||
|
this.socket = socket
|
||
|
|
||
|
socket.onopen = function(e) {
|
||
|
wrapper.retryCount = 0
|
||
|
api.triggerEvent(socketElt, 'htmx:wsOpen', { event: e, socketWrapper: wrapper.publicInterface })
|
||
|
wrapper.handleQueuedMessages()
|
||
|
}
|
||
|
|
||
|
socket.onclose = function(e) {
|
||
|
// If socket should not be connected, stop further attempts to establish connection
|
||
|
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
|
||
|
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
|
||
|
var delay = getWebSocketReconnectDelay(wrapper.retryCount)
|
||
|
setTimeout(function() {
|
||
|
wrapper.retryCount += 1
|
||
|
wrapper.init()
|
||
|
}, delay)
|
||
|
}
|
||
|
|
||
|
// Notify client code that connection has been closed. Client code can inspect `event` field
|
||
|
// to determine whether closure has been valid or abnormal
|
||
|
api.triggerEvent(socketElt, 'htmx:wsClose', { event: e, socketWrapper: wrapper.publicInterface })
|
||
|
}
|
||
|
|
||
|
socket.onerror = function(e) {
|
||
|
api.triggerErrorEvent(socketElt, 'htmx:wsError', { error: e, socketWrapper: wrapper })
|
||
|
maybeCloseWebSocketSource(socketElt)
|
||
|
}
|
||
|
|
||
|
var events = this.events
|
||
|
Object.keys(events).forEach(function(k) {
|
||
|
events[k].forEach(function(e) {
|
||
|
socket.addEventListener(k, e)
|
||
|
})
|
||
|
})
|
||
|
},
|
||
|
|
||
|
close: function() {
|
||
|
this.socket.close()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
wrapper.init()
|
||
|
|
||
|
wrapper.publicInterface = {
|
||
|
send: wrapper.send.bind(wrapper),
|
||
|
sendImmediately: wrapper.sendImmediately.bind(wrapper),
|
||
|
queue: wrapper.messageQueue
|
||
|
}
|
||
|
|
||
|
return wrapper
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* ensureWebSocketSend attaches trigger handles to elements with
|
||
|
* "ws-send" attribute
|
||
|
* @param {HTMLElement} elt
|
||
|
*/
|
||
|
function ensureWebSocketSend(elt) {
|
||
|
var legacyAttribute = api.getAttributeValue(elt, 'hx-ws')
|
||
|
if (legacyAttribute && legacyAttribute !== 'send') {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
|
||
|
processWebSocketSend(webSocketParent, elt)
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* hasWebSocket function checks if a node has webSocket instance attached
|
||
|
* @param {HTMLElement} node
|
||
|
* @returns {boolean}
|
||
|
*/
|
||
|
function hasWebSocket(node) {
|
||
|
return api.getInternalData(node).webSocket != null
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* processWebSocketSend adds event listeners to the <form> element so that
|
||
|
* messages can be sent to the WebSocket server when the form is submitted.
|
||
|
* @param {HTMLElement} socketElt
|
||
|
* @param {HTMLElement} sendElt
|
||
|
*/
|
||
|
function processWebSocketSend(socketElt, sendElt) {
|
||
|
var nodeData = api.getInternalData(sendElt)
|
||
|
var triggerSpecs = api.getTriggerSpecs(sendElt)
|
||
|
triggerSpecs.forEach(function(ts) {
|
||
|
api.addTriggerHandler(sendElt, ts, nodeData, function(elt, evt) {
|
||
|
if (maybeCloseWebSocketSource(socketElt)) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
/** @type {WebSocketWrapper} */
|
||
|
var socketWrapper = api.getInternalData(socketElt).webSocket
|
||
|
var headers = api.getHeaders(sendElt, api.getTarget(sendElt))
|
||
|
var results = api.getInputValues(sendElt, 'post')
|
||
|
var errors = results.errors
|
||
|
var rawParameters = Object.assign({}, results.values)
|
||
|
var expressionVars = api.getExpressionVars(sendElt)
|
||
|
var allParameters = api.mergeObjects(rawParameters, expressionVars)
|
||
|
var filteredParameters = api.filterValues(allParameters, sendElt)
|
||
|
|
||
|
var sendConfig = {
|
||
|
parameters: filteredParameters,
|
||
|
unfilteredParameters: allParameters,
|
||
|
headers,
|
||
|
errors,
|
||
|
|
||
|
triggeringEvent: evt,
|
||
|
messageBody: undefined,
|
||
|
socketWrapper: socketWrapper.publicInterface
|
||
|
}
|
||
|
|
||
|
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if (errors && errors.length > 0) {
|
||
|
api.triggerEvent(elt, 'htmx:validation:halted', errors)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
var body = sendConfig.messageBody
|
||
|
if (body === undefined) {
|
||
|
var toSend = Object.assign({}, sendConfig.parameters)
|
||
|
if (sendConfig.headers) { toSend.HEADERS = headers }
|
||
|
body = JSON.stringify(toSend)
|
||
|
}
|
||
|
|
||
|
socketWrapper.send(body, elt)
|
||
|
|
||
|
if (evt && api.shouldCancel(evt, elt)) {
|
||
|
evt.preventDefault()
|
||
|
}
|
||
|
})
|
||
|
})
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
|
||
|
* @param {number} retryCount // The number of retries that have already taken place
|
||
|
* @returns {number}
|
||
|
*/
|
||
|
function getWebSocketReconnectDelay(retryCount) {
|
||
|
/** @type {"full-jitter" | ((retryCount:number) => number)} */
|
||
|
var delay = htmx.config.wsReconnectDelay
|
||
|
if (typeof delay === 'function') {
|
||
|
return delay(retryCount)
|
||
|
}
|
||
|
if (delay === 'full-jitter') {
|
||
|
var exp = Math.min(retryCount, 6)
|
||
|
var maxDelay = 1000 * Math.pow(2, exp)
|
||
|
return maxDelay * Math.random()
|
||
|
}
|
||
|
|
||
|
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
|
||
|
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
|
||
|
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
|
||
|
* returns FALSE.
|
||
|
*
|
||
|
* @param {*} elt
|
||
|
* @returns
|
||
|
*/
|
||
|
function maybeCloseWebSocketSource(elt) {
|
||
|
if (!api.bodyContains(elt)) {
|
||
|
api.getInternalData(elt).webSocket.close()
|
||
|
return true
|
||
|
}
|
||
|
return false
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* createWebSocket is the default method for creating new WebSocket objects.
|
||
|
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
|
||
|
*
|
||
|
* @param {string} url
|
||
|
* @returns WebSocket
|
||
|
*/
|
||
|
function createWebSocket(url) {
|
||
|
var sock = new WebSocket(url, [])
|
||
|
sock.binaryType = htmx.config.wsBinaryType
|
||
|
return sock
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
|
||
|
*
|
||
|
* @param {HTMLElement} elt
|
||
|
* @param {string} attributeName
|
||
|
*/
|
||
|
function queryAttributeOnThisOrChildren(elt, attributeName) {
|
||
|
var result = []
|
||
|
|
||
|
// If the parent element also contains the requested attribute, then add it to the results too.
|
||
|
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, 'hx-ws')) {
|
||
|
result.push(elt)
|
||
|
}
|
||
|
|
||
|
// Search all child nodes that match the requested attribute
|
||
|
elt.querySelectorAll('[' + attributeName + '], [data-' + attributeName + '], [data-hx-ws], [hx-ws]').forEach(function(node) {
|
||
|
result.push(node)
|
||
|
})
|
||
|
|
||
|
return result
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @template T
|
||
|
* @param {T[]} arr
|
||
|
* @param {(T) => void} func
|
||
|
*/
|
||
|
function forEach(arr, func) {
|
||
|
if (arr) {
|
||
|
for (var i = 0; i < arr.length; i++) {
|
||
|
func(arr[i])
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
})()
|