htmx.org 4.0.0-alpha5 → 4.0.0-alpha6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ext/hx-upsert.js +89 -0
- package/dist/ext/hx-upsert.min.js +1 -0
- package/dist/ext/hx-upsert.min.js.map +1 -0
- package/dist/ext/hx-ws.js +292 -145
- package/dist/ext/hx-ws.min.js +1 -1
- package/dist/ext/hx-ws.min.js.map +1 -1
- package/dist/htmx.esm.js +178 -154
- package/dist/htmx.esm.min.js +1 -1
- package/dist/htmx.esm.min.js.map +1 -1
- package/dist/htmx.js +178 -154
- package/dist/htmx.min.js +1 -1
- package/dist/htmx.min.js.map +1 -1
- package/package.json +3 -4
package/dist/ext/hx-ws.js
CHANGED
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
(() => {
|
|
2
2
|
let api;
|
|
3
3
|
|
|
4
|
+
// ========================================
|
|
5
|
+
// ATTRIBUTE HELPERS
|
|
6
|
+
// ========================================
|
|
7
|
+
|
|
8
|
+
// Helper to build proper attribute name respecting htmx prefix
|
|
9
|
+
function buildAttrName(suffix) {
|
|
10
|
+
// htmx.config.prefix replaces 'hx-' entirely, e.g. 'data-hx-'
|
|
11
|
+
// So 'hx-ws:connect' becomes 'data-hx-ws:connect'
|
|
12
|
+
let prefix = htmx.config.prefix || 'hx-';
|
|
13
|
+
return prefix + 'ws' + suffix;
|
|
14
|
+
}
|
|
15
|
+
|
|
4
16
|
// Helper to get attribute value, checking colon, hyphen, and plain variants
|
|
17
|
+
// Uses api.attributeValue for automatic prefix handling and inheritance support
|
|
5
18
|
function getWsAttribute(element, attrName) {
|
|
6
|
-
// Try colon variant first (hx-ws:connect)
|
|
19
|
+
// Try colon variant first (hx-ws:connect) - prefix applied automatically by htmx
|
|
7
20
|
let colonValue = api.attributeValue(element, 'hx-ws:' + attrName);
|
|
8
|
-
if (colonValue
|
|
21
|
+
if (colonValue != null) return colonValue;
|
|
9
22
|
|
|
10
23
|
// Try hyphen variant for JSX (hx-ws-connect)
|
|
11
24
|
let hyphenValue = api.attributeValue(element, 'hx-ws-' + attrName);
|
|
12
|
-
if (hyphenValue
|
|
25
|
+
if (hyphenValue != null) return hyphenValue;
|
|
13
26
|
|
|
14
27
|
// For 'send', also check plain 'hx-ws' (marker attribute)
|
|
15
28
|
if (attrName === 'send') {
|
|
16
29
|
let plainValue = api.attributeValue(element, 'hx-ws');
|
|
17
|
-
if (plainValue
|
|
30
|
+
if (plainValue != null) return plainValue;
|
|
18
31
|
}
|
|
19
32
|
|
|
20
33
|
return null;
|
|
@@ -26,6 +39,14 @@
|
|
|
26
39
|
return value !== null && value !== undefined;
|
|
27
40
|
}
|
|
28
41
|
|
|
42
|
+
// Build selector for WS attributes
|
|
43
|
+
function buildWsSelector(attrName) {
|
|
44
|
+
let colonAttr = buildAttrName(':' + attrName);
|
|
45
|
+
let hyphenAttr = buildAttrName('-' + attrName);
|
|
46
|
+
// Escape colon for CSS selector
|
|
47
|
+
return `[${colonAttr.replace(':', '\\:')}],[${hyphenAttr}]`;
|
|
48
|
+
}
|
|
49
|
+
|
|
29
50
|
// ========================================
|
|
30
51
|
// CONFIGURATION
|
|
31
52
|
// ========================================
|
|
@@ -36,12 +57,50 @@
|
|
|
36
57
|
reconnectDelay: 1000,
|
|
37
58
|
reconnectMaxDelay: 30000,
|
|
38
59
|
reconnectJitter: true,
|
|
39
|
-
|
|
40
|
-
|
|
60
|
+
// Note: pauseInBackground is NOT implemented. Reconnection continues in background tabs.
|
|
61
|
+
// To implement visibility-aware behavior, listen for htmx:ws:reconnect and cancel if needed.
|
|
62
|
+
pendingRequestTTL: 30000 // TTL for pending requests in ms
|
|
41
63
|
};
|
|
42
64
|
return { ...defaults, ...(htmx.config.websockets || {}) };
|
|
43
65
|
}
|
|
44
66
|
|
|
67
|
+
// ========================================
|
|
68
|
+
// URL NORMALIZATION
|
|
69
|
+
// ========================================
|
|
70
|
+
|
|
71
|
+
function normalizeWebSocketUrl(url) {
|
|
72
|
+
// Already a WebSocket URL
|
|
73
|
+
if (url.startsWith('ws://') || url.startsWith('wss://')) {
|
|
74
|
+
return url;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Convert http(s):// to ws(s)://
|
|
78
|
+
if (url.startsWith('http://')) {
|
|
79
|
+
return 'ws://' + url.slice(7);
|
|
80
|
+
}
|
|
81
|
+
if (url.startsWith('https://')) {
|
|
82
|
+
return 'wss://' + url.slice(8);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Relative URL - build absolute ws(s):// URL based on current location
|
|
86
|
+
let protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
87
|
+
let host = window.location.host;
|
|
88
|
+
|
|
89
|
+
if (url.startsWith('//')) {
|
|
90
|
+
// Protocol-relative URL
|
|
91
|
+
return protocol + url;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (url.startsWith('/')) {
|
|
95
|
+
// Absolute path
|
|
96
|
+
return protocol + '//' + host + url;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Relative path - resolve against current location
|
|
100
|
+
let basePath = window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/') + 1);
|
|
101
|
+
return protocol + '//' + host + basePath + url;
|
|
102
|
+
}
|
|
103
|
+
|
|
45
104
|
// ========================================
|
|
46
105
|
// CONNECTION REGISTRY
|
|
47
106
|
// ========================================
|
|
@@ -49,44 +108,52 @@
|
|
|
49
108
|
const connectionRegistry = new Map();
|
|
50
109
|
|
|
51
110
|
function getOrCreateConnection(url, element) {
|
|
52
|
-
|
|
53
|
-
|
|
111
|
+
let normalizedUrl = normalizeWebSocketUrl(url);
|
|
112
|
+
|
|
113
|
+
if (connectionRegistry.has(normalizedUrl)) {
|
|
114
|
+
let entry = connectionRegistry.get(normalizedUrl);
|
|
54
115
|
entry.refCount++;
|
|
55
116
|
entry.elements.add(element);
|
|
56
117
|
return entry;
|
|
57
118
|
}
|
|
58
119
|
|
|
120
|
+
// Create entry but DON'T add to registry yet - wait for before:ws:connect
|
|
59
121
|
let entry = {
|
|
122
|
+
url: normalizedUrl,
|
|
60
123
|
socket: null,
|
|
61
124
|
refCount: 1,
|
|
62
125
|
elements: new Set([element]),
|
|
63
126
|
reconnectAttempts: 0,
|
|
64
127
|
reconnectTimer: null,
|
|
65
|
-
pendingRequests: new Map()
|
|
128
|
+
pendingRequests: new Map(),
|
|
129
|
+
listeners: {} // Store listener references for proper cleanup
|
|
66
130
|
};
|
|
67
131
|
|
|
68
|
-
|
|
69
|
-
|
|
132
|
+
// Fire cancelable event BEFORE storing in registry
|
|
133
|
+
if (!triggerEvent(element, 'htmx:before:ws:connect', { url: normalizedUrl })) {
|
|
134
|
+
// Event was cancelled - don't create connection or store entry
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Event passed - now store in registry and create socket
|
|
139
|
+
connectionRegistry.set(normalizedUrl, entry);
|
|
140
|
+
createWebSocket(normalizedUrl, entry);
|
|
70
141
|
return entry;
|
|
71
142
|
}
|
|
72
143
|
|
|
73
144
|
function createWebSocket(url, entry) {
|
|
74
145
|
let firstElement = entry.elements.values().next().value;
|
|
75
|
-
if (firstElement) {
|
|
76
|
-
if (!triggerEvent(firstElement, 'htmx:before:ws:connect', { url })) {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
146
|
|
|
81
|
-
// Close and remove listeners from old socket
|
|
147
|
+
// Close and remove listeners from old socket properly
|
|
82
148
|
if (entry.socket) {
|
|
83
149
|
let oldSocket = entry.socket;
|
|
84
150
|
entry.socket = null;
|
|
85
151
|
|
|
86
|
-
|
|
87
|
-
oldSocket.
|
|
88
|
-
oldSocket.
|
|
89
|
-
oldSocket.
|
|
152
|
+
// Remove listeners using stored references
|
|
153
|
+
if (entry.listeners.open) oldSocket.removeEventListener('open', entry.listeners.open);
|
|
154
|
+
if (entry.listeners.message) oldSocket.removeEventListener('message', entry.listeners.message);
|
|
155
|
+
if (entry.listeners.close) oldSocket.removeEventListener('close', entry.listeners.close);
|
|
156
|
+
if (entry.listeners.error) oldSocket.removeEventListener('error', entry.listeners.error);
|
|
90
157
|
|
|
91
158
|
try {
|
|
92
159
|
if (oldSocket.readyState === WebSocket.OPEN || oldSocket.readyState === WebSocket.CONNECTING) {
|
|
@@ -98,24 +165,30 @@
|
|
|
98
165
|
try {
|
|
99
166
|
entry.socket = new WebSocket(url);
|
|
100
167
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
//
|
|
168
|
+
// Create and store listener references
|
|
169
|
+
entry.listeners.open = () => {
|
|
170
|
+
// Reset reconnect attempts on successful connection
|
|
171
|
+
entry.reconnectAttempts = 0;
|
|
172
|
+
|
|
104
173
|
if (firstElement) {
|
|
105
174
|
triggerEvent(firstElement, 'htmx:after:ws:connect', { url, socket: entry.socket });
|
|
106
175
|
}
|
|
107
|
-
}
|
|
176
|
+
};
|
|
108
177
|
|
|
109
|
-
entry.
|
|
178
|
+
entry.listeners.message = (event) => {
|
|
110
179
|
handleMessage(entry, event);
|
|
111
|
-
}
|
|
180
|
+
};
|
|
112
181
|
|
|
113
|
-
entry.
|
|
182
|
+
entry.listeners.close = (event) => {
|
|
114
183
|
// Check if this socket is still the active one
|
|
115
184
|
if (event.target !== entry.socket) return;
|
|
116
185
|
|
|
117
186
|
if (firstElement) {
|
|
118
|
-
triggerEvent(firstElement, 'htmx:ws:close', {
|
|
187
|
+
triggerEvent(firstElement, 'htmx:ws:close', {
|
|
188
|
+
url,
|
|
189
|
+
code: event.code,
|
|
190
|
+
reason: event.reason
|
|
191
|
+
});
|
|
119
192
|
}
|
|
120
193
|
|
|
121
194
|
// Check if entry is still valid (not cleared)
|
|
@@ -125,15 +198,23 @@
|
|
|
125
198
|
if (config.reconnect && entry.refCount > 0) {
|
|
126
199
|
scheduleReconnect(url, entry);
|
|
127
200
|
} else {
|
|
201
|
+
cleanupPendingRequests(entry);
|
|
128
202
|
connectionRegistry.delete(url);
|
|
129
203
|
}
|
|
130
|
-
}
|
|
204
|
+
};
|
|
131
205
|
|
|
132
|
-
entry.
|
|
206
|
+
entry.listeners.error = (error) => {
|
|
133
207
|
if (firstElement) {
|
|
134
208
|
triggerEvent(firstElement, 'htmx:ws:error', { url, error });
|
|
135
209
|
}
|
|
136
|
-
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Add listeners
|
|
213
|
+
entry.socket.addEventListener('open', entry.listeners.open);
|
|
214
|
+
entry.socket.addEventListener('message', entry.listeners.message);
|
|
215
|
+
entry.socket.addEventListener('close', entry.listeners.close);
|
|
216
|
+
entry.socket.addEventListener('error', entry.listeners.error);
|
|
217
|
+
|
|
137
218
|
} catch (error) {
|
|
138
219
|
if (firstElement) {
|
|
139
220
|
triggerEvent(firstElement, 'htmx:ws:error', { url, error });
|
|
@@ -144,12 +225,12 @@
|
|
|
144
225
|
function scheduleReconnect(url, entry) {
|
|
145
226
|
let config = getConfig();
|
|
146
227
|
|
|
147
|
-
// Increment attempts
|
|
148
|
-
let attempts = entry.reconnectAttempts;
|
|
228
|
+
// Increment attempts FIRST, then calculate delay
|
|
149
229
|
entry.reconnectAttempts++;
|
|
230
|
+
let attempts = entry.reconnectAttempts;
|
|
150
231
|
|
|
151
232
|
let delay = Math.min(
|
|
152
|
-
(config.reconnectDelay || 1000) * Math.pow(2, attempts),
|
|
233
|
+
(config.reconnectDelay || 1000) * Math.pow(2, attempts - 1),
|
|
153
234
|
config.reconnectMaxDelay || 30000
|
|
154
235
|
);
|
|
155
236
|
|
|
@@ -161,6 +242,7 @@
|
|
|
161
242
|
if (entry.refCount > 0) {
|
|
162
243
|
let firstElement = entry.elements.values().next().value;
|
|
163
244
|
if (firstElement) {
|
|
245
|
+
// attempts now means "this is attempt number N"
|
|
164
246
|
triggerEvent(firstElement, 'htmx:ws:reconnect', { url, attempts });
|
|
165
247
|
}
|
|
166
248
|
createWebSocket(url, entry);
|
|
@@ -169,9 +251,12 @@
|
|
|
169
251
|
}
|
|
170
252
|
|
|
171
253
|
function decrementRef(url, element) {
|
|
172
|
-
|
|
254
|
+
// Try both original and normalized URL
|
|
255
|
+
let normalizedUrl = normalizeWebSocketUrl(url);
|
|
173
256
|
|
|
174
|
-
|
|
257
|
+
if (!connectionRegistry.has(normalizedUrl)) return;
|
|
258
|
+
|
|
259
|
+
let entry = connectionRegistry.get(normalizedUrl);
|
|
175
260
|
entry.elements.delete(element);
|
|
176
261
|
entry.refCount--;
|
|
177
262
|
|
|
@@ -179,10 +264,31 @@
|
|
|
179
264
|
if (entry.reconnectTimer) {
|
|
180
265
|
clearTimeout(entry.reconnectTimer);
|
|
181
266
|
}
|
|
267
|
+
cleanupPendingRequests(entry);
|
|
182
268
|
if (entry.socket && entry.socket.readyState === WebSocket.OPEN) {
|
|
183
269
|
entry.socket.close();
|
|
184
270
|
}
|
|
185
|
-
connectionRegistry.delete(
|
|
271
|
+
connectionRegistry.delete(normalizedUrl);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ========================================
|
|
276
|
+
// PENDING REQUEST MANAGEMENT
|
|
277
|
+
// ========================================
|
|
278
|
+
|
|
279
|
+
function cleanupPendingRequests(entry) {
|
|
280
|
+
entry.pendingRequests.clear();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function cleanupExpiredRequests(entry) {
|
|
284
|
+
let config = getConfig();
|
|
285
|
+
let now = Date.now();
|
|
286
|
+
let ttl = config.pendingRequestTTL || 30000;
|
|
287
|
+
|
|
288
|
+
for (let [requestId, pending] of entry.pendingRequests) {
|
|
289
|
+
if (now - pending.timestamp > ttl) {
|
|
290
|
+
entry.pendingRequests.delete(requestId);
|
|
291
|
+
}
|
|
186
292
|
}
|
|
187
293
|
}
|
|
188
294
|
|
|
@@ -190,37 +296,83 @@
|
|
|
190
296
|
// MESSAGE SENDING
|
|
191
297
|
// ========================================
|
|
192
298
|
|
|
193
|
-
|
|
299
|
+
// Check if a value looks like a URL (vs a boolean marker like "" or "true")
|
|
300
|
+
function looksLikeUrl(value) {
|
|
301
|
+
if (!value) return false;
|
|
302
|
+
// Check for URL-like patterns: paths, protocols, protocol-relative
|
|
303
|
+
return value.startsWith('/') ||
|
|
304
|
+
value.startsWith('.') ||
|
|
305
|
+
value.startsWith('ws:') ||
|
|
306
|
+
value.startsWith('wss:') ||
|
|
307
|
+
value.startsWith('http:') ||
|
|
308
|
+
value.startsWith('https:') ||
|
|
309
|
+
value.startsWith('//');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function sendMessage(element, event) {
|
|
194
313
|
// Find connection URL
|
|
195
314
|
let url = getWsAttribute(element, 'send');
|
|
196
|
-
if (!url) {
|
|
197
|
-
//
|
|
198
|
-
let
|
|
199
|
-
let ancestor = element.closest(
|
|
315
|
+
if (!looksLikeUrl(url)) {
|
|
316
|
+
// Value is empty, "true", or other non-URL marker - look for ancestor connection
|
|
317
|
+
let selector = buildWsSelector('connect');
|
|
318
|
+
let ancestor = element.closest(selector);
|
|
200
319
|
if (ancestor) {
|
|
201
320
|
url = getWsAttribute(ancestor, 'connect');
|
|
321
|
+
} else {
|
|
322
|
+
url = null;
|
|
202
323
|
}
|
|
203
324
|
}
|
|
204
325
|
|
|
205
326
|
if (!url) {
|
|
206
|
-
|
|
327
|
+
// Emit error event instead of console.error
|
|
328
|
+
triggerEvent(element, 'htmx:wsSendError', {
|
|
329
|
+
element,
|
|
330
|
+
error: 'No WebSocket connection found for element'
|
|
331
|
+
});
|
|
207
332
|
return;
|
|
208
333
|
}
|
|
209
334
|
|
|
210
|
-
let
|
|
335
|
+
let normalizedUrl = normalizeWebSocketUrl(url);
|
|
336
|
+
let entry = connectionRegistry.get(normalizedUrl);
|
|
211
337
|
if (!entry || !entry.socket || entry.socket.readyState !== WebSocket.OPEN) {
|
|
212
|
-
triggerEvent(element, 'htmx:wsSendError', { url, error: 'Connection not open' });
|
|
338
|
+
triggerEvent(element, 'htmx:wsSendError', { url: normalizedUrl, error: 'Connection not open' });
|
|
213
339
|
return;
|
|
214
340
|
}
|
|
215
341
|
|
|
342
|
+
// Cleanup expired pending requests periodically
|
|
343
|
+
cleanupExpiredRequests(entry);
|
|
344
|
+
|
|
216
345
|
// Build message
|
|
217
346
|
let form = element.form || element.closest('form');
|
|
218
347
|
let body = api.collectFormData(element, form, event.submitter);
|
|
219
|
-
api.handleHxVals(element, body);
|
|
348
|
+
let valsResult = api.handleHxVals(element, body);
|
|
349
|
+
if (valsResult) await valsResult;
|
|
220
350
|
|
|
351
|
+
// Preserve multi-value form fields (checkboxes, multi-selects)
|
|
221
352
|
let values = {};
|
|
222
353
|
for (let [key, value] of body) {
|
|
223
|
-
|
|
354
|
+
if (key in values) {
|
|
355
|
+
// Convert to array if needed
|
|
356
|
+
if (!Array.isArray(values[key])) {
|
|
357
|
+
values[key] = [values[key]];
|
|
358
|
+
}
|
|
359
|
+
values[key].push(value);
|
|
360
|
+
} else {
|
|
361
|
+
values[key] = value;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Build headers object
|
|
366
|
+
let headers = {
|
|
367
|
+
'HX-Request': 'true',
|
|
368
|
+
'HX-Current-URL': window.location.href
|
|
369
|
+
};
|
|
370
|
+
if (element.id) {
|
|
371
|
+
headers['HX-Trigger'] = element.id;
|
|
372
|
+
}
|
|
373
|
+
let targetAttr = api.attributeValue(element, 'hx-target');
|
|
374
|
+
if (targetAttr) {
|
|
375
|
+
headers['HX-Target'] = targetAttr;
|
|
224
376
|
}
|
|
225
377
|
|
|
226
378
|
let requestId = generateUUID();
|
|
@@ -228,29 +380,30 @@
|
|
|
228
380
|
type: 'request',
|
|
229
381
|
request_id: requestId,
|
|
230
382
|
event: event.type,
|
|
383
|
+
headers: headers,
|
|
231
384
|
values: values,
|
|
232
|
-
path:
|
|
385
|
+
path: normalizedUrl
|
|
233
386
|
};
|
|
234
387
|
|
|
235
388
|
if (element.id) {
|
|
236
389
|
message.id = element.id;
|
|
237
390
|
}
|
|
238
391
|
|
|
239
|
-
// Allow modification via event
|
|
240
|
-
let detail = { message, element, url };
|
|
392
|
+
// Allow modification via event - use 'data' as documented
|
|
393
|
+
let detail = { data: message, element, url: normalizedUrl };
|
|
241
394
|
if (!triggerEvent(element, 'htmx:before:ws:send', detail)) {
|
|
242
395
|
return;
|
|
243
396
|
}
|
|
244
397
|
|
|
245
398
|
try {
|
|
246
|
-
entry.socket.send(JSON.stringify(detail.
|
|
399
|
+
entry.socket.send(JSON.stringify(detail.data));
|
|
247
400
|
|
|
248
401
|
// Store pending request for response matching
|
|
249
402
|
entry.pendingRequests.set(requestId, { element, timestamp: Date.now() });
|
|
250
403
|
|
|
251
|
-
triggerEvent(element, 'htmx:after:ws:send', {
|
|
404
|
+
triggerEvent(element, 'htmx:after:ws:send', { data: detail.data, url: normalizedUrl });
|
|
252
405
|
} catch (error) {
|
|
253
|
-
triggerEvent(element, 'htmx:wsSendError', { url, error });
|
|
406
|
+
triggerEvent(element, 'htmx:wsSendError', { url: normalizedUrl, error });
|
|
254
407
|
}
|
|
255
408
|
}
|
|
256
409
|
|
|
@@ -271,10 +424,10 @@
|
|
|
271
424
|
try {
|
|
272
425
|
envelope = JSON.parse(event.data);
|
|
273
426
|
} catch (e) {
|
|
274
|
-
// Not JSON, emit unknown message event
|
|
427
|
+
// Not JSON, emit unknown message event for parse failures
|
|
275
428
|
let firstElement = entry.elements.values().next().value;
|
|
276
429
|
if (firstElement) {
|
|
277
|
-
triggerEvent(firstElement, 'htmx:wsUnknownMessage', { data: event.data });
|
|
430
|
+
triggerEvent(firstElement, 'htmx:wsUnknownMessage', { data: event.data, parseError: e });
|
|
278
431
|
}
|
|
279
432
|
return;
|
|
280
433
|
}
|
|
@@ -301,46 +454,45 @@
|
|
|
301
454
|
// Route based on channel
|
|
302
455
|
if (envelope.channel === 'ui' && envelope.format === 'html') {
|
|
303
456
|
handleHtmlMessage(targetElement, envelope);
|
|
304
|
-
} else if (envelope.channel && (envelope.channel === 'audio' || envelope.channel === 'json' || envelope.channel === 'binary')) {
|
|
305
|
-
// Known custom channel - emit event for extensions to handle
|
|
306
|
-
triggerEvent(targetElement, 'htmx:wsMessage', { ...envelope, element: targetElement });
|
|
307
457
|
} else {
|
|
308
|
-
//
|
|
309
|
-
|
|
458
|
+
// Any non-ui/html message emits htmx:wsMessage for application handling
|
|
459
|
+
// This is extensible - apps can handle json, audio, binary, custom channels, etc.
|
|
460
|
+
triggerEvent(targetElement, 'htmx:wsMessage', { ...envelope, element: targetElement });
|
|
310
461
|
}
|
|
311
462
|
|
|
312
463
|
triggerEvent(targetElement, 'htmx:after:ws:message', { envelope, element: targetElement });
|
|
313
464
|
}
|
|
314
465
|
|
|
315
466
|
// ========================================
|
|
316
|
-
// HTML PARTIAL HANDLING
|
|
467
|
+
// HTML PARTIAL HANDLING - Using htmx.swap(ctx)
|
|
317
468
|
// ========================================
|
|
318
469
|
|
|
319
470
|
function handleHtmlMessage(element, envelope) {
|
|
320
471
|
let parser = new DOMParser();
|
|
321
|
-
let doc = parser.parseFromString(envelope.payload, 'text/html');
|
|
472
|
+
let doc = parser.parseFromString(envelope.payload || '', 'text/html');
|
|
322
473
|
|
|
323
|
-
// Find all hx-partial elements
|
|
474
|
+
// Find all hx-partial elements (legacy format)
|
|
324
475
|
let partials = doc.querySelectorAll('hx-partial');
|
|
325
476
|
|
|
326
477
|
if (partials.length === 0) {
|
|
327
478
|
// No partials, treat entire payload as content for element's target
|
|
328
479
|
let target = resolveTarget(element, envelope.target);
|
|
329
480
|
if (target) {
|
|
330
|
-
|
|
481
|
+
swapWithHtmx(target, envelope.payload, element, envelope.swap);
|
|
331
482
|
}
|
|
332
483
|
return;
|
|
333
484
|
}
|
|
334
485
|
|
|
335
|
-
|
|
486
|
+
// Process each partial
|
|
487
|
+
for (let partial of partials) {
|
|
336
488
|
let targetId = partial.getAttribute('id');
|
|
337
|
-
if (!targetId)
|
|
489
|
+
if (!targetId) continue;
|
|
338
490
|
|
|
339
491
|
let target = document.getElementById(targetId);
|
|
340
|
-
if (!target)
|
|
492
|
+
if (!target) continue;
|
|
341
493
|
|
|
342
|
-
|
|
343
|
-
}
|
|
494
|
+
swapWithHtmx(target, partial.innerHTML, element);
|
|
495
|
+
}
|
|
344
496
|
}
|
|
345
497
|
|
|
346
498
|
function resolveTarget(element, envelopeTarget) {
|
|
@@ -360,54 +512,28 @@
|
|
|
360
512
|
return element;
|
|
361
513
|
}
|
|
362
514
|
|
|
363
|
-
function
|
|
515
|
+
function swapWithHtmx(target, content, sourceElement, envelopeSwap) {
|
|
516
|
+
// Determine swap style from envelope, element attribute, or default
|
|
364
517
|
let swapStyle = envelopeSwap || api.attributeValue(sourceElement, 'hx-swap') || htmx.config.defaultSwap;
|
|
365
518
|
|
|
366
|
-
//
|
|
367
|
-
let
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
target.insertAdjacentHTML('afterbegin', content);
|
|
385
|
-
break;
|
|
386
|
-
case 'beforeend':
|
|
387
|
-
target.insertAdjacentHTML('beforeend', content);
|
|
388
|
-
break;
|
|
389
|
-
case 'afterend':
|
|
390
|
-
target.insertAdjacentHTML('afterend', content);
|
|
391
|
-
break;
|
|
392
|
-
case 'delete':
|
|
393
|
-
target.remove();
|
|
394
|
-
break;
|
|
395
|
-
case 'none':
|
|
396
|
-
// Do nothing
|
|
397
|
-
break;
|
|
398
|
-
default:
|
|
399
|
-
target.innerHTML = content;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// Process new content with HTMX
|
|
403
|
-
htmx.process(target);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function normalizeSwapStyle(style) {
|
|
407
|
-
return style === 'before' ? 'beforebegin' :
|
|
408
|
-
style === 'after' ? 'afterend' :
|
|
409
|
-
style === 'prepend' ? 'afterbegin' :
|
|
410
|
-
style === 'append' ? 'beforeend' : style;
|
|
519
|
+
// Create a document fragment from the HTML content
|
|
520
|
+
let template = document.createElement('template');
|
|
521
|
+
template.innerHTML = content || '';
|
|
522
|
+
let fragment = template.content;
|
|
523
|
+
|
|
524
|
+
// Use htmx's internal insertContent which handles:
|
|
525
|
+
// - All swap styles correctly
|
|
526
|
+
// - Processing new content with htmx.process()
|
|
527
|
+
// - Preserved elements
|
|
528
|
+
// - Auto-focus
|
|
529
|
+
// - Scroll handling
|
|
530
|
+
let task = {
|
|
531
|
+
target: target,
|
|
532
|
+
swapSpec: swapStyle, // Can be a string - insertContent will parse it
|
|
533
|
+
fragment: fragment
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
api.insertContent(task);
|
|
411
537
|
}
|
|
412
538
|
|
|
413
539
|
// ========================================
|
|
@@ -432,30 +558,38 @@
|
|
|
432
558
|
element._htmx = element._htmx || {};
|
|
433
559
|
element._htmx.wsInitialized = true;
|
|
434
560
|
|
|
435
|
-
let config = getConfig();
|
|
436
561
|
let triggerSpec = api.attributeValue(element, 'hx-trigger');
|
|
437
562
|
|
|
438
|
-
if (!triggerSpec
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
563
|
+
if (!triggerSpec) {
|
|
564
|
+
// No trigger specified - connect immediately (default behavior)
|
|
565
|
+
// This is the most common use case: connect when element appears
|
|
566
|
+
let entry = getOrCreateConnection(connectUrl, element);
|
|
567
|
+
if (entry) {
|
|
568
|
+
element._htmx.wsUrl = entry.url;
|
|
569
|
+
}
|
|
570
|
+
} else {
|
|
571
|
+
// Connect based on explicit trigger
|
|
572
|
+
// Note: We only support bare event names for connection triggers.
|
|
573
|
+
// Modifiers like once, delay, throttle, from, target are NOT supported
|
|
574
|
+
// for connection establishment. Use htmx:before:ws:connect event for
|
|
575
|
+
// custom connection control logic.
|
|
445
576
|
let specs = api.parseTriggerSpecs(triggerSpec);
|
|
446
577
|
if (specs.length > 0) {
|
|
447
578
|
let spec = specs[0];
|
|
448
579
|
if (spec.name === 'load') {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
580
|
+
// Explicit load trigger - connect immediately
|
|
581
|
+
let entry = getOrCreateConnection(connectUrl, element);
|
|
582
|
+
if (entry) {
|
|
583
|
+
element._htmx.wsUrl = entry.url;
|
|
584
|
+
}
|
|
452
585
|
} else {
|
|
453
|
-
// Set up event listener for other triggers
|
|
586
|
+
// Set up event listener for other triggers (bare event name only)
|
|
454
587
|
element.addEventListener(spec.name, () => {
|
|
455
588
|
if (!element._htmx?.wsUrl) {
|
|
456
|
-
getOrCreateConnection(connectUrl, element);
|
|
457
|
-
|
|
458
|
-
|
|
589
|
+
let entry = getOrCreateConnection(connectUrl, element);
|
|
590
|
+
if (entry) {
|
|
591
|
+
element._htmx.wsUrl = entry.url;
|
|
592
|
+
}
|
|
459
593
|
}
|
|
460
594
|
}, { once: true });
|
|
461
595
|
}
|
|
@@ -466,7 +600,9 @@
|
|
|
466
600
|
function initializeSendElement(element) {
|
|
467
601
|
if (element._htmx?.wsSendInitialized) return;
|
|
468
602
|
|
|
469
|
-
let
|
|
603
|
+
let sendAttr = getWsAttribute(element, 'send');
|
|
604
|
+
// Only treat as URL if it looks like one (not "", "true", etc.)
|
|
605
|
+
let sendUrl = looksLikeUrl(sendAttr) ? sendAttr : null;
|
|
470
606
|
let triggerSpec = api.attributeValue(element, 'hx-trigger');
|
|
471
607
|
|
|
472
608
|
if (!triggerSpec) {
|
|
@@ -476,11 +612,14 @@
|
|
|
476
612
|
'click';
|
|
477
613
|
}
|
|
478
614
|
|
|
615
|
+
// Note: We only support bare event names for send triggers.
|
|
616
|
+
// Modifiers like once, delay, throttle, from, target are NOT supported.
|
|
617
|
+
// For complex trigger logic, use htmx:before:ws:send to implement custom behavior.
|
|
479
618
|
let specs = api.parseTriggerSpecs(triggerSpec);
|
|
480
619
|
if (specs.length > 0) {
|
|
481
620
|
let spec = specs[0];
|
|
482
621
|
|
|
483
|
-
let handler = (evt) => {
|
|
622
|
+
let handler = async (evt) => {
|
|
484
623
|
// Prevent default for forms
|
|
485
624
|
if (element.matches('form') && evt.type === 'submit') {
|
|
486
625
|
evt.preventDefault();
|
|
@@ -489,13 +628,14 @@
|
|
|
489
628
|
// If this element has its own URL, ensure connection exists
|
|
490
629
|
if (sendUrl) {
|
|
491
630
|
if (!element._htmx?.wsUrl) {
|
|
492
|
-
getOrCreateConnection(sendUrl, element);
|
|
493
|
-
|
|
494
|
-
|
|
631
|
+
let entry = getOrCreateConnection(sendUrl, element);
|
|
632
|
+
if (entry) {
|
|
633
|
+
element._htmx.wsUrl = entry.url;
|
|
634
|
+
}
|
|
495
635
|
}
|
|
496
636
|
}
|
|
497
637
|
|
|
498
|
-
sendMessage(element, evt);
|
|
638
|
+
await sendMessage(element, evt);
|
|
499
639
|
};
|
|
500
640
|
|
|
501
641
|
element.addEventListener(spec.name, handler);
|
|
@@ -528,14 +668,16 @@
|
|
|
528
668
|
// Map legacy attributes to new ones (prefer hyphen variant for broader compatibility)
|
|
529
669
|
if (element.hasAttribute('ws-connect')) {
|
|
530
670
|
let url = element.getAttribute('ws-connect');
|
|
531
|
-
|
|
532
|
-
|
|
671
|
+
let hyphenAttr = buildAttrName('-connect');
|
|
672
|
+
if (!element.hasAttribute(hyphenAttr)) {
|
|
673
|
+
element.setAttribute(hyphenAttr, url);
|
|
533
674
|
}
|
|
534
675
|
}
|
|
535
676
|
|
|
536
677
|
if (element.hasAttribute('ws-send')) {
|
|
537
|
-
|
|
538
|
-
|
|
678
|
+
let hyphenAttr = buildAttrName('-send');
|
|
679
|
+
if (!element.hasAttribute(hyphenAttr)) {
|
|
680
|
+
element.setAttribute(hyphenAttr, '');
|
|
539
681
|
}
|
|
540
682
|
}
|
|
541
683
|
}
|
|
@@ -574,8 +716,13 @@
|
|
|
574
716
|
// Process the element itself
|
|
575
717
|
processNode(element);
|
|
576
718
|
|
|
577
|
-
// Process descendants
|
|
578
|
-
|
|
719
|
+
// Process descendants - build proper selector respecting prefix
|
|
720
|
+
let connectSelector = buildWsSelector('connect');
|
|
721
|
+
let sendSelector = buildWsSelector('send');
|
|
722
|
+
let plainAttr = buildAttrName('');
|
|
723
|
+
let fullSelector = `${connectSelector},${sendSelector},[${plainAttr}],[ws-connect],[ws-send]`;
|
|
724
|
+
|
|
725
|
+
element.querySelectorAll(fullSelector).forEach(processNode);
|
|
579
726
|
},
|
|
580
727
|
|
|
581
728
|
htmx_before_cleanup: (element) => {
|
|
@@ -605,8 +752,8 @@
|
|
|
605
752
|
entry.pendingRequests.clear();
|
|
606
753
|
});
|
|
607
754
|
},
|
|
608
|
-
get: (key) => connectionRegistry.get(key),
|
|
609
|
-
has: (key) => connectionRegistry.has(key),
|
|
755
|
+
get: (key) => connectionRegistry.get(normalizeWebSocketUrl(key)),
|
|
756
|
+
has: (key) => connectionRegistry.has(normalizeWebSocketUrl(key)),
|
|
610
757
|
size: connectionRegistry.size
|
|
611
758
|
})
|
|
612
759
|
};
|