lavkach3

Форк
0
475 строк · 13.3 Кб
1
/*
2
WebSockets Extension
3
============================
4
This extension adds support for WebSockets to htmx.  See /www/extensions/ws.md for usage instructions.
5
*/
6

7
(function () {
8

9
	/** @type {import("../htmx").HtmxInternalApi} */
10
	var api;
11

12
	htmx.defineExtension("ws", {
13

14
		/**
15
		 * init is called once, when this extension is first registered.
16
		 * @param {import("../htmx").HtmxInternalApi} apiRef
17
		 */
18
		init: function (apiRef) {
19

20
			// Store reference to internal API
21
			api = apiRef;
22

23
			// Default function for creating new EventSource objects
24
			if (!htmx.createWebSocket) {
25
				htmx.createWebSocket = createWebSocket;
26
			}
27

28
			// Default setting for reconnect delay
29
			if (!htmx.config.wsReconnectDelay) {
30
				htmx.config.wsReconnectDelay = "full-jitter";
31
			}
32
		},
33

34
		/**
35
		 * onEvent handles all events passed to this extension.
36
		 *
37
		 * @param {string} name
38
		 * @param {Event} evt
39
		 */
40
		onEvent: function (name, evt) {
41
			var parent = evt.target || evt.detail.elt;
42

43
			switch (name) {
44

45
				// Try to close the socket when elements are removed
46
				case "htmx:beforeCleanupElement":
47

48
					var internalData = api.getInternalData(parent)
49

50
					if (internalData.webSocket) {
51
						internalData.webSocket.close();
52
					}
53
					return;
54

55
				// Try to create websockets when elements are processed
56
				case "htmx:beforeProcessNode":
57
					forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function (child) {
58
						ensureWebSocket(child)
59
					});
60
					forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) {
61
						ensureWebSocketSend(child)
62
					});
63
			}
64
		}
65
	});
66

67
	function splitOnWhitespace(trigger) {
68
		return trigger.trim().split(/\s+/);
69
	}
70

71
	function getLegacyWebsocketURL(elt) {
72
		var legacySSEValue = api.getAttributeValue(elt, "hx-ws");
73
		if (legacySSEValue) {
74
			var values = splitOnWhitespace(legacySSEValue);
75
			for (var i = 0; i < values.length; i++) {
76
				var value = values[i].split(/:(.+)/);
77
				if (value[0] === "connect") {
78
					return value[1];
79
				}
80
			}
81
		}
82
	}
83

84
	/**
85
	 * ensureWebSocket creates a new WebSocket on the designated element, using
86
	 * the element's "ws-connect" attribute.
87
	 * @param {HTMLElement} socketElt
88
	 * @returns
89
	 */
90
	function ensureWebSocket(socketElt) {
91

92
		// If the element containing the WebSocket connection no longer exists, then
93
		// do not connect/reconnect the WebSocket.
94
		if (!api.bodyContains(socketElt)) {
95
			return;
96
		}
97

98
		// Get the source straight from the element's value
99
		var wssSource = api.getAttributeValue(socketElt, "ws-connect")
100

101
		if (wssSource == null || wssSource === "") {
102
			var legacySource = getLegacyWebsocketURL(socketElt);
103
			if (legacySource == null) {
104
				return;
105
			} else {
106
				wssSource = legacySource;
107
			}
108
		}
109

110
		// Guarantee that the wssSource value is a fully qualified URL
111
		if (wssSource.indexOf("/") === 0) {
112
			var base_part = location.hostname + (location.port ? ':' + location.port : '');
113
			if (location.protocol === 'https:') {
114
				wssSource = "wss://" + base_part + wssSource;
115
			} else if (location.protocol === 'http:') {
116
				wssSource = "ws://" + base_part + wssSource;
117
			}
118
		}
119

120
		var socketWrapper = createWebsocketWrapper(socketElt, function () {
121
			return htmx.createWebSocket(wssSource)
122
		});
123

124
		socketWrapper.addEventListener('message', function (event) {
125
			if (maybeCloseWebSocketSource(socketElt)) {
126
				return;
127
			}
128

129
			var response = event.data;
130
			if (!api.triggerEvent(socketElt, "htmx:wsBeforeMessage", {
131
				message: response,
132
				socketWrapper: socketWrapper.publicInterface
133
			})) {
134
				return;
135
			}
136

137
			api.withExtensions(socketElt, function (extension) {
138
				response = extension.transformResponse(response, null, socketElt);
139
			});
140

141
			var settleInfo = api.makeSettleInfo(socketElt);
142
			var fragment = api.makeFragment(response);
143

144
			if (fragment.children.length) {
145
				var children = Array.from(fragment.children);
146
				for (var i = 0; i < children.length; i++) {
147
					api.oobSwap(api.getAttributeValue(children[i], "hx-swap-oob") || "true", children[i], settleInfo);
148
				}
149
			}
150

151
			api.settleImmediately(settleInfo.tasks);
152
			api.triggerEvent(socketElt, "htmx:wsAfterMessage", { message: response, socketWrapper: socketWrapper.publicInterface })
153
		});
154

155
		// Put the WebSocket into the HTML Element's custom data.
156
		api.getInternalData(socketElt).webSocket = socketWrapper;
157
	}
158

159
	/**
160
	 * @typedef {Object} WebSocketWrapper
161
	 * @property {WebSocket} socket
162
	 * @property {Array<{message: string, sendElt: Element}>} messageQueue
163
	 * @property {number} retryCount
164
	 * @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
165
	 * @property {(message: string, sendElt: Element) => void} send
166
	 * @property {(event: string, handler: Function) => void} addEventListener
167
	 * @property {() => void} handleQueuedMessages
168
	 * @property {() => void} init
169
	 * @property {() => void} close
170
	 */
171
	/**
172
	 *
173
	 * @param socketElt
174
	 * @param socketFunc
175
	 * @returns {WebSocketWrapper}
176
	 */
177
	function createWebsocketWrapper(socketElt, socketFunc) {
178
		var wrapper = {
179
			socket: null,
180
			messageQueue: [],
181
			retryCount: 0,
182

183
			/** @type {Object<string, Function[]>} */
184
			events: {},
185

186
			addEventListener: function (event, handler) {
187
				if (this.socket) {
188
					this.socket.addEventListener(event, handler);
189
				}
190

191
				if (!this.events[event]) {
192
					this.events[event] = [];
193
				}
194

195
				this.events[event].push(handler);
196
			},
197

198
			sendImmediately: function (message, sendElt) {
199
				if (!this.socket) {
200
					api.triggerErrorEvent()
201
				}
202
				if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
203
					message: message,
204
					socketWrapper: this.publicInterface
205
				})) {
206
					this.socket.send(message);
207
					sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
208
						message: message,
209
						socketWrapper: this.publicInterface
210
					})
211
				}
212
			},
213

214
			send: function (message, sendElt) {
215
				if (this.socket.readyState !== this.socket.OPEN) {
216
					this.messageQueue.push({ message: message, sendElt: sendElt });
217
				} else {
218
					this.sendImmediately(message, sendElt);
219
				}
220
			},
221

222
			handleQueuedMessages: function () {
223
				while (this.messageQueue.length > 0) {
224
					var queuedItem = this.messageQueue[0]
225
					if (this.socket.readyState === this.socket.OPEN) {
226
						this.sendImmediately(queuedItem.message, queuedItem.sendElt);
227
						this.messageQueue.shift();
228
					} else {
229
						break;
230
					}
231
				}
232
			},
233

234
			init: function () {
235
				if (this.socket && this.socket.readyState === this.socket.OPEN) {
236
					// Close discarded socket
237
					this.socket.close()
238
				}
239

240
				// Create a new WebSocket and event handlers
241
				/** @type {WebSocket} */
242
				var socket = socketFunc();
243

244
				// The event.type detail is added for interface conformance with the
245
				// other two lifecycle events (open and close) so a single handler method
246
				// can handle them polymorphically, if required.
247
				api.triggerEvent(socketElt, "htmx:wsConnecting", { event: { type: 'connecting' } });
248

249
				this.socket = socket;
250

251
				socket.onopen = function (e) {
252
					wrapper.retryCount = 0;
253
					api.triggerEvent(socketElt, "htmx:wsOpen", { event: e, socketWrapper: wrapper.publicInterface });
254
					wrapper.handleQueuedMessages();
255
				}
256

257
				socket.onclose = function (e) {
258
					// If socket should not be connected, stop further attempts to establish connection
259
					// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
260
					if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
261
						var delay = getWebSocketReconnectDelay(wrapper.retryCount);
262
						setTimeout(function () {
263
							wrapper.retryCount += 1;
264
							wrapper.init();
265
						}, delay);
266
					}
267

268
					// Notify client code that connection has been closed. Client code can inspect `event` field
269
					// to determine whether closure has been valid or abnormal
270
					api.triggerEvent(socketElt, "htmx:wsClose", { event: e, socketWrapper: wrapper.publicInterface })
271
				};
272

273
				socket.onerror = function (e) {
274
					api.triggerErrorEvent(socketElt, "htmx:wsError", { error: e, socketWrapper: wrapper });
275
					maybeCloseWebSocketSource(socketElt);
276
				};
277

278
				var events = this.events;
279
				Object.keys(events).forEach(function (k) {
280
					events[k].forEach(function (e) {
281
						socket.addEventListener(k, e);
282
					})
283
				});
284
			},
285

286
			close: function () {
287
				this.socket.close()
288
			}
289
		}
290

291
		wrapper.init();
292

293
		wrapper.publicInterface = {
294
			send: wrapper.send.bind(wrapper),
295
			sendImmediately: wrapper.sendImmediately.bind(wrapper),
296
			queue: wrapper.messageQueue
297
		};
298

299
		return wrapper;
300
	}
301

302
	/**
303
	 * ensureWebSocketSend attaches trigger handles to elements with
304
	 * "ws-send" attribute
305
	 * @param {HTMLElement} elt
306
	 */
307
	function ensureWebSocketSend(elt) {
308
		var legacyAttribute = api.getAttributeValue(elt, "hx-ws");
309
		if (legacyAttribute && legacyAttribute !== 'send') {
310
			return;
311
		}
312

313
		var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
314
		processWebSocketSend(webSocketParent, elt);
315
	}
316

317
	/**
318
	 * hasWebSocket function checks if a node has webSocket instance attached
319
	 * @param {HTMLElement} node
320
	 * @returns {boolean}
321
	 */
322
	function hasWebSocket(node) {
323
		return api.getInternalData(node).webSocket != null;
324
	}
325

326
	/**
327
	 * processWebSocketSend adds event listeners to the <form> element so that
328
	 * messages can be sent to the WebSocket server when the form is submitted.
329
	 * @param {HTMLElement} socketElt
330
	 * @param {HTMLElement} sendElt
331
	 */
332
	function processWebSocketSend(socketElt, sendElt) {
333
		var nodeData = api.getInternalData(sendElt);
334
		var triggerSpecs = api.getTriggerSpecs(sendElt);
335
		triggerSpecs.forEach(function (ts) {
336
			api.addTriggerHandler(sendElt, ts, nodeData, function (elt, evt) {
337
				if (maybeCloseWebSocketSource(socketElt)) {
338
					return;
339
				}
340

341
				/** @type {WebSocketWrapper} */
342
				var socketWrapper = api.getInternalData(socketElt).webSocket;
343
				var headers = api.getHeaders(sendElt, api.getTarget(sendElt));
344
				var results = api.getInputValues(sendElt, 'post');
345
				var errors = results.errors;
346
				var rawParameters = results.values;
347
				var expressionVars = api.getExpressionVars(sendElt);
348
				var allParameters = api.mergeObjects(rawParameters, expressionVars);
349
				var filteredParameters = api.filterValues(allParameters, sendElt);
350

351
				var sendConfig = {
352
					parameters: filteredParameters,
353
					unfilteredParameters: allParameters,
354
					headers: headers,
355
					errors: errors,
356

357
					triggeringEvent: evt,
358
					messageBody: undefined,
359
					socketWrapper: socketWrapper.publicInterface
360
				};
361

362
				if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
363
					return;
364
				}
365

366
				if (errors && errors.length > 0) {
367
					api.triggerEvent(elt, 'htmx:validation:halted', errors);
368
					return;
369
				}
370

371
				var body = sendConfig.messageBody;
372
				if (body === undefined) {
373
					var toSend = Object.assign({}, sendConfig.parameters);
374
					if (sendConfig.headers)
375
						toSend['HEADERS'] = headers;
376
					body = JSON.stringify(toSend);
377
				}
378

379
				socketWrapper.send(body, elt);
380

381
				if (evt && api.shouldCancel(evt, elt)) {
382
					evt.preventDefault();
383
				}
384
			});
385
		});
386
	}
387

388
	/**
389
	 * getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
390
	 * @param {number} retryCount // The number of retries that have already taken place
391
	 * @returns {number}
392
	 */
393
	function getWebSocketReconnectDelay(retryCount) {
394

395
		/** @type {"full-jitter" | ((retryCount:number) => number)} */
396
		var delay = htmx.config.wsReconnectDelay;
397
		if (typeof delay === 'function') {
398
			return delay(retryCount);
399
		}
400
		if (delay === 'full-jitter') {
401
			var exp = Math.min(retryCount, 6);
402
			var maxDelay = 1000 * Math.pow(2, exp);
403
			return maxDelay * Math.random();
404
		}
405

406
		logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"');
407
	}
408

409
	/**
410
	 * maybeCloseWebSocketSource checks to the if the element that created the WebSocket
411
	 * still exists in the DOM.  If NOT, then the WebSocket is closed and this function
412
	 * returns TRUE.  If the element DOES EXIST, then no action is taken, and this function
413
	 * returns FALSE.
414
	 *
415
	 * @param {*} elt
416
	 * @returns
417
	 */
418
	function maybeCloseWebSocketSource(elt) {
419
		if (!api.bodyContains(elt)) {
420
			api.getInternalData(elt).webSocket.close();
421
			return true;
422
		}
423
		return false;
424
	}
425

426
	/**
427
	 * createWebSocket is the default method for creating new WebSocket objects.
428
	 * it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
429
	 *
430
	 * @param {string} url
431
	 * @returns WebSocket
432
	 */
433
	function createWebSocket(url) {
434
		var sock = new WebSocket(url, []);
435
		sock.binaryType = htmx.config.wsBinaryType;
436
		return sock;
437
	}
438

439
	/**
440
	 * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
441
	 *
442
	 * @param {HTMLElement} elt
443
	 * @param {string} attributeName
444
	 */
445
	function queryAttributeOnThisOrChildren(elt, attributeName) {
446

447
		var result = []
448

449
		// If the parent element also contains the requested attribute, then add it to the results too.
450
		if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-ws")) {
451
			result.push(elt);
452
		}
453

454
		// Search all child nodes that match the requested attribute
455
		elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [data-hx-ws], [hx-ws]").forEach(function (node) {
456
			result.push(node)
457
		})
458

459
		return result
460
	}
461

462
	/**
463
	 * @template T
464
	 * @param {T[]} arr
465
	 * @param {(T) => void} func
466
	 */
467
	function forEach(arr, func) {
468
		if (arr) {
469
			for (var i = 0; i < arr.length; i++) {
470
				func(arr[i]);
471
			}
472
		}
473
	}
474

475
})();
476

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.