talkdom 0.3.1 → 0.4.0

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/README.md CHANGED
@@ -223,6 +223,100 @@ talkDOM.methods["show:"] = function (el, message) {
223
223
  };
224
224
  ```
225
225
 
226
+ ## WebSocket plugin
227
+
228
+ The optional `websocket.js` plugin adds server-push via WebSocket as an alternative to polling. Load it after the core library:
229
+
230
+ ```html
231
+ <script src="https://cdn.jsdelivr.net/npm/talkdom/dist/talkdom.min.js"></script>
232
+ <script src="https://cdn.jsdelivr.net/npm/talkdom/dist/talkdom-ws.min.js"></script>
233
+ ```
234
+
235
+ ### Receiving
236
+
237
+ Add `ws:` as the last keyword on a receiver with a WebSocket URL as its argument. The server pushes content — no client-side method keywords needed.
238
+
239
+ ```html
240
+ <div receiver="feed ws: ws://localhost:3000/updates"></div>
241
+ ```
242
+
243
+ The server sends JSON messages to control what gets applied:
244
+
245
+ ```json
246
+ {"receiver": "feed", "content": "<p>New post</p>", "op": "append"}
247
+ ```
248
+
249
+ | Field | Required | Description |
250
+ |---|---|---|
251
+ | `receiver` | yes | Target receiver name |
252
+ | `content` | no | HTML or text content |
253
+ | `op` | no | `inner` (default), `text`, `append`, `outer` |
254
+
255
+ Omitting `receiver` broadcasts to all receivers on that connection.
256
+
257
+ The server can also send raw talkDOM message syntax instead of JSON:
258
+
259
+ ```
260
+ feed apply: Updated! text
261
+ ```
262
+
263
+ This dispatches through the same path as sender clicks and server triggers.
264
+
265
+ ### Sending
266
+
267
+ The plugin registers a `ws:send:` method. The receiver element's value (for inputs/textareas/selects) or text content is sent over the WebSocket connection.
268
+
269
+ ```html
270
+ <input receiver="chatbox" type="text">
271
+ <button sender="chatbox ws:send: ws://localhost:3000/chat">Send</button>
272
+ ```
273
+
274
+ ### Shared connections
275
+
276
+ Multiple receivers pointing to the same URL share a single WebSocket connection. The server routes messages by the `receiver` field in JSON.
277
+
278
+ ```html
279
+ <div receiver="messages ws: ws://localhost:3000/live"></div>
280
+ <div receiver="presence ws: ws://localhost:3000/live"></div>
281
+ ```
282
+
283
+ ### Reconnection
284
+
285
+ Connections automatically reconnect with exponential backoff (1s initial, 30s max, ±25% jitter). Backoff resets on successful connection. Reconnection stops when all receivers for a URL are removed from the DOM.
286
+
287
+ ### Lifecycle events
288
+
289
+ | Event | Detail |
290
+ |---|---|
291
+ | `talkdom:ws:open` | `{ url }` |
292
+ | `talkdom:ws:close` | `{ url, code, reason }` |
293
+ | `talkdom:ws:error` | `{ url }` |
294
+
295
+ Events fire on all receiver elements subscribed to the URL and bubble.
296
+
297
+ ```js
298
+ document.addEventListener("talkdom:ws:open", function (e) {
299
+ console.log("connected to", e.detail.url);
300
+ });
301
+ ```
302
+
303
+ Incoming messages also fire the standard `talkdom:done` event on the target receiver after applying content.
304
+
305
+ ### Programmatic API
306
+
307
+ ```js
308
+ talkDOM.ws.connect("ws://localhost:3000/live");
309
+ talkDOM.ws.send("ws://localhost:3000/live", { action: "subscribe", channel: "news" });
310
+ talkDOM.ws.send("ws://localhost:3000/live", "plain string");
311
+ talkDOM.ws.disconnect("ws://localhost:3000/live");
312
+
313
+ talkDOM.ws.connections; // { "ws://...": { state: 1, receivers: 2 } }
314
+ talkDOM.ws.maxConnections; // default 16
315
+ talkDOM.ws.maxConnections = 32;
316
+ ```
317
+
318
+ `talkDOM.ws.send` returns `true` if sent, `false` if the connection is not open.
319
+
226
320
  ## Security
227
321
 
228
322
  talkDOM does **not** sanitize HTML. Content from `get:apply:`, `post:apply:`, server triggers, and piped `apply:` is inserted via `innerHTML` / `insertAdjacentHTML` / `outerHTML` as-is. You are responsible for ensuring that server responses do not contain untrusted markup.
@@ -0,0 +1 @@
1
+ !function(){if(window.talkDOM){var e=talkDOM.methods,n=Object.create(null),t=16,r=1e3,o=/\s+/;new MutationObserver(function(e){for(var n=0;n<e.length;n++)for(var t=e[n].addedNodes,r=0;r<t.length;r++){var o=t[r];1===o.nodeType&&(o.hasAttribute&&o.hasAttribute("receiver")&&u(o),o.querySelectorAll&&o.querySelectorAll("[receiver]").forEach(u))}}).observe(document,{childList:!0,subtree:!0}),e["ws:send:"]=function(e,t){var r=n[t];if(!r||!r.ws||r.ws.readyState!==WebSocket.OPEN)return console.error("talkdom-ws: no open connection to "+t),Promise.reject("not connected");var o="value"in e?e.value:e.textContent;r.ws.send(o)},document.querySelectorAll("[receiver]").forEach(u),talkDOM.ws={connect:function(e){if(!n[e]){if(Object.keys(n).length>=t)return void console.warn("talkdom-ws: max connections ("+t+") reached");n[e]={ws:null,receivers:new Set,backoff:r,timer:null,checkTimer:null},n[e].checkTimer=setInterval(function(){c(n[e])||s(e)},5e3)}l(e)},disconnect:function(e){s(e)},send:function(e,t){var r=n[e];return!(!r||!r.ws||r.ws.readyState!==WebSocket.OPEN)&&(r.ws.send("string"==typeof t?t:JSON.stringify(t)),!0)},get connections(){var e=Object.create(null);for(var t in n)e[t]={state:n[t].ws?n[t].ws.readyState:-1,receivers:n[t].receivers.size};return e},get maxConnections(){return t},set maxConnections(e){t=e}}}else console.error("talkdom-ws: load index.js before websocket.js");function c(e){return e.receivers.forEach(function(n){n.isConnected||e.receivers.delete(n)}),e.receivers.size>0}function i(e,n,t){e.receivers.forEach(function(e){e.isConnected&&e.dispatchEvent(new CustomEvent(n,{bubbles:!0,detail:t}))})}function a(t,r){var o=n[t];if(o){c(o);var i=r.data;if("string"==typeof i)if("{"===i.charAt(0))try{!function(n,t){var r,o=t.receiver,c=t.op||"inner",i=t.content||"";r=o?document.querySelectorAll('[receiver~="'+o+'"]'):Array.from(n.receivers);for(var a={receiver:o||"",selector:"apply:",args:[i,c]},s=0;s<r.length;s++){var l=r[s];e["apply:"](l,i,c),l.dispatchEvent(new CustomEvent("talkdom:done",{bubbles:!0,detail:a}))}}(o,JSON.parse(i))}catch(e){console.error("talkdom-ws: invalid JSON from "+t,e)}else talkDOM.send(i).catch(function(e){console.warn("talkdom-ws:",e)})}}function s(e){var t=n[e];t&&(t.timer&&clearTimeout(t.timer),t.checkTimer&&clearInterval(t.checkTimer),t.ws&&(t.ws.onclose=null,t.ws.close()),delete n[e])}function l(e){var t=n[e];if(t&&(!t.ws||t.ws.readyState!==WebSocket.OPEN&&t.ws.readyState!==WebSocket.CONNECTING)){var o=new WebSocket(e);o.onopen=function(){t.backoff=r,i(t,"talkdom:ws:open",{url:e})},o.onmessage=function(n){a(e,n)},o.onclose=function(r){i(t,"talkdom:ws:close",{url:e,code:r.code,reason:r.reason}),function(e){var t=n[e];if(t)if(c(t)){var r=Math.min(t.backoff,3e4);r*=.75+.5*Math.random(),t.timer=setTimeout(function(){t.backoff=Math.min(2*t.backoff,3e4),l(e)},r)}else s(e)}(e)},o.onerror=function(){i(t,"talkdom:ws:error",{url:e})},t.ws=o}}function u(e){var i=e.getAttribute("receiver");if(i){var a=function(e){for(var n=e.trim().split(o),t=1;t<n.length;t++)if("ws:"===n[t]&&n[t+1])return n[t+1];return null}(i);a&&function(e,o){var i=n[o];if(!i){if(Object.keys(n).length>=t)return void console.warn("talkdom-ws: max connections ("+t+") reached, ignoring "+o);i={ws:null,receivers:new Set,backoff:r,timer:null,checkTimer:null},n[o]=i,i.checkTimer=setInterval(function(){c(i)||s(o)},5e3)}i.receivers.add(e),l(o)}(e,a)}}}();
@@ -0,0 +1 @@
1
+ {"version":3,"names":["window","talkDOM","methods","connections","Object","create","maxConnections","BASE_DELAY","WS","MutationObserver","mutations","i","length","added","addedNodes","j","node","nodeType","hasAttribute","initElement","querySelectorAll","forEach","observe","document","childList","subtree","el","url","conn","ws","readyState","WebSocket","OPEN","console","error","Promise","reject","payload","value","textContent","send","connect","keys","warn","receivers","Set","backoff","timer","checkTimer","setInterval","pruneReceivers","cleanup","connectWs","disconnect","data","JSON","stringify","out","state","size","n","isConnected","delete","fireEvent","name","detail","dispatchEvent","CustomEvent","bubbles","onMessage","event","charAt","msg","targets","receiver","op","content","Array","from","selector","args","routeJson","parse","e","catch","err","clearTimeout","clearInterval","onclose","close","CONNECTING","onopen","onmessage","code","reason","delay","Math","min","random","setTimeout","scheduleReconnect","onerror","attr","getAttribute","tokens","trim","split","parseWsUrl","add","subscribe"],"sources":["websocket.js"],"mappings":"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","ignoreList":[]}
package/package.json CHANGED
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "talkdom",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Smalltalk-inspired message passing for the DOM",
5
5
  "main": "index.js",
6
6
  "jsdelivr": "dist/talkdom.min.js",
7
7
  "unpkg": "dist/talkdom.min.js",
8
8
  "files": [
9
9
  "index.js",
10
+ "websocket.js",
10
11
  "dist/"
11
12
  ],
12
13
  "scripts": {
13
- "build": "node -e \"require('fs').mkdirSync('dist',{recursive:true})\" && terser index.js -o dist/talkdom.min.js -c -m --source-map",
14
+ "build": "node -e \"require('fs').mkdirSync('dist',{recursive:true})\" && terser index.js -o dist/talkdom.min.js -c -m --source-map && terser websocket.js -o dist/talkdom-ws.min.js -c -m --source-map",
14
15
  "test": "node test-runner.js",
15
16
  "lint": "eslint index.js",
16
17
  "prepublishOnly": "npm run build"
package/websocket.js ADDED
@@ -0,0 +1,234 @@
1
+ (function () {
2
+
3
+ if (!window.talkDOM) {
4
+ console.error("talkdom-ws: load index.js before websocket.js");
5
+ return;
6
+ }
7
+
8
+ var methods = talkDOM.methods;
9
+ var connections = Object.create(null);
10
+ var maxConnections = 16;
11
+ var BASE_DELAY = 1000;
12
+ var MAX_DELAY = 30000;
13
+ var CLEANUP_INTERVAL = 5000;
14
+ var WS = /\s+/;
15
+
16
+ // Parse receiver attribute to extract ws: URL.
17
+ // Returns the URL string or null if no ws: keyword found.
18
+ function parseWsUrl(attr) {
19
+ var tokens = attr.trim().split(WS);
20
+ for (var i = 1; i < tokens.length; i++) {
21
+ if (tokens[i] === "ws:" && tokens[i + 1]) return tokens[i + 1];
22
+ }
23
+ return null;
24
+ }
25
+
26
+ // Remove disconnected elements from a connection's receiver set.
27
+ // Returns true if any receivers remain.
28
+ function pruneReceivers(conn) {
29
+ conn.receivers.forEach(function (el) {
30
+ if (!el.isConnected) conn.receivers.delete(el);
31
+ });
32
+ return conn.receivers.size > 0;
33
+ }
34
+
35
+ // Fire a custom event on all connected receivers for a URL.
36
+ function fireEvent(conn, name, detail) {
37
+ conn.receivers.forEach(function (el) {
38
+ if (el.isConnected) {
39
+ el.dispatchEvent(new CustomEvent(name, { bubbles: true, detail: detail }));
40
+ }
41
+ });
42
+ }
43
+
44
+ // Route a parsed JSON message to matching receiver elements.
45
+ function routeJson(conn, msg) {
46
+ var name = msg.receiver;
47
+ var op = msg.op || "inner";
48
+ var content = msg.content || "";
49
+ var targets;
50
+ if (name) {
51
+ targets = document.querySelectorAll('[receiver~="' + name + '"]');
52
+ } else {
53
+ // Broadcast to all receivers on this connection.
54
+ targets = Array.from(conn.receivers);
55
+ }
56
+ var detail = { receiver: name || "", selector: "apply:", args: [content, op] };
57
+ for (var i = 0; i < targets.length; i++) {
58
+ var el = targets[i];
59
+ methods["apply:"](el, content, op);
60
+ el.dispatchEvent(new CustomEvent("talkdom:done", { bubbles: true, detail: detail }));
61
+ }
62
+ }
63
+
64
+ // Handle an incoming WebSocket message.
65
+ function onMessage(url, event) {
66
+ var conn = connections[url];
67
+ if (!conn) return;
68
+ pruneReceivers(conn);
69
+ var data = event.data;
70
+ if (typeof data !== "string") return; // ignore binary
71
+ if (data.charAt(0) === "{") {
72
+ try {
73
+ var msg = JSON.parse(data);
74
+ routeJson(conn, msg);
75
+ } catch (e) {
76
+ console.error("talkdom-ws: invalid JSON from " + url, e);
77
+ }
78
+ } else {
79
+ // Raw talkDOM message syntax, dispatch through core.
80
+ talkDOM.send(data).catch(function (err) {
81
+ console.warn("talkdom-ws:", err);
82
+ });
83
+ }
84
+ }
85
+
86
+ function scheduleReconnect(url) {
87
+ var conn = connections[url];
88
+ if (!conn) return;
89
+ if (!pruneReceivers(conn)) { cleanup(url); return; }
90
+ var delay = Math.min(conn.backoff, MAX_DELAY);
91
+ delay = delay * (0.75 + Math.random() * 0.5);
92
+ conn.timer = setTimeout(function () {
93
+ conn.backoff = Math.min(conn.backoff * 2, MAX_DELAY);
94
+ connectWs(url);
95
+ }, delay);
96
+ }
97
+
98
+ function cleanup(url) {
99
+ var conn = connections[url];
100
+ if (!conn) return;
101
+ if (conn.timer) clearTimeout(conn.timer);
102
+ if (conn.checkTimer) clearInterval(conn.checkTimer);
103
+ if (conn.ws) {
104
+ conn.ws.onclose = null; // prevent reconnect on intentional close
105
+ conn.ws.close();
106
+ }
107
+ delete connections[url];
108
+ }
109
+
110
+ function connectWs(url) {
111
+ var conn = connections[url];
112
+ if (!conn) return;
113
+ // Already open or connecting — skip.
114
+ if (conn.ws && (conn.ws.readyState === WebSocket.OPEN || conn.ws.readyState === WebSocket.CONNECTING)) return;
115
+
116
+ var ws = new WebSocket(url);
117
+
118
+ ws.onopen = function () {
119
+ conn.backoff = BASE_DELAY;
120
+ fireEvent(conn, "talkdom:ws:open", { url: url });
121
+ };
122
+
123
+ ws.onmessage = function (e) {
124
+ onMessage(url, e);
125
+ };
126
+
127
+ ws.onclose = function (e) {
128
+ fireEvent(conn, "talkdom:ws:close", { url: url, code: e.code, reason: e.reason });
129
+ scheduleReconnect(url);
130
+ };
131
+
132
+ ws.onerror = function () {
133
+ fireEvent(conn, "talkdom:ws:error", { url: url });
134
+ };
135
+
136
+ conn.ws = ws;
137
+ }
138
+
139
+ // Subscribe an element to a WebSocket URL.
140
+ function subscribe(el, url) {
141
+ var conn = connections[url];
142
+ if (!conn) {
143
+ var count = Object.keys(connections).length;
144
+ if (count >= maxConnections) {
145
+ console.warn("talkdom-ws: max connections (" + maxConnections + ") reached, ignoring " + url);
146
+ return;
147
+ }
148
+ conn = { ws: null, receivers: new Set(), backoff: BASE_DELAY, timer: null, checkTimer: null };
149
+ connections[url] = conn;
150
+ // Periodic cleanup check for this connection.
151
+ conn.checkTimer = setInterval(function () {
152
+ if (!pruneReceivers(conn)) cleanup(url);
153
+ }, CLEANUP_INTERVAL);
154
+ }
155
+ conn.receivers.add(el);
156
+ connectWs(url);
157
+ }
158
+
159
+ // Scan a single element for ws: keyword and subscribe.
160
+ function initElement(el) {
161
+ var attr = el.getAttribute("receiver");
162
+ if (!attr) return;
163
+ var url = parseWsUrl(attr);
164
+ if (!url) return;
165
+ subscribe(el, url);
166
+ }
167
+
168
+ // Scan all existing receiver elements.
169
+ function initWsReceivers() {
170
+ document.querySelectorAll("[receiver]").forEach(initElement);
171
+ }
172
+
173
+ // Watch for dynamically added ws: receivers.
174
+ new MutationObserver(function (mutations) {
175
+ for (var i = 0; i < mutations.length; i++) {
176
+ var added = mutations[i].addedNodes;
177
+ for (var j = 0; j < added.length; j++) {
178
+ var node = added[j];
179
+ if (node.nodeType !== 1) continue;
180
+ if (node.hasAttribute && node.hasAttribute("receiver")) initElement(node);
181
+ if (node.querySelectorAll) {
182
+ node.querySelectorAll("[receiver]").forEach(initElement);
183
+ }
184
+ }
185
+ }
186
+ }).observe(document, { childList: true, subtree: true });
187
+
188
+ // ws:send: method — send element value over an existing WebSocket connection.
189
+ methods["ws:send:"] = function (el, url) {
190
+ var conn = connections[url];
191
+ if (!conn || !conn.ws || conn.ws.readyState !== WebSocket.OPEN) {
192
+ console.error("talkdom-ws: no open connection to " + url);
193
+ return Promise.reject("not connected");
194
+ }
195
+ var payload = ("value" in el) ? el.value : el.textContent;
196
+ conn.ws.send(payload);
197
+ };
198
+
199
+ initWsReceivers();
200
+
201
+ talkDOM.ws = {
202
+ connect: function (url) {
203
+ if (!connections[url]) {
204
+ var count = Object.keys(connections).length;
205
+ if (count >= maxConnections) {
206
+ console.warn("talkdom-ws: max connections (" + maxConnections + ") reached");
207
+ return;
208
+ }
209
+ connections[url] = { ws: null, receivers: new Set(), backoff: BASE_DELAY, timer: null, checkTimer: null };
210
+ connections[url].checkTimer = setInterval(function () {
211
+ if (!pruneReceivers(connections[url])) cleanup(url);
212
+ }, CLEANUP_INTERVAL);
213
+ }
214
+ connectWs(url);
215
+ },
216
+ disconnect: function (url) { cleanup(url); },
217
+ send: function (url, data) {
218
+ var conn = connections[url];
219
+ if (!conn || !conn.ws || conn.ws.readyState !== WebSocket.OPEN) return false;
220
+ conn.ws.send(typeof data === "string" ? data : JSON.stringify(data));
221
+ return true;
222
+ },
223
+ get connections() {
224
+ var out = Object.create(null);
225
+ for (var url in connections) {
226
+ out[url] = { state: connections[url].ws ? connections[url].ws.readyState : -1, receivers: connections[url].receivers.size };
227
+ }
228
+ return out;
229
+ },
230
+ get maxConnections() { return maxConnections; },
231
+ set maxConnections(n) { maxConnections = n; },
232
+ };
233
+
234
+ }());