talkdom 0.3.1 → 0.4.1

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.
@@ -0,0 +1,223 @@
1
+ import { methods, talkDOM } from "./index.js";
2
+
3
+ const WS = /\s+/;
4
+ const BASE_DELAY = 1000;
5
+ const MAX_DELAY = 30000;
6
+ const CLEANUP_INTERVAL = 5000;
7
+ const MAX_CONNECTIONS = 16;
8
+
9
+ const connections = Object.create(null);
10
+
11
+ // Parse receiver attribute to extract ws: URL.
12
+ function parseWsUrl(attr) {
13
+ const tokens = attr.trim().split(WS);
14
+ for (let i = 1; i < tokens.length; i++) {
15
+ if (tokens[i] === "ws:" && tokens[i + 1]) return tokens[i + 1];
16
+ }
17
+ return null;
18
+ }
19
+
20
+ // Remove disconnected elements from a connection's receiver set.
21
+ function pruneReceivers(conn) {
22
+ conn.receivers.forEach(function (el) {
23
+ if (!el.isConnected) conn.receivers.delete(el);
24
+ });
25
+ return conn.receivers.size > 0;
26
+ }
27
+
28
+ // Fire a custom event on all connected receivers for a URL.
29
+ function fireEvent(conn, name, detail) {
30
+ conn.receivers.forEach(function (el) {
31
+ if (el.isConnected) {
32
+ el.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
33
+ }
34
+ });
35
+ }
36
+
37
+ // Route a parsed JSON message to matching receiver elements.
38
+ function routeJson(conn, msg) {
39
+ const name = msg.receiver;
40
+ const op = msg.op || "inner";
41
+ const content = msg.content || "";
42
+ const targets = name
43
+ ? document.querySelectorAll('[receiver~="' + name + '"]')
44
+ : Array.from(conn.receivers);
45
+
46
+ const detail = { receiver: name || "", selector: "apply:", args: [content, op] };
47
+ for (let i = 0; i < targets.length; i++) {
48
+ const el = targets[i];
49
+ methods["apply:"](el, content, op);
50
+ el.dispatchEvent(new CustomEvent("talkdom:done", { bubbles: true, detail }));
51
+ }
52
+ }
53
+
54
+ // Handle an incoming WebSocket message.
55
+ function onMessage(url, event) {
56
+ const conn = connections[url];
57
+ if (!conn) return;
58
+ pruneReceivers(conn);
59
+ const data = event.data;
60
+ if (typeof data !== "string") return;
61
+ if (data.charAt(0) === "{") {
62
+ try {
63
+ const msg = JSON.parse(data);
64
+ routeJson(conn, msg);
65
+ } catch (e) {
66
+ console.error("talkdom-ws: invalid JSON from " + url, e);
67
+ }
68
+ } else {
69
+ talkDOM.send(data).catch(function (err) {
70
+ console.warn("talkdom-ws:", err);
71
+ });
72
+ }
73
+ }
74
+
75
+ function scheduleReconnect(url) {
76
+ const conn = connections[url];
77
+ if (!conn) return;
78
+ if (!pruneReceivers(conn)) { cleanup(url); return; }
79
+ let delay = Math.min(conn.backoff, MAX_DELAY);
80
+ delay = delay * (0.75 + Math.random() * 0.5);
81
+ conn.timer = setTimeout(function () {
82
+ conn.backoff = Math.min(conn.backoff * 2, MAX_DELAY);
83
+ connectWs(url);
84
+ }, delay);
85
+ }
86
+
87
+ function cleanup(url) {
88
+ const conn = connections[url];
89
+ if (!conn) return;
90
+ if (conn.timer) clearTimeout(conn.timer);
91
+ if (conn.checkTimer) clearInterval(conn.checkTimer);
92
+ if (conn.ws) {
93
+ conn.ws.onclose = null;
94
+ conn.ws.close();
95
+ }
96
+ delete connections[url];
97
+ }
98
+
99
+ function connectWs(url) {
100
+ const conn = connections[url];
101
+ if (!conn) return;
102
+ if (conn.ws && (conn.ws.readyState === WebSocket.OPEN || conn.ws.readyState === WebSocket.CONNECTING)) return;
103
+
104
+ const ws = new WebSocket(url);
105
+
106
+ ws.onopen = function () {
107
+ conn.backoff = BASE_DELAY;
108
+ fireEvent(conn, "talkdom:ws:open", { url });
109
+ };
110
+
111
+ ws.onmessage = function (e) {
112
+ onMessage(url, e);
113
+ };
114
+
115
+ ws.onclose = function (e) {
116
+ fireEvent(conn, "talkdom:ws:close", { url, code: e.code, reason: e.reason });
117
+ scheduleReconnect(url);
118
+ };
119
+
120
+ ws.onerror = function () {
121
+ fireEvent(conn, "talkdom:ws:error", { url });
122
+ };
123
+
124
+ conn.ws = ws;
125
+ }
126
+
127
+ // Subscribe an element to a WebSocket URL.
128
+ function subscribe(el, url) {
129
+ let conn = connections[url];
130
+ if (!conn) {
131
+ const count = Object.keys(connections).length;
132
+ if (count >= MAX_CONNECTIONS) {
133
+ console.warn("talkdom-ws: max connections (" + MAX_CONNECTIONS + ") reached, ignoring " + url);
134
+ return;
135
+ }
136
+ conn = { ws: null, receivers: new Set(), backoff: BASE_DELAY, timer: null, checkTimer: null };
137
+ connections[url] = conn;
138
+ conn.checkTimer = setInterval(function () {
139
+ if (!pruneReceivers(conn)) cleanup(url);
140
+ }, CLEANUP_INTERVAL);
141
+ }
142
+ conn.receivers.add(el);
143
+ connectWs(url);
144
+ }
145
+
146
+ // Scan a single element for ws: keyword and subscribe.
147
+ function initElement(el) {
148
+ const attr = el.getAttribute("receiver");
149
+ if (!attr) return;
150
+ const url = parseWsUrl(attr);
151
+ if (!url) return;
152
+ subscribe(el, url);
153
+ }
154
+
155
+ // Scan all existing receiver elements.
156
+ function initWsReceivers() {
157
+ document.querySelectorAll("[receiver]").forEach(initElement);
158
+ }
159
+
160
+ // Watch for dynamically added ws: receivers.
161
+ new MutationObserver(function (mutations) {
162
+ for (let i = 0; i < mutations.length; i++) {
163
+ const added = mutations[i].addedNodes;
164
+ for (let j = 0; j < added.length; j++) {
165
+ const node = added[j];
166
+ if (node.nodeType !== 1) continue;
167
+ if (node.hasAttribute && node.hasAttribute("receiver")) initElement(node);
168
+ if (node.querySelectorAll) {
169
+ node.querySelectorAll("[receiver]").forEach(initElement);
170
+ }
171
+ }
172
+ }
173
+ }).observe(document, { childList: true, subtree: true });
174
+
175
+ // ws:send: method
176
+ methods["ws:send:"] = function (el, url) {
177
+ const conn = connections[url];
178
+ if (!conn || !conn.ws || conn.ws.readyState !== WebSocket.OPEN) {
179
+ console.error("talkdom-ws: no open connection to " + url);
180
+ return Promise.reject("not connected");
181
+ }
182
+ const payload = ("value" in el) ? el.value : el.textContent;
183
+ conn.ws.send(payload);
184
+ };
185
+
186
+ initWsReceivers();
187
+
188
+ const ws = {
189
+ connect: function (url) {
190
+ if (!connections[url]) {
191
+ const count = Object.keys(connections).length;
192
+ if (count >= MAX_CONNECTIONS) {
193
+ console.warn("talkdom-ws: max connections (" + MAX_CONNECTIONS + ") reached");
194
+ return;
195
+ }
196
+ connections[url] = { ws: null, receivers: new Set(), backoff: BASE_DELAY, timer: null, checkTimer: null };
197
+ connections[url].checkTimer = setInterval(function () {
198
+ if (!pruneReceivers(connections[url])) cleanup(url);
199
+ }, CLEANUP_INTERVAL);
200
+ }
201
+ connectWs(url);
202
+ },
203
+ disconnect: function (url) { cleanup(url); },
204
+ send: function (url, data) {
205
+ const conn = connections[url];
206
+ if (!conn || !conn.ws || conn.ws.readyState !== WebSocket.OPEN) return false;
207
+ conn.ws.send(typeof data === "string" ? data : JSON.stringify(data));
208
+ return true;
209
+ },
210
+ get connections() {
211
+ const out = Object.create(null);
212
+ for (const url in connections) {
213
+ out[url] = {
214
+ state: connections[url].ws ? connections[url].ws.readyState : -1,
215
+ receivers: connections[url].receivers.size,
216
+ };
217
+ }
218
+ return out;
219
+ },
220
+ get maxConnections() { return MAX_CONNECTIONS; },
221
+ };
222
+
223
+ export { ws };
package/index.js DELETED
@@ -1,366 +0,0 @@
1
- (function () {
2
-
3
- var WS = /\s+/;
4
-
5
- // Parse "receiver keyword: arg keyword: arg" into structured message object.
6
- // Tokens ending with ":" are keywords, everything else fills args.
7
- function parseMessage(str) {
8
- var trimmed = str.trim();
9
- var tokens = trimmed.split(WS);
10
- var receiver = tokens[0];
11
- var body = trimmed.substring(receiver.length).trim();
12
- var rest = tokens.slice(1);
13
- var keywords = [];
14
- var args = [];
15
- var currentArg = [];
16
-
17
- for (var i = 0; i < rest.length; i++) {
18
- var token = rest[i];
19
- if (token.endsWith(":")) {
20
- if (keywords.length > 0 && currentArg.length > 0) {
21
- args.push(currentArg.join(" "));
22
- currentArg = [];
23
- } else if (keywords.length > 0) {
24
- args.push("");
25
- }
26
- keywords.push(token);
27
- } else {
28
- currentArg.push(token);
29
- }
30
- }
31
- if (keywords.length > 0) {
32
- args.push(currentArg.join(" "));
33
- }
34
-
35
- return { receiver: receiver, selector: keywords.join(""), keywords: keywords, args: args, body: body };
36
- }
37
-
38
- // Extract the first word from the receiver attribute (the name).
39
- function receiverName(el) {
40
- var attr = el.getAttribute("receiver").trim();
41
- var sp = attr.indexOf(" ");
42
- return sp === -1 ? attr : attr.substring(0, sp);
43
- }
44
-
45
- // Receiver cache: maps name -> NodeList, invalidated by DOM mutations.
46
- var receiverCache = Object.create(null);
47
- var cacheValid = false;
48
-
49
- new MutationObserver(function () { cacheValid = false; })
50
- .observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ["receiver"] });
51
-
52
- // Find all elements whose receiver attribute contains the given name.
53
- function findReceivers(name) {
54
- if (!cacheValid) { receiverCache = Object.create(null); cacheValid = true; }
55
- if (receiverCache[name]) return receiverCache[name];
56
- var result = document.querySelectorAll('[receiver~="' + name + '"]');
57
- receiverCache[name] = result;
58
- return result;
59
- }
60
-
61
- // Check if a receiver allows a given apply operation (inner, text, append, outer).
62
- // No "accepts" attribute means everything is allowed.
63
- function accepts(el, op) {
64
- var attr = el.getAttribute("accepts");
65
- if (!attr) return true;
66
- return (" " + attr + " ").indexOf(" " + op + " ") !== -1;
67
- }
68
-
69
- // Save receiver content to localStorage after apply, keyed by receiver name.
70
- function persist(el, op) {
71
- if (!el.hasAttribute("receiver") || !el.hasAttribute("persist")) return;
72
- var name = receiverName(el);
73
- var key = "talkDOM:" + name;
74
- if (op === "outer") {
75
- localStorage.setItem(key, JSON.stringify({ op: op, content: el.outerHTML }));
76
- } else {
77
- localStorage.setItem(key, JSON.stringify({ op: op, content: el.innerHTML }));
78
- }
79
- }
80
-
81
- // On page load, restore persisted receiver content from localStorage.
82
- function restore() {
83
- document.querySelectorAll("[persist]").forEach(function (el) {
84
- if (!el.hasAttribute("receiver")) return;
85
- var name = receiverName(el);
86
- var raw = localStorage.getItem("talkDOM:" + name);
87
- if (!raw) return;
88
- var state;
89
- try { state = JSON.parse(raw); } catch (e) { void e; localStorage.removeItem("talkDOM:" + name); return; }
90
- if (state.op === "outer") {
91
- el.outerHTML = state.content;
92
- } else {
93
- el.innerHTML = state.content;
94
- }
95
- });
96
- }
97
-
98
- // Apply content to an element using the specified operation (inner, text, append, outer).
99
- function apply(el, op, content) {
100
- if (!accepts(el, op)) {
101
- console.error(receiverName(el) + " does not accept " + op);
102
- return;
103
- }
104
- switch (op) {
105
- case "inner": el.innerHTML = content; break;
106
- case "text": el.textContent = content; break;
107
- case "append": el.insertAdjacentHTML("beforeend", content); break;
108
- case "outer": el.outerHTML = content; break;
109
- }
110
- persist(el, op);
111
- return content;
112
- }
113
-
114
- var csrfMeta = null;
115
-
116
- function csrfToken() {
117
- // Cache the element reference; re-query only if not found yet or removed.
118
- if (!csrfMeta || !csrfMeta.isConnected) {
119
- csrfMeta = document.querySelector('meta[name="csrf-token"]');
120
- }
121
- return csrfMeta ? csrfMeta.getAttribute("content") : "";
122
- }
123
-
124
- // Perform a fetch with talkDOM headers. Returns a promise resolving to response text.
125
- // Fires server-triggered messages from X-TalkDOM-Trigger header if present.
126
- function request(method, url, receiver) {
127
- var headers = {
128
- "X-TalkDOM-Request": "true",
129
- "X-TalkDOM-Current-URL": location.href,
130
- };
131
- if (receiver) {
132
- headers["X-TalkDOM-Receiver"] = receiver;
133
- }
134
- if (method !== "GET") {
135
- var token = csrfToken();
136
- if (token) headers["X-CSRF-Token"] = token;
137
- else console.warn("talkDOM: no CSRF token found for " + method + " " + url);
138
- }
139
- return fetch(url, { method: method, headers: headers }).then(function (r) {
140
- if (!r.ok) {
141
- console.error("talkDOM: " + method + " " + url + " " + r.status);
142
- return Promise.reject(r.status);
143
- }
144
- var trigger = r.headers.get("X-TalkDOM-Trigger");
145
- return r.text().then(function (text) {
146
- if (trigger) dispatchRaw(trigger);
147
- return text;
148
- });
149
- }, function (err) {
150
- console.error("talkDOM: " + method + " " + url + " failed", err);
151
- return Promise.reject(err);
152
- });
153
- }
154
-
155
- function recName(el) {
156
- return el.hasAttribute("receiver") ? receiverName(el) : "";
157
- }
158
-
159
- // Built-in method table. Each method receives (el, ...args) from the parsed message.
160
- // Extensible via talkDOM.methods at runtime.
161
- const methods = {
162
- "get:": function (el, url) { return request("GET", url, recName(el)); },
163
- "post:": function (el, url) { return request("POST", url, recName(el)); },
164
- "put:": function (el, url) { return request("PUT", url, recName(el)); },
165
- "delete:": function (el, url) { return request("DELETE", url, recName(el)); },
166
- "confirm:": function (el, message) { if (!confirm(message)) return Promise.reject("cancelled"); },
167
- "apply:": function (el, content, op) { return apply(el, op, content); },
168
- "get:apply:": function (el, url, op) { return request("GET", url, recName(el)).then(function (t) { return apply(el, op, t); }); },
169
- "post:apply:": function (el, url, op) { return request("POST", url, recName(el)).then(function (t) { return apply(el, op, t); }); },
170
- "put:apply:": function (el, url, op) { return request("PUT", url, recName(el)).then(function (t) { return apply(el, op, t); }); },
171
- "delete:apply:": function (el, url, op) { return request("DELETE", url, recName(el)).then(function (t) { return apply(el, op, t); }); },
172
- };
173
-
174
- var pushing = false;
175
-
176
- // Push URL to browser history. Uses push-url attr value, or falls back to first message arg.
177
- function pushUrl(senderEl, raw) {
178
- if (!senderEl.hasAttribute("push-url")) return;
179
- var url = senderEl.getAttribute("push-url");
180
- if (!url) {
181
- // Extract the first arg from the first step without a full parseMessage call.
182
- // Pattern: "receiver keyword: arg ..." -- grab the token after the first ":"
183
- var first = raw.split(";")[0].split("|")[0].trim();
184
- var colonIdx = first.indexOf(":");
185
- if (colonIdx !== -1) {
186
- var afterColon = first.substring(colonIdx + 1).trim();
187
- url = afterColon.split(/\s/)[0] || "";
188
- }
189
- }
190
- if (url && (location.pathname + location.search) !== url) {
191
- history.pushState({ sender: raw }, "", url);
192
- }
193
- }
194
-
195
- // Re-dispatch a sender message from history state (back/forward navigation).
196
- function replayState(state) {
197
- if (!state || !state.sender) return;
198
- pushing = true;
199
- dispatchRaw(state.sender);
200
- pushing = false;
201
- }
202
-
203
- window.addEventListener("popstate", function (e) {
204
- replayState(e.state);
205
- });
206
-
207
- // After an outer swap `el` is gone. Walk from the snapshotted sibling or
208
- // parent to find the element that took its place; fall back to a fresh
209
- // receiver query if the DOM was restructured.
210
- function resolveTarget(el, next, parent, name) {
211
- if (el.isConnected) return el;
212
- var candidate = next && next.isConnected ? next.previousElementSibling
213
- : parent && parent.isConnected ? parent.lastElementChild : null;
214
- return candidate || findReceivers(name)[0];
215
- }
216
-
217
- // Deliver a parsed message to all matching receivers. Fires talkdom:done or talkdom:error
218
- // lifecycle events on the receiver element (or its replacement if outer-swapped).
219
- function send(msg, piped) {
220
- var els = findReceivers(msg.receiver);
221
- if (els.length === 0) {
222
- console.error(msg.receiver + " not found");
223
- return;
224
- }
225
- var method = methods[msg.selector];
226
- if (!method) {
227
- console.error(msg.receiver + " does not understand " + msg.selector);
228
- return;
229
- }
230
- var args = piped !== undefined ? [piped].concat(msg.args) : msg.args;
231
- var result;
232
- els.forEach(function (el) {
233
- var detail = { receiver: msg.receiver, selector: msg.selector, args: msg.args };
234
- // Snapshot DOM neighbors before the method runs. If the method does an
235
- // outer swap, `el` is replaced and disconnected, so we need these anchors
236
- // to locate the replacement element for dispatching lifecycle events.
237
- var parent = el.parentNode;
238
- var next = el.nextElementSibling;
239
- result = method(el, ...args);
240
- if (result && typeof result.then === "function") {
241
- result.then(function () {
242
- var target = resolveTarget(el, next, parent, msg.receiver);
243
- if (target) target.dispatchEvent(new CustomEvent("talkdom:done", { bubbles: true, detail: detail }));
244
- }, function (err) {
245
- detail.error = err;
246
- var target = resolveTarget(el, next, parent, msg.receiver);
247
- if (target) target.dispatchEvent(new CustomEvent("talkdom:error", { bubbles: true, detail: detail }));
248
- });
249
- } else {
250
- var target = resolveTarget(el, next, parent, msg.receiver);
251
- if (target) target.dispatchEvent(new CustomEvent("talkdom:done", { bubbles: true, detail: detail }));
252
- }
253
- });
254
- return result;
255
- }
256
-
257
- // Programmatic API: parse and execute a raw message string (supports pipes and semicolons).
258
- // Returns a promise that resolves when all chains complete.
259
- function run(raw) {
260
- var trimmed = raw.trim();
261
- // Fast path: no pipes or semicolons (most common case).
262
- if (trimmed.indexOf(";") === -1 && trimmed.indexOf("|") === -1) {
263
- return Promise.resolve(send(parseMessage(trimmed))).then(function (r) { return [r]; });
264
- }
265
- // Semicolons split into independent chains that run in parallel.
266
- var chains = trimmed.split(";").map(function (chain) {
267
- var step = chain.trim();
268
- if (!step) return Promise.resolve();
269
- // Pipes split a chain into sequential steps where each step's return
270
- // value is fed as the first argument to the next step.
271
- var steps = step.split("|").map(function (s) { return s.trim(); }).filter(Boolean);
272
- if (steps.length === 1) {
273
- return Promise.resolve(send(parseMessage(steps[0])));
274
- }
275
- // Reduce builds a promise chain: each step waits for the previous one,
276
- // then passes its resolved value (piped) into send().
277
- return steps.reduce(function (prev, step) {
278
- var msg = parseMessage(step);
279
- return Promise.resolve(prev).then(function (piped) {
280
- return send(msg, piped);
281
- });
282
- }, undefined);
283
- });
284
- // All independent chains resolve together.
285
- return Promise.all(chains);
286
- }
287
-
288
- // Fire-and-forget dispatch used by declarative senders and server triggers.
289
- function dispatchRaw(raw) {
290
- run(raw).catch(function (err) { console.warn("talkDOM:", err); });
291
- }
292
-
293
- // Entry point for a sender click: dispatch its message and optionally push URL.
294
- function dispatch(senderEl) {
295
- var raw = senderEl.getAttribute("sender");
296
- dispatchRaw(raw);
297
- if (!pushing) pushUrl(senderEl, raw);
298
- }
299
-
300
- function parseInterval(str) {
301
- var match = str.match(/^(\d+)(s|ms)$/);
302
- if (!match) return null;
303
- var n = parseInt(match[1], 10);
304
- return match[2] === "s" ? n * 1000 : n;
305
- }
306
-
307
- // Set up a repeating interval for receivers with a poll: keyword.
308
- // Stops automatically when the element is removed from the DOM.
309
- var activePollers = 0;
310
- var maxPollers = 64;
311
-
312
- function startPolling(el) {
313
- var attr = el.getAttribute("receiver");
314
- var msg = parseMessage(attr);
315
- if (msg.keywords[msg.keywords.length - 1] !== "poll:") return;
316
- if (activePollers >= maxPollers) {
317
- console.warn("talkDOM: max pollers (" + maxPollers + ") reached, ignoring " + msg.receiver);
318
- return;
319
- }
320
- var interval = parseInterval(msg.args[msg.args.length - 1]);
321
- if (!interval) {
322
- console.error("poll: invalid interval for " + msg.receiver);
323
- return;
324
- }
325
- var selector = msg.keywords.slice(0, -1).join("");
326
- var args = msg.args.slice(0, -1);
327
- var name = msg.receiver;
328
- var cachedTargets = findReceivers(name);
329
- var method = methods[selector];
330
- activePollers++;
331
- var id = setInterval(function () {
332
- if (!el.isConnected) { clearInterval(id); activePollers--; return; }
333
- if (cachedTargets.length === 0 || !cachedTargets[0].isConnected) {
334
- cachedTargets = findReceivers(name);
335
- }
336
- if (cachedTargets.length === 0) return;
337
- if (!method) method = methods[selector];
338
- if (!method) {
339
- console.error(name + " does not understand " + selector);
340
- return;
341
- }
342
- cachedTargets.forEach(function (target) { method(target, ...args); });
343
- }, interval);
344
- }
345
-
346
- // Global click handler: delegate to any element with a sender attribute.
347
- document.addEventListener("click", function (e) {
348
- const sender = e.target.closest("[sender]");
349
- if (sender) {
350
- e.preventDefault();
351
- dispatch(sender);
352
- }
353
- });
354
-
355
- restore();
356
- replayState(history.state);
357
- document.querySelectorAll("[receiver]").forEach(startPolling);
358
-
359
- window.talkDOM = {
360
- methods: methods,
361
- send: run,
362
- get maxPollers() { return maxPollers; },
363
- set maxPollers(n) { maxPollers = n; },
364
- };
365
-
366
- }());