htmx.org 4.0.0-alpha4 → 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-compat.js +60 -6
- package/dist/ext/hx-compat.min.js +1 -1
- package/dist/ext/hx-compat.min.js.map +1 -1
- package/dist/ext/hx-head.js +129 -0
- package/dist/ext/hx-head.min.js +1 -0
- package/dist/ext/hx-head.min.js.map +1 -0
- package/dist/ext/hx-preload.js +23 -10
- package/dist/ext/hx-preload.min.js +1 -1
- package/dist/ext/hx-preload.min.js.map +1 -1
- 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 +316 -143
- package/dist/ext/hx-ws.min.js +1 -1
- package/dist/ext/hx-ws.min.js.map +1 -1
- package/dist/htmx.esm.js +338 -236
- package/dist/htmx.esm.min.js +1 -1
- package/dist/htmx.esm.min.js.map +1 -1
- package/dist/htmx.js +338 -236
- package/dist/htmx.min.js +1 -1
- package/dist/htmx.min.js.map +1 -1
- package/package.json +5 -5
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,53 +108,87 @@
|
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
146
|
+
|
|
147
|
+
// Close and remove listeners from old socket properly
|
|
148
|
+
if (entry.socket) {
|
|
149
|
+
let oldSocket = entry.socket;
|
|
150
|
+
entry.socket = null;
|
|
151
|
+
|
|
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);
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
if (oldSocket.readyState === WebSocket.OPEN || oldSocket.readyState === WebSocket.CONNECTING) {
|
|
160
|
+
oldSocket.close();
|
|
161
|
+
}
|
|
162
|
+
} catch (e) {}
|
|
79
163
|
}
|
|
80
164
|
|
|
81
165
|
try {
|
|
82
166
|
entry.socket = new WebSocket(url);
|
|
83
167
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
168
|
+
// Create and store listener references
|
|
169
|
+
entry.listeners.open = () => {
|
|
170
|
+
// Reset reconnect attempts on successful connection
|
|
171
|
+
entry.reconnectAttempts = 0;
|
|
172
|
+
|
|
87
173
|
if (firstElement) {
|
|
88
174
|
triggerEvent(firstElement, 'htmx:after:ws:connect', { url, socket: entry.socket });
|
|
89
175
|
}
|
|
90
|
-
}
|
|
176
|
+
};
|
|
91
177
|
|
|
92
|
-
entry.
|
|
178
|
+
entry.listeners.message = (event) => {
|
|
93
179
|
handleMessage(entry, event);
|
|
94
|
-
}
|
|
180
|
+
};
|
|
95
181
|
|
|
96
|
-
entry.
|
|
182
|
+
entry.listeners.close = (event) => {
|
|
183
|
+
// Check if this socket is still the active one
|
|
184
|
+
if (event.target !== entry.socket) return;
|
|
185
|
+
|
|
97
186
|
if (firstElement) {
|
|
98
|
-
triggerEvent(firstElement, 'htmx:ws:close', {
|
|
187
|
+
triggerEvent(firstElement, 'htmx:ws:close', {
|
|
188
|
+
url,
|
|
189
|
+
code: event.code,
|
|
190
|
+
reason: event.reason
|
|
191
|
+
});
|
|
99
192
|
}
|
|
100
193
|
|
|
101
194
|
// Check if entry is still valid (not cleared)
|
|
@@ -105,15 +198,23 @@
|
|
|
105
198
|
if (config.reconnect && entry.refCount > 0) {
|
|
106
199
|
scheduleReconnect(url, entry);
|
|
107
200
|
} else {
|
|
201
|
+
cleanupPendingRequests(entry);
|
|
108
202
|
connectionRegistry.delete(url);
|
|
109
203
|
}
|
|
110
|
-
}
|
|
204
|
+
};
|
|
111
205
|
|
|
112
|
-
entry.
|
|
206
|
+
entry.listeners.error = (error) => {
|
|
113
207
|
if (firstElement) {
|
|
114
208
|
triggerEvent(firstElement, 'htmx:ws:error', { url, error });
|
|
115
209
|
}
|
|
116
|
-
}
|
|
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
|
+
|
|
117
218
|
} catch (error) {
|
|
118
219
|
if (firstElement) {
|
|
119
220
|
triggerEvent(firstElement, 'htmx:ws:error', { url, error });
|
|
@@ -124,12 +225,12 @@
|
|
|
124
225
|
function scheduleReconnect(url, entry) {
|
|
125
226
|
let config = getConfig();
|
|
126
227
|
|
|
127
|
-
// Increment attempts
|
|
128
|
-
let attempts = entry.reconnectAttempts;
|
|
228
|
+
// Increment attempts FIRST, then calculate delay
|
|
129
229
|
entry.reconnectAttempts++;
|
|
230
|
+
let attempts = entry.reconnectAttempts;
|
|
130
231
|
|
|
131
232
|
let delay = Math.min(
|
|
132
|
-
(config.reconnectDelay || 1000) * Math.pow(2, attempts),
|
|
233
|
+
(config.reconnectDelay || 1000) * Math.pow(2, attempts - 1),
|
|
133
234
|
config.reconnectMaxDelay || 30000
|
|
134
235
|
);
|
|
135
236
|
|
|
@@ -141,6 +242,7 @@
|
|
|
141
242
|
if (entry.refCount > 0) {
|
|
142
243
|
let firstElement = entry.elements.values().next().value;
|
|
143
244
|
if (firstElement) {
|
|
245
|
+
// attempts now means "this is attempt number N"
|
|
144
246
|
triggerEvent(firstElement, 'htmx:ws:reconnect', { url, attempts });
|
|
145
247
|
}
|
|
146
248
|
createWebSocket(url, entry);
|
|
@@ -149,9 +251,12 @@
|
|
|
149
251
|
}
|
|
150
252
|
|
|
151
253
|
function decrementRef(url, element) {
|
|
152
|
-
|
|
254
|
+
// Try both original and normalized URL
|
|
255
|
+
let normalizedUrl = normalizeWebSocketUrl(url);
|
|
256
|
+
|
|
257
|
+
if (!connectionRegistry.has(normalizedUrl)) return;
|
|
153
258
|
|
|
154
|
-
let entry = connectionRegistry.get(
|
|
259
|
+
let entry = connectionRegistry.get(normalizedUrl);
|
|
155
260
|
entry.elements.delete(element);
|
|
156
261
|
entry.refCount--;
|
|
157
262
|
|
|
@@ -159,10 +264,31 @@
|
|
|
159
264
|
if (entry.reconnectTimer) {
|
|
160
265
|
clearTimeout(entry.reconnectTimer);
|
|
161
266
|
}
|
|
267
|
+
cleanupPendingRequests(entry);
|
|
162
268
|
if (entry.socket && entry.socket.readyState === WebSocket.OPEN) {
|
|
163
269
|
entry.socket.close();
|
|
164
270
|
}
|
|
165
|
-
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
|
+
}
|
|
166
292
|
}
|
|
167
293
|
}
|
|
168
294
|
|
|
@@ -170,37 +296,83 @@
|
|
|
170
296
|
// MESSAGE SENDING
|
|
171
297
|
// ========================================
|
|
172
298
|
|
|
173
|
-
|
|
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) {
|
|
174
313
|
// Find connection URL
|
|
175
314
|
let url = getWsAttribute(element, 'send');
|
|
176
|
-
if (!url) {
|
|
177
|
-
//
|
|
178
|
-
let
|
|
179
|
-
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);
|
|
180
319
|
if (ancestor) {
|
|
181
320
|
url = getWsAttribute(ancestor, 'connect');
|
|
321
|
+
} else {
|
|
322
|
+
url = null;
|
|
182
323
|
}
|
|
183
324
|
}
|
|
184
325
|
|
|
185
326
|
if (!url) {
|
|
186
|
-
|
|
327
|
+
// Emit error event instead of console.error
|
|
328
|
+
triggerEvent(element, 'htmx:wsSendError', {
|
|
329
|
+
element,
|
|
330
|
+
error: 'No WebSocket connection found for element'
|
|
331
|
+
});
|
|
187
332
|
return;
|
|
188
333
|
}
|
|
189
334
|
|
|
190
|
-
let
|
|
335
|
+
let normalizedUrl = normalizeWebSocketUrl(url);
|
|
336
|
+
let entry = connectionRegistry.get(normalizedUrl);
|
|
191
337
|
if (!entry || !entry.socket || entry.socket.readyState !== WebSocket.OPEN) {
|
|
192
|
-
triggerEvent(element, 'htmx:wsSendError', { url, error: 'Connection not open' });
|
|
338
|
+
triggerEvent(element, 'htmx:wsSendError', { url: normalizedUrl, error: 'Connection not open' });
|
|
193
339
|
return;
|
|
194
340
|
}
|
|
195
341
|
|
|
342
|
+
// Cleanup expired pending requests periodically
|
|
343
|
+
cleanupExpiredRequests(entry);
|
|
344
|
+
|
|
196
345
|
// Build message
|
|
197
346
|
let form = element.form || element.closest('form');
|
|
198
347
|
let body = api.collectFormData(element, form, event.submitter);
|
|
199
|
-
api.handleHxVals(element, body);
|
|
348
|
+
let valsResult = api.handleHxVals(element, body);
|
|
349
|
+
if (valsResult) await valsResult;
|
|
200
350
|
|
|
351
|
+
// Preserve multi-value form fields (checkboxes, multi-selects)
|
|
201
352
|
let values = {};
|
|
202
353
|
for (let [key, value] of body) {
|
|
203
|
-
|
|
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;
|
|
204
376
|
}
|
|
205
377
|
|
|
206
378
|
let requestId = generateUUID();
|
|
@@ -208,29 +380,30 @@
|
|
|
208
380
|
type: 'request',
|
|
209
381
|
request_id: requestId,
|
|
210
382
|
event: event.type,
|
|
383
|
+
headers: headers,
|
|
211
384
|
values: values,
|
|
212
|
-
path:
|
|
385
|
+
path: normalizedUrl
|
|
213
386
|
};
|
|
214
387
|
|
|
215
388
|
if (element.id) {
|
|
216
389
|
message.id = element.id;
|
|
217
390
|
}
|
|
218
391
|
|
|
219
|
-
// Allow modification via event
|
|
220
|
-
let detail = { message, element, url };
|
|
392
|
+
// Allow modification via event - use 'data' as documented
|
|
393
|
+
let detail = { data: message, element, url: normalizedUrl };
|
|
221
394
|
if (!triggerEvent(element, 'htmx:before:ws:send', detail)) {
|
|
222
395
|
return;
|
|
223
396
|
}
|
|
224
397
|
|
|
225
398
|
try {
|
|
226
|
-
entry.socket.send(JSON.stringify(detail.
|
|
399
|
+
entry.socket.send(JSON.stringify(detail.data));
|
|
227
400
|
|
|
228
401
|
// Store pending request for response matching
|
|
229
402
|
entry.pendingRequests.set(requestId, { element, timestamp: Date.now() });
|
|
230
403
|
|
|
231
|
-
triggerEvent(element, 'htmx:after:ws:send', {
|
|
404
|
+
triggerEvent(element, 'htmx:after:ws:send', { data: detail.data, url: normalizedUrl });
|
|
232
405
|
} catch (error) {
|
|
233
|
-
triggerEvent(element, 'htmx:wsSendError', { url, error });
|
|
406
|
+
triggerEvent(element, 'htmx:wsSendError', { url: normalizedUrl, error });
|
|
234
407
|
}
|
|
235
408
|
}
|
|
236
409
|
|
|
@@ -251,10 +424,10 @@
|
|
|
251
424
|
try {
|
|
252
425
|
envelope = JSON.parse(event.data);
|
|
253
426
|
} catch (e) {
|
|
254
|
-
// Not JSON, emit unknown message event
|
|
427
|
+
// Not JSON, emit unknown message event for parse failures
|
|
255
428
|
let firstElement = entry.elements.values().next().value;
|
|
256
429
|
if (firstElement) {
|
|
257
|
-
triggerEvent(firstElement, 'htmx:wsUnknownMessage', { data: event.data });
|
|
430
|
+
triggerEvent(firstElement, 'htmx:wsUnknownMessage', { data: event.data, parseError: e });
|
|
258
431
|
}
|
|
259
432
|
return;
|
|
260
433
|
}
|
|
@@ -281,49 +454,54 @@
|
|
|
281
454
|
// Route based on channel
|
|
282
455
|
if (envelope.channel === 'ui' && envelope.format === 'html') {
|
|
283
456
|
handleHtmlMessage(targetElement, envelope);
|
|
284
|
-
} else if (envelope.channel && (envelope.channel === 'audio' || envelope.channel === 'json' || envelope.channel === 'binary')) {
|
|
285
|
-
// Known custom channel - emit event for extensions to handle
|
|
286
|
-
triggerEvent(targetElement, 'htmx:wsMessage', { ...envelope, element: targetElement });
|
|
287
457
|
} else {
|
|
288
|
-
//
|
|
289
|
-
|
|
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 });
|
|
290
461
|
}
|
|
291
462
|
|
|
292
463
|
triggerEvent(targetElement, 'htmx:after:ws:message', { envelope, element: targetElement });
|
|
293
464
|
}
|
|
294
465
|
|
|
295
466
|
// ========================================
|
|
296
|
-
// HTML PARTIAL HANDLING
|
|
467
|
+
// HTML PARTIAL HANDLING - Using htmx.swap(ctx)
|
|
297
468
|
// ========================================
|
|
298
469
|
|
|
299
470
|
function handleHtmlMessage(element, envelope) {
|
|
300
471
|
let parser = new DOMParser();
|
|
301
|
-
let doc = parser.parseFromString(envelope.payload, 'text/html');
|
|
472
|
+
let doc = parser.parseFromString(envelope.payload || '', 'text/html');
|
|
302
473
|
|
|
303
|
-
// Find all hx-partial elements
|
|
474
|
+
// Find all hx-partial elements (legacy format)
|
|
304
475
|
let partials = doc.querySelectorAll('hx-partial');
|
|
305
476
|
|
|
306
477
|
if (partials.length === 0) {
|
|
307
478
|
// No partials, treat entire payload as content for element's target
|
|
308
|
-
let target = resolveTarget(element);
|
|
479
|
+
let target = resolveTarget(element, envelope.target);
|
|
309
480
|
if (target) {
|
|
310
|
-
|
|
481
|
+
swapWithHtmx(target, envelope.payload, element, envelope.swap);
|
|
311
482
|
}
|
|
312
483
|
return;
|
|
313
484
|
}
|
|
314
485
|
|
|
315
|
-
|
|
486
|
+
// Process each partial
|
|
487
|
+
for (let partial of partials) {
|
|
316
488
|
let targetId = partial.getAttribute('id');
|
|
317
|
-
if (!targetId)
|
|
489
|
+
if (!targetId) continue;
|
|
318
490
|
|
|
319
491
|
let target = document.getElementById(targetId);
|
|
320
|
-
if (!target)
|
|
492
|
+
if (!target) continue;
|
|
321
493
|
|
|
322
|
-
|
|
323
|
-
}
|
|
494
|
+
swapWithHtmx(target, partial.innerHTML, element);
|
|
495
|
+
}
|
|
324
496
|
}
|
|
325
497
|
|
|
326
|
-
function resolveTarget(element) {
|
|
498
|
+
function resolveTarget(element, envelopeTarget) {
|
|
499
|
+
if (envelopeTarget) {
|
|
500
|
+
if (envelopeTarget === 'this') {
|
|
501
|
+
return element;
|
|
502
|
+
}
|
|
503
|
+
return document.querySelector(envelopeTarget);
|
|
504
|
+
}
|
|
327
505
|
let targetSelector = api.attributeValue(element, 'hx-target');
|
|
328
506
|
if (targetSelector) {
|
|
329
507
|
if (targetSelector === 'this') {
|
|
@@ -334,54 +512,28 @@
|
|
|
334
512
|
return element;
|
|
335
513
|
}
|
|
336
514
|
|
|
337
|
-
function
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
break;
|
|
360
|
-
case 'beforeend':
|
|
361
|
-
target.insertAdjacentHTML('beforeend', content);
|
|
362
|
-
break;
|
|
363
|
-
case 'afterend':
|
|
364
|
-
target.insertAdjacentHTML('afterend', content);
|
|
365
|
-
break;
|
|
366
|
-
case 'delete':
|
|
367
|
-
target.remove();
|
|
368
|
-
break;
|
|
369
|
-
case 'none':
|
|
370
|
-
// Do nothing
|
|
371
|
-
break;
|
|
372
|
-
default:
|
|
373
|
-
target.innerHTML = content;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Process new content with HTMX
|
|
377
|
-
htmx.process(target);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function normalizeSwapStyle(style) {
|
|
381
|
-
return style === 'before' ? 'beforebegin' :
|
|
382
|
-
style === 'after' ? 'afterend' :
|
|
383
|
-
style === 'prepend' ? 'afterbegin' :
|
|
384
|
-
style === 'append' ? 'beforeend' : style;
|
|
515
|
+
function swapWithHtmx(target, content, sourceElement, envelopeSwap) {
|
|
516
|
+
// Determine swap style from envelope, element attribute, or default
|
|
517
|
+
let swapStyle = envelopeSwap || api.attributeValue(sourceElement, 'hx-swap') || htmx.config.defaultSwap;
|
|
518
|
+
|
|
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);
|
|
385
537
|
}
|
|
386
538
|
|
|
387
539
|
// ========================================
|
|
@@ -406,30 +558,38 @@
|
|
|
406
558
|
element._htmx = element._htmx || {};
|
|
407
559
|
element._htmx.wsInitialized = true;
|
|
408
560
|
|
|
409
|
-
let config = getConfig();
|
|
410
561
|
let triggerSpec = api.attributeValue(element, 'hx-trigger');
|
|
411
562
|
|
|
412
|
-
if (!triggerSpec
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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.
|
|
419
576
|
let specs = api.parseTriggerSpecs(triggerSpec);
|
|
420
577
|
if (specs.length > 0) {
|
|
421
578
|
let spec = specs[0];
|
|
422
579
|
if (spec.name === 'load') {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
580
|
+
// Explicit load trigger - connect immediately
|
|
581
|
+
let entry = getOrCreateConnection(connectUrl, element);
|
|
582
|
+
if (entry) {
|
|
583
|
+
element._htmx.wsUrl = entry.url;
|
|
584
|
+
}
|
|
426
585
|
} else {
|
|
427
|
-
// Set up event listener for other triggers
|
|
586
|
+
// Set up event listener for other triggers (bare event name only)
|
|
428
587
|
element.addEventListener(spec.name, () => {
|
|
429
588
|
if (!element._htmx?.wsUrl) {
|
|
430
|
-
getOrCreateConnection(connectUrl, element);
|
|
431
|
-
|
|
432
|
-
|
|
589
|
+
let entry = getOrCreateConnection(connectUrl, element);
|
|
590
|
+
if (entry) {
|
|
591
|
+
element._htmx.wsUrl = entry.url;
|
|
592
|
+
}
|
|
433
593
|
}
|
|
434
594
|
}, { once: true });
|
|
435
595
|
}
|
|
@@ -440,7 +600,9 @@
|
|
|
440
600
|
function initializeSendElement(element) {
|
|
441
601
|
if (element._htmx?.wsSendInitialized) return;
|
|
442
602
|
|
|
443
|
-
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;
|
|
444
606
|
let triggerSpec = api.attributeValue(element, 'hx-trigger');
|
|
445
607
|
|
|
446
608
|
if (!triggerSpec) {
|
|
@@ -450,11 +612,14 @@
|
|
|
450
612
|
'click';
|
|
451
613
|
}
|
|
452
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.
|
|
453
618
|
let specs = api.parseTriggerSpecs(triggerSpec);
|
|
454
619
|
if (specs.length > 0) {
|
|
455
620
|
let spec = specs[0];
|
|
456
621
|
|
|
457
|
-
let handler = (evt) => {
|
|
622
|
+
let handler = async (evt) => {
|
|
458
623
|
// Prevent default for forms
|
|
459
624
|
if (element.matches('form') && evt.type === 'submit') {
|
|
460
625
|
evt.preventDefault();
|
|
@@ -463,13 +628,14 @@
|
|
|
463
628
|
// If this element has its own URL, ensure connection exists
|
|
464
629
|
if (sendUrl) {
|
|
465
630
|
if (!element._htmx?.wsUrl) {
|
|
466
|
-
getOrCreateConnection(sendUrl, element);
|
|
467
|
-
|
|
468
|
-
|
|
631
|
+
let entry = getOrCreateConnection(sendUrl, element);
|
|
632
|
+
if (entry) {
|
|
633
|
+
element._htmx.wsUrl = entry.url;
|
|
634
|
+
}
|
|
469
635
|
}
|
|
470
636
|
}
|
|
471
637
|
|
|
472
|
-
sendMessage(element, evt);
|
|
638
|
+
await sendMessage(element, evt);
|
|
473
639
|
};
|
|
474
640
|
|
|
475
641
|
element.addEventListener(spec.name, handler);
|
|
@@ -502,14 +668,16 @@
|
|
|
502
668
|
// Map legacy attributes to new ones (prefer hyphen variant for broader compatibility)
|
|
503
669
|
if (element.hasAttribute('ws-connect')) {
|
|
504
670
|
let url = element.getAttribute('ws-connect');
|
|
505
|
-
|
|
506
|
-
|
|
671
|
+
let hyphenAttr = buildAttrName('-connect');
|
|
672
|
+
if (!element.hasAttribute(hyphenAttr)) {
|
|
673
|
+
element.setAttribute(hyphenAttr, url);
|
|
507
674
|
}
|
|
508
675
|
}
|
|
509
676
|
|
|
510
677
|
if (element.hasAttribute('ws-send')) {
|
|
511
|
-
|
|
512
|
-
|
|
678
|
+
let hyphenAttr = buildAttrName('-send');
|
|
679
|
+
if (!element.hasAttribute(hyphenAttr)) {
|
|
680
|
+
element.setAttribute(hyphenAttr, '');
|
|
513
681
|
}
|
|
514
682
|
}
|
|
515
683
|
}
|
|
@@ -548,8 +716,13 @@
|
|
|
548
716
|
// Process the element itself
|
|
549
717
|
processNode(element);
|
|
550
718
|
|
|
551
|
-
// Process descendants
|
|
552
|
-
|
|
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);
|
|
553
726
|
},
|
|
554
727
|
|
|
555
728
|
htmx_before_cleanup: (element) => {
|
|
@@ -579,8 +752,8 @@
|
|
|
579
752
|
entry.pendingRequests.clear();
|
|
580
753
|
});
|
|
581
754
|
},
|
|
582
|
-
get: (key) => connectionRegistry.get(key),
|
|
583
|
-
has: (key) => connectionRegistry.has(key),
|
|
755
|
+
get: (key) => connectionRegistry.get(normalizeWebSocketUrl(key)),
|
|
756
|
+
has: (key) => connectionRegistry.has(normalizeWebSocketUrl(key)),
|
|
584
757
|
size: connectionRegistry.size
|
|
585
758
|
})
|
|
586
759
|
};
|