@mml-io/networked-dom-web 0.0.42

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 @@
1
+ export * from "../src/index";
package/build/index.js ADDED
@@ -0,0 +1,447 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __export = (target, all) => {
6
+ for (var name in all)
7
+ __defProp(target, name, { get: all[name], enumerable: true });
8
+ };
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
+
19
+ // src/index.ts
20
+ var src_exports = {};
21
+ __export(src_exports, {
22
+ DOMSanitizer: () => DOMSanitizer,
23
+ NetworkedDOMWebsocket: () => NetworkedDOMWebsocket,
24
+ NetworkedDOMWebsocketStatus: () => NetworkedDOMWebsocketStatus
25
+ });
26
+ module.exports = __toCommonJS(src_exports);
27
+
28
+ // src/DOMSanitizer.ts
29
+ var DOMSanitizer = class {
30
+ static sanitise(node) {
31
+ if (node.getAttributeNames) {
32
+ for (const attr of node.getAttributeNames()) {
33
+ if (!DOMSanitizer.IsValidAttributeName(attr)) {
34
+ node.removeAttribute(attr);
35
+ }
36
+ }
37
+ }
38
+ if (node.nodeName === "SCRIPT") {
39
+ node.innerText = "";
40
+ } else {
41
+ if (node.getAttributeNames) {
42
+ for (const attr of node.getAttributeNames()) {
43
+ if (!DOMSanitizer.shouldAcceptAttribute(attr)) {
44
+ node.removeAttribute(attr);
45
+ }
46
+ }
47
+ }
48
+ for (let i = 0; i < node.childNodes.length; i++) {
49
+ DOMSanitizer.sanitise(node.childNodes[i]);
50
+ }
51
+ }
52
+ }
53
+ static IsASCIIDigit(c) {
54
+ return c >= "0" && c <= "9";
55
+ }
56
+ static IsASCIIAlpha(c) {
57
+ return c >= "a" && c <= "z";
58
+ }
59
+ static IsValidAttributeName(characters) {
60
+ const c = characters[0];
61
+ if (!(DOMSanitizer.IsASCIIAlpha(c) || c === ":" || c === "_")) {
62
+ return false;
63
+ }
64
+ for (let i = 1; i < characters.length; i++) {
65
+ const c2 = characters[i];
66
+ if (!(DOMSanitizer.IsASCIIDigit(c2) || DOMSanitizer.IsASCIIAlpha(c2) || c2 === ":" || c2 === "_" || c2 === "-" || c2 === ".")) {
67
+ return false;
68
+ }
69
+ }
70
+ return true;
71
+ }
72
+ static shouldAcceptAttribute(attribute) {
73
+ if (!DOMSanitizer.IsValidAttributeName(attribute)) {
74
+ console.warn("Invalid attribute name", attribute);
75
+ return false;
76
+ }
77
+ return !attribute.startsWith("on");
78
+ }
79
+ };
80
+
81
+ // src/NetworkedDOMWebsocket.ts
82
+ var websocketProtocol = "networked-dom-v0.1";
83
+ var startingBackoffTimeMilliseconds = 100;
84
+ var maximumBackoffTimeMilliseconds = 1e4;
85
+ var maximumWebsocketConnectionTimeout = 5e3;
86
+ var NetworkedDOMWebsocketStatus = /* @__PURE__ */ ((NetworkedDOMWebsocketStatus2) => {
87
+ NetworkedDOMWebsocketStatus2[NetworkedDOMWebsocketStatus2["Connecting"] = 0] = "Connecting";
88
+ NetworkedDOMWebsocketStatus2[NetworkedDOMWebsocketStatus2["Connected"] = 1] = "Connected";
89
+ NetworkedDOMWebsocketStatus2[NetworkedDOMWebsocketStatus2["Reconnecting"] = 2] = "Reconnecting";
90
+ NetworkedDOMWebsocketStatus2[NetworkedDOMWebsocketStatus2["Disconnected"] = 3] = "Disconnected";
91
+ return NetworkedDOMWebsocketStatus2;
92
+ })(NetworkedDOMWebsocketStatus || {});
93
+ var NetworkedDOMWebsocket = class {
94
+ constructor(url, websocketFactory, parentElement, timeCallback, statusUpdateCallback) {
95
+ this.idToElement = /* @__PURE__ */ new Map();
96
+ this.elementToId = /* @__PURE__ */ new Map();
97
+ this.websocket = null;
98
+ this.currentRoot = null;
99
+ this.stopped = false;
100
+ this.backoffTime = startingBackoffTimeMilliseconds;
101
+ this.status = null;
102
+ this.url = url;
103
+ this.websocketFactory = websocketFactory;
104
+ this.parentElement = parentElement;
105
+ this.timeCallback = timeCallback || (() => {
106
+ });
107
+ this.statusUpdateCallback = statusUpdateCallback || (() => {
108
+ });
109
+ this.setStatus(0 /* Connecting */);
110
+ this.startWebSocketConnectionAttempt();
111
+ }
112
+ static createWebSocket(url) {
113
+ return new WebSocket(url, [websocketProtocol]);
114
+ }
115
+ setStatus(status) {
116
+ if (this.status !== status) {
117
+ this.status = status;
118
+ this.statusUpdateCallback(status);
119
+ }
120
+ }
121
+ createWebsocketWithTimeout(timeout) {
122
+ return new Promise((resolve, reject) => {
123
+ const timeoutId = setTimeout(() => {
124
+ reject(new Error("websocket connection timed out"));
125
+ }, timeout);
126
+ const websocket = this.websocketFactory(this.url);
127
+ websocket.addEventListener("open", () => {
128
+ clearTimeout(timeoutId);
129
+ this.websocket = websocket;
130
+ websocket.addEventListener("message", (event) => {
131
+ if (websocket !== this.websocket) {
132
+ console.log("Ignoring websocket message event because it is no longer current");
133
+ websocket.close();
134
+ return;
135
+ }
136
+ this.handleIncomingWebsocketMessage(event);
137
+ });
138
+ const onWebsocketClose = async () => {
139
+ const hadContents = this.currentRoot !== null;
140
+ this.clearContents();
141
+ if (this.stopped) {
142
+ this.setStatus(3 /* Disconnected */);
143
+ return;
144
+ }
145
+ if (!hadContents) {
146
+ await this.waitBackoffTime();
147
+ }
148
+ this.setStatus(2 /* Reconnecting */);
149
+ this.startWebSocketConnectionAttempt();
150
+ };
151
+ websocket.addEventListener("close", (e) => {
152
+ if (websocket !== this.websocket) {
153
+ console.warn("Ignoring websocket close event because it is no longer current");
154
+ return;
155
+ }
156
+ console.log("NetworkedDOMWebsocket close", e);
157
+ onWebsocketClose();
158
+ });
159
+ websocket.addEventListener("error", (e) => {
160
+ if (websocket !== this.websocket) {
161
+ console.log("Ignoring websocket error event because it is no longer current");
162
+ return;
163
+ }
164
+ console.error("NetworkedDOMWebsocket error", e);
165
+ onWebsocketClose();
166
+ });
167
+ resolve(websocket);
168
+ });
169
+ websocket.addEventListener("error", (e) => {
170
+ clearTimeout(timeoutId);
171
+ reject(e);
172
+ });
173
+ });
174
+ }
175
+ async waitBackoffTime() {
176
+ console.warn(`Websocket connection to '${this.url}' failed: retrying in ${this.backoffTime}ms`);
177
+ await new Promise((resolve) => setTimeout(resolve, this.backoffTime));
178
+ this.backoffTime = Math.min(
179
+ // Introduce a small amount of randomness to prevent clients from retrying in lockstep
180
+ this.backoffTime * (1.5 + Math.random() * 0.5),
181
+ maximumBackoffTimeMilliseconds
182
+ );
183
+ }
184
+ async startWebSocketConnectionAttempt() {
185
+ if (this.stopped) {
186
+ return;
187
+ }
188
+ while (true) {
189
+ if (this.stopped) {
190
+ return;
191
+ }
192
+ try {
193
+ await this.createWebsocketWithTimeout(maximumWebsocketConnectionTimeout);
194
+ break;
195
+ } catch (e) {
196
+ this.setStatus(2 /* Reconnecting */);
197
+ await this.waitBackoffTime();
198
+ }
199
+ }
200
+ }
201
+ handleIncomingWebsocketMessage(event) {
202
+ const messages = JSON.parse(event.data);
203
+ for (const message of messages) {
204
+ if (message.type === "error") {
205
+ console.error("Error from server", message);
206
+ } else if (message.type === "warning") {
207
+ console.warn("Warning from server", message);
208
+ } else {
209
+ if (message.documentTime) {
210
+ if (this.timeCallback) {
211
+ this.timeCallback(message.documentTime);
212
+ }
213
+ }
214
+ if (message.type === "snapshot") {
215
+ this.backoffTime = startingBackoffTimeMilliseconds;
216
+ this.setStatus(1 /* Connected */);
217
+ if (this.currentRoot) {
218
+ this.currentRoot.remove();
219
+ this.currentRoot = null;
220
+ this.elementToId.clear();
221
+ this.idToElement.clear();
222
+ }
223
+ const element = this.handleNewElement(message.snapshot);
224
+ if (!element) {
225
+ throw new Error("Snapshot element not created");
226
+ }
227
+ if (!(element instanceof HTMLElement)) {
228
+ throw new Error("Snapshot element is not an HTMLElement");
229
+ }
230
+ this.currentRoot = element;
231
+ this.parentElement.append(element);
232
+ } else if (message.type === "attributeChange") {
233
+ this.handleAttributeChange(message);
234
+ } else if (message.type === "childrenChanged") {
235
+ this.handleChildrenChanged(message);
236
+ } else if (message.type === "textChanged") {
237
+ this.handleTextChanged(message);
238
+ } else if (message.type === "ping") {
239
+ this.send({
240
+ type: "pong",
241
+ pong: message.ping
242
+ });
243
+ } else {
244
+ console.warn("unknown message type", message);
245
+ }
246
+ }
247
+ }
248
+ }
249
+ stop() {
250
+ this.stopped = true;
251
+ if (this.websocket !== null) {
252
+ this.websocket.close();
253
+ this.websocket = null;
254
+ }
255
+ }
256
+ handleEvent(element, event) {
257
+ const nodeId = this.elementToId.get(element);
258
+ if (nodeId === void 0 || nodeId === null) {
259
+ throw new Error("Element not found");
260
+ }
261
+ console.log(
262
+ `Sending event to websocket: "${event.type}" on node: ${nodeId} type: ${element.tagName}`
263
+ );
264
+ const detailWithoutElement = {
265
+ ...event.detail
266
+ };
267
+ delete detailWithoutElement.element;
268
+ const remoteEvent = {
269
+ type: "event",
270
+ nodeId,
271
+ name: event.type,
272
+ bubbles: event.bubbles,
273
+ params: detailWithoutElement
274
+ };
275
+ this.send(remoteEvent);
276
+ }
277
+ send(fromClientMessage) {
278
+ if (!this.websocket) {
279
+ throw new Error("No websocket created");
280
+ }
281
+ this.websocket.send(JSON.stringify(fromClientMessage));
282
+ }
283
+ handleTextChanged(message) {
284
+ const { nodeId, text } = message;
285
+ if (nodeId === void 0 || nodeId === null) {
286
+ console.warn("No nodeId in textChanged message");
287
+ return;
288
+ }
289
+ const node = this.idToElement.get(nodeId);
290
+ if (!node) {
291
+ throw new Error("No node found for textChanged message");
292
+ }
293
+ if (!(node instanceof Text)) {
294
+ throw new Error("Node for textChanged message is not a Text node");
295
+ }
296
+ node.textContent = text;
297
+ }
298
+ handleChildrenChanged(message) {
299
+ const { nodeId, addedNodes, removedNodes, previousNodeId } = message;
300
+ if (nodeId === void 0 || nodeId === null) {
301
+ console.warn("No nodeId in childrenChanged message");
302
+ return;
303
+ }
304
+ const parent = this.idToElement.get(nodeId);
305
+ if (!parent) {
306
+ throw new Error("No parent found for childrenChanged message");
307
+ }
308
+ if (!parent.isConnected) {
309
+ console.error("Parent is not connected", parent);
310
+ }
311
+ if (!(parent instanceof HTMLElement)) {
312
+ throw new Error("Parent is not an HTMLElement (that supports children)");
313
+ }
314
+ let previousElement;
315
+ if (previousNodeId) {
316
+ previousElement = this.idToElement.get(previousNodeId);
317
+ if (!previousElement) {
318
+ throw new Error("No previous element found for childrenChanged message");
319
+ }
320
+ }
321
+ for (const addedNode of addedNodes) {
322
+ const childElement = this.handleNewElement(addedNode);
323
+ if (childElement) {
324
+ if (previousElement) {
325
+ const nextElement = previousElement.nextSibling;
326
+ if (nextElement) {
327
+ parent.insertBefore(childElement, nextElement);
328
+ } else {
329
+ parent.append(childElement);
330
+ }
331
+ } else {
332
+ parent.append(childElement);
333
+ }
334
+ }
335
+ }
336
+ for (const removedNode of removedNodes) {
337
+ const childElement = this.idToElement.get(removedNode);
338
+ if (!childElement) {
339
+ throw new Error(`Child element not found: ${removedNode}`);
340
+ }
341
+ this.elementToId.delete(childElement);
342
+ this.idToElement.delete(removedNode);
343
+ parent.removeChild(childElement);
344
+ if (childElement instanceof HTMLElement) {
345
+ this.removeChildElementIds(childElement);
346
+ }
347
+ }
348
+ }
349
+ removeChildElementIds(parent) {
350
+ for (let i = 0; i < parent.children.length; i++) {
351
+ const child = parent.children[i];
352
+ const childId = this.elementToId.get(child);
353
+ if (!childId) {
354
+ console.error("Inner child of removed element had no id", child);
355
+ } else {
356
+ this.elementToId.delete(child);
357
+ this.idToElement.delete(childId);
358
+ }
359
+ this.removeChildElementIds(child);
360
+ }
361
+ }
362
+ handleAttributeChange(message) {
363
+ const { nodeId, attribute, newValue } = message;
364
+ if (nodeId === void 0 || nodeId === null) {
365
+ console.warn("No nodeId in attributeChange message");
366
+ return;
367
+ }
368
+ const element = this.idToElement.get(nodeId);
369
+ if (element) {
370
+ if (element instanceof HTMLElement) {
371
+ if (newValue === null) {
372
+ element.removeAttribute(attribute);
373
+ } else {
374
+ if (DOMSanitizer.shouldAcceptAttribute(attribute)) {
375
+ element.setAttribute(attribute, newValue);
376
+ }
377
+ }
378
+ } else {
379
+ console.error("Element is not an HTMLElement and cannot support attributes", element);
380
+ }
381
+ } else {
382
+ console.error("No element found for attributeChange message");
383
+ }
384
+ }
385
+ handleNewElement(message) {
386
+ if (message.type === "text") {
387
+ const { nodeId: nodeId2, text: text2 } = message;
388
+ const textNode = document.createTextNode("");
389
+ textNode.textContent = text2;
390
+ this.idToElement.set(nodeId2, textNode);
391
+ this.elementToId.set(textNode, nodeId2);
392
+ return textNode;
393
+ }
394
+ const { tag, nodeId, attributes, children, text } = message;
395
+ if (nodeId === void 0 || nodeId === null) {
396
+ console.warn("No nodeId in handleNewElement message", message);
397
+ return null;
398
+ }
399
+ if (this.idToElement.has(nodeId)) {
400
+ console.error(
401
+ "Received nodeId to add that is already present",
402
+ nodeId,
403
+ this.idToElement.get(nodeId)
404
+ );
405
+ }
406
+ if (tag === "#text") {
407
+ const textNode = document.createTextNode("");
408
+ textNode.textContent = text || null;
409
+ this.idToElement.set(nodeId, textNode);
410
+ this.elementToId.set(textNode, nodeId);
411
+ return textNode;
412
+ }
413
+ const element = document.createElement(tag);
414
+ this.idToElement.set(nodeId, element);
415
+ this.elementToId.set(element, nodeId);
416
+ for (const key in attributes) {
417
+ if (DOMSanitizer.shouldAcceptAttribute(key)) {
418
+ const value = attributes[key];
419
+ element.setAttribute(key, value);
420
+ }
421
+ }
422
+ if (children) {
423
+ for (const child of children) {
424
+ const childElement = this.handleNewElement(child);
425
+ if (childElement) {
426
+ element.append(childElement);
427
+ }
428
+ }
429
+ }
430
+ return element;
431
+ }
432
+ clearContents() {
433
+ this.idToElement.clear();
434
+ this.elementToId.clear();
435
+ if (this.currentRoot) {
436
+ this.currentRoot.remove();
437
+ this.currentRoot = null;
438
+ }
439
+ }
440
+ };
441
+ // Annotate the CommonJS export names for ESM import in node:
442
+ 0 && (module.exports = {
443
+ DOMSanitizer,
444
+ NetworkedDOMWebsocket,
445
+ NetworkedDOMWebsocketStatus
446
+ });
447
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.ts", "../src/DOMSanitizer.ts", "../src/NetworkedDOMWebsocket.ts"],
4
+ "sourcesContent": ["export * from \"./NetworkedDOMWebsocket\";\nexport * from \"./DOMSanitizer\";\n", "export class DOMSanitizer {\n static sanitise(node: HTMLElement) {\n if (node.getAttributeNames) {\n for (const attr of node.getAttributeNames()) {\n if (!DOMSanitizer.IsValidAttributeName(attr)) {\n node.removeAttribute(attr);\n }\n }\n }\n if (node.nodeName === \"SCRIPT\") {\n // set text to empty string\n node.innerText = \"\";\n } else {\n if (node.getAttributeNames) {\n for (const attr of node.getAttributeNames()) {\n if (!DOMSanitizer.shouldAcceptAttribute(attr)) {\n node.removeAttribute(attr);\n }\n }\n }\n for (let i = 0; i < node.childNodes.length; i++) {\n DOMSanitizer.sanitise(node.childNodes[i] as HTMLElement);\n }\n }\n }\n\n static IsASCIIDigit(c: string): boolean {\n return c >= \"0\" && c <= \"9\";\n }\n\n static IsASCIIAlpha(c: string) {\n return c >= \"a\" && c <= \"z\";\n }\n\n static IsValidAttributeName(characters: string): boolean {\n const c = characters[0];\n if (!(DOMSanitizer.IsASCIIAlpha(c) || c === \":\" || c === \"_\")) {\n return false;\n }\n\n for (let i = 1; i < characters.length; i++) {\n const c = characters[i];\n if (\n !(\n DOMSanitizer.IsASCIIDigit(c) ||\n DOMSanitizer.IsASCIIAlpha(c) ||\n c === \":\" ||\n c === \"_\" ||\n c === \"-\" ||\n c === \".\"\n )\n ) {\n return false;\n }\n }\n\n return true;\n }\n\n static shouldAcceptAttribute(attribute: string) {\n if (!DOMSanitizer.IsValidAttributeName(attribute)) {\n console.warn(\"Invalid attribute name\", attribute);\n return false;\n }\n\n // TODO - this might be overly restrictive - apologies to someone that finds this because you have a non-event attribute filtered by this\n return !attribute.startsWith(\"on\");\n }\n}\n", "import {\n AttributeChangedDiff,\n ChildrenChangedDiff,\n ClientMessage,\n NodeDescription,\n RemoteEvent,\n ServerMessage,\n TextChangedDiff,\n} from \"@mml-io/networked-dom-protocol\";\n\nimport { DOMSanitizer } from \"./DOMSanitizer\";\n\nconst websocketProtocol = \"networked-dom-v0.1\";\n\nconst startingBackoffTimeMilliseconds = 100;\nconst maximumBackoffTimeMilliseconds = 10000;\nconst maximumWebsocketConnectionTimeout = 5000;\n\nexport type NetworkedDOMWebsocketFactory = (url: string) => WebSocket;\n\nexport enum NetworkedDOMWebsocketStatus {\n Connecting,\n Connected,\n Reconnecting,\n Disconnected,\n}\n\nexport class NetworkedDOMWebsocket {\n private idToElement = new Map<number, Node>();\n private elementToId = new Map<Node, number>();\n private websocket: WebSocket | null = null;\n private currentRoot: HTMLElement | null = null;\n\n private url: string;\n private websocketFactory: NetworkedDOMWebsocketFactory;\n private parentElement: HTMLElement;\n private timeCallback: (time: number) => void;\n private statusUpdateCallback: (status: NetworkedDOMWebsocketStatus) => void;\n private stopped = false;\n private backoffTime = startingBackoffTimeMilliseconds;\n private status: NetworkedDOMWebsocketStatus | null = null;\n\n public static createWebSocket(url: string): WebSocket {\n return new WebSocket(url, [websocketProtocol]);\n }\n\n constructor(\n url: string,\n websocketFactory: NetworkedDOMWebsocketFactory,\n parentElement: HTMLElement,\n timeCallback?: (time: number) => void,\n statusUpdateCallback?: (status: NetworkedDOMWebsocketStatus) => void,\n ) {\n this.url = url;\n this.websocketFactory = websocketFactory;\n this.parentElement = parentElement;\n this.timeCallback =\n timeCallback ||\n (() => {\n // no-op\n });\n this.statusUpdateCallback =\n statusUpdateCallback ||\n (() => {\n // no-op\n });\n this.setStatus(NetworkedDOMWebsocketStatus.Connecting);\n this.startWebSocketConnectionAttempt();\n }\n\n private setStatus(status: NetworkedDOMWebsocketStatus) {\n if (this.status !== status) {\n this.status = status;\n this.statusUpdateCallback(status);\n }\n }\n\n private createWebsocketWithTimeout(timeout: number): Promise<WebSocket> {\n return new Promise((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n reject(new Error(\"websocket connection timed out\"));\n }, timeout);\n const websocket = this.websocketFactory(this.url);\n websocket.addEventListener(\"open\", () => {\n clearTimeout(timeoutId);\n\n this.websocket = websocket;\n\n websocket.addEventListener(\"message\", (event) => {\n if (websocket !== this.websocket) {\n console.log(\"Ignoring websocket message event because it is no longer current\");\n websocket.close();\n return;\n }\n this.handleIncomingWebsocketMessage(event);\n });\n\n const onWebsocketClose = async () => {\n const hadContents = this.currentRoot !== null;\n this.clearContents();\n if (this.stopped) {\n // This closing is expected. The client closed the websocket.\n this.setStatus(NetworkedDOMWebsocketStatus.Disconnected);\n return;\n }\n if (!hadContents) {\n // The websocket did not deliver any contents. It may have been successfully opened, but immediately closed. This client should back off to prevent this happening in a rapid loop.\n await this.waitBackoffTime();\n }\n // The websocket closed unexpectedly. Try to reconnect.\n this.setStatus(NetworkedDOMWebsocketStatus.Reconnecting);\n this.startWebSocketConnectionAttempt();\n };\n\n websocket.addEventListener(\"close\", (e) => {\n if (websocket !== this.websocket) {\n console.warn(\"Ignoring websocket close event because it is no longer current\");\n return;\n }\n console.log(\"NetworkedDOMWebsocket close\", e);\n onWebsocketClose();\n });\n websocket.addEventListener(\"error\", (e) => {\n if (websocket !== this.websocket) {\n console.log(\"Ignoring websocket error event because it is no longer current\");\n return;\n }\n console.error(\"NetworkedDOMWebsocket error\", e);\n onWebsocketClose();\n });\n\n resolve(websocket);\n });\n websocket.addEventListener(\"error\", (e) => {\n clearTimeout(timeoutId);\n reject(e);\n });\n });\n }\n\n private async waitBackoffTime(): Promise<void> {\n console.warn(`Websocket connection to '${this.url}' failed: retrying in ${this.backoffTime}ms`);\n await new Promise((resolve) => setTimeout(resolve, this.backoffTime));\n this.backoffTime = Math.min(\n // Introduce a small amount of randomness to prevent clients from retrying in lockstep\n this.backoffTime * (1.5 + Math.random() * 0.5),\n maximumBackoffTimeMilliseconds,\n );\n }\n\n private async startWebSocketConnectionAttempt() {\n if (this.stopped) {\n return;\n }\n // eslint-disable-next-line no-constant-condition\n while (true) {\n if (this.stopped) {\n return;\n }\n try {\n await this.createWebsocketWithTimeout(maximumWebsocketConnectionTimeout);\n break;\n } catch (e) {\n // Connection failed, retry with backoff\n this.setStatus(NetworkedDOMWebsocketStatus.Reconnecting);\n await this.waitBackoffTime();\n }\n }\n }\n\n private handleIncomingWebsocketMessage(event: MessageEvent) {\n const messages = JSON.parse(event.data) as Array<ServerMessage>;\n for (const message of messages) {\n if (message.type === \"error\") {\n console.error(\"Error from server\", message);\n } else if (message.type === \"warning\") {\n console.warn(\"Warning from server\", message);\n } else {\n if (message.documentTime) {\n if (this.timeCallback) {\n this.timeCallback(message.documentTime);\n }\n }\n if (message.type === \"snapshot\") {\n // This websocket is successfully connected. Reset the backoff time.\n this.backoffTime = startingBackoffTimeMilliseconds;\n this.setStatus(NetworkedDOMWebsocketStatus.Connected);\n\n if (this.currentRoot) {\n this.currentRoot.remove();\n this.currentRoot = null;\n this.elementToId.clear();\n this.idToElement.clear();\n }\n\n // create a tree of DOM elements\n // NOTE: the MLElement contructors are not executed during this stage\n const element = this.handleNewElement(message.snapshot);\n if (!element) {\n throw new Error(\"Snapshot element not created\");\n }\n if (!(element instanceof HTMLElement)) {\n throw new Error(\"Snapshot element is not an HTMLElement\");\n }\n this.currentRoot = element;\n // appending to the tree causes MElements to be constructed\n this.parentElement.append(element);\n } else if (message.type === \"attributeChange\") {\n this.handleAttributeChange(message);\n } else if (message.type === \"childrenChanged\") {\n this.handleChildrenChanged(message);\n } else if (message.type === \"textChanged\") {\n this.handleTextChanged(message);\n } else if (message.type === \"ping\") {\n this.send({\n type: \"pong\",\n pong: message.ping,\n });\n } else {\n console.warn(\"unknown message type\", message);\n }\n }\n }\n }\n\n public stop() {\n this.stopped = true;\n if (this.websocket !== null) {\n this.websocket.close();\n this.websocket = null;\n }\n }\n\n public handleEvent(element: HTMLElement, event: CustomEvent<{ element: HTMLElement }>) {\n const nodeId = this.elementToId.get(element);\n if (nodeId === undefined || nodeId === null) {\n throw new Error(\"Element not found\");\n }\n\n console.log(\n `Sending event to websocket: \"${event.type}\" on node: ${nodeId} type: ${element.tagName}`,\n );\n\n const detailWithoutElement: Partial<typeof event.detail> = {\n ...event.detail,\n };\n delete detailWithoutElement.element;\n\n const remoteEvent: RemoteEvent = {\n type: \"event\",\n nodeId,\n name: event.type,\n bubbles: event.bubbles,\n params: detailWithoutElement,\n };\n\n this.send(remoteEvent);\n }\n\n private send(fromClientMessage: ClientMessage) {\n if (!this.websocket) {\n throw new Error(\"No websocket created\");\n }\n this.websocket.send(JSON.stringify(fromClientMessage));\n }\n\n private handleTextChanged(message: TextChangedDiff) {\n const { nodeId, text } = message;\n\n if (nodeId === undefined || nodeId === null) {\n console.warn(\"No nodeId in textChanged message\");\n return;\n }\n const node = this.idToElement.get(nodeId);\n if (!node) {\n throw new Error(\"No node found for textChanged message\");\n }\n if (!(node instanceof Text)) {\n throw new Error(\"Node for textChanged message is not a Text node\");\n }\n node.textContent = text;\n }\n\n private handleChildrenChanged(message: ChildrenChangedDiff) {\n const { nodeId, addedNodes, removedNodes, previousNodeId } = message;\n if (nodeId === undefined || nodeId === null) {\n console.warn(\"No nodeId in childrenChanged message\");\n return;\n }\n const parent = this.idToElement.get(nodeId);\n if (!parent) {\n throw new Error(\"No parent found for childrenChanged message\");\n }\n if (!parent.isConnected) {\n console.error(\"Parent is not connected\", parent);\n }\n if (!(parent instanceof HTMLElement)) {\n throw new Error(\"Parent is not an HTMLElement (that supports children)\");\n }\n let previousElement;\n if (previousNodeId) {\n previousElement = this.idToElement.get(previousNodeId);\n if (!previousElement) {\n throw new Error(\"No previous element found for childrenChanged message\");\n }\n }\n\n for (const addedNode of addedNodes) {\n const childElement = this.handleNewElement(addedNode);\n if (childElement) {\n if (previousElement) {\n const nextElement = previousElement.nextSibling;\n if (nextElement) {\n parent.insertBefore(childElement, nextElement);\n } else {\n parent.append(childElement);\n }\n } else {\n parent.append(childElement);\n }\n }\n }\n for (const removedNode of removedNodes) {\n const childElement = this.idToElement.get(removedNode);\n if (!childElement) {\n throw new Error(`Child element not found: ${removedNode}`);\n }\n this.elementToId.delete(childElement);\n this.idToElement.delete(removedNode);\n parent.removeChild(childElement);\n if (childElement instanceof HTMLElement) {\n // If child is capable of supporting children then remove any that exist\n this.removeChildElementIds(childElement);\n }\n }\n }\n\n private removeChildElementIds(parent: HTMLElement) {\n for (let i = 0; i < parent.children.length; i++) {\n const child = parent.children[i];\n const childId = this.elementToId.get(child as HTMLElement);\n if (!childId) {\n console.error(\"Inner child of removed element had no id\", child);\n } else {\n this.elementToId.delete(child);\n this.idToElement.delete(childId);\n }\n this.removeChildElementIds(child as HTMLElement);\n }\n }\n\n private handleAttributeChange(message: AttributeChangedDiff) {\n const { nodeId, attribute, newValue } = message;\n if (nodeId === undefined || nodeId === null) {\n console.warn(\"No nodeId in attributeChange message\");\n return;\n }\n const element = this.idToElement.get(nodeId);\n if (element) {\n if (element instanceof HTMLElement) {\n if (newValue === null) {\n element.removeAttribute(attribute);\n } else {\n if (DOMSanitizer.shouldAcceptAttribute(attribute)) {\n element.setAttribute(attribute, newValue);\n }\n }\n } else {\n console.error(\"Element is not an HTMLElement and cannot support attributes\", element);\n }\n } else {\n console.error(\"No element found for attributeChange message\");\n }\n }\n\n private handleNewElement(message: NodeDescription): Node | null {\n if (message.type === \"text\") {\n const { nodeId, text } = message;\n const textNode = document.createTextNode(\"\");\n textNode.textContent = text;\n this.idToElement.set(nodeId, textNode);\n this.elementToId.set(textNode, nodeId);\n return textNode;\n }\n const { tag, nodeId, attributes, children, text } = message;\n if (nodeId === undefined || nodeId === null) {\n console.warn(\"No nodeId in handleNewElement message\", message);\n return null;\n }\n if (this.idToElement.has(nodeId)) {\n console.error(\n \"Received nodeId to add that is already present\",\n nodeId,\n this.idToElement.get(nodeId),\n );\n }\n if (tag === \"#text\") {\n const textNode = document.createTextNode(\"\");\n textNode.textContent = text || null;\n this.idToElement.set(nodeId, textNode);\n this.elementToId.set(textNode, nodeId);\n return textNode;\n }\n\n const element = document.createElement(tag);\n this.idToElement.set(nodeId, element);\n this.elementToId.set(element, nodeId);\n for (const key in attributes) {\n if (DOMSanitizer.shouldAcceptAttribute(key)) {\n const value = attributes[key];\n element.setAttribute(key, value);\n }\n }\n if (children) {\n for (const child of children) {\n const childElement = this.handleNewElement(child);\n if (childElement) {\n element.append(childElement);\n }\n }\n }\n return element;\n }\n\n private clearContents() {\n this.idToElement.clear();\n this.elementToId.clear();\n if (this.currentRoot) {\n this.currentRoot.remove();\n this.currentRoot = null;\n }\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["c", "NetworkedDOMWebsocketStatus", "nodeId", "text"]
7
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@mml-io/networked-dom-web",
3
+ "version": "0.0.42",
4
+ "main": "./build/index.js",
5
+ "types": "./build/index.d.ts",
6
+ "files": [
7
+ "/build",
8
+ "/src"
9
+ ],
10
+ "scripts": {
11
+ "type-check": "tsc --noEmit",
12
+ "build": "node ./build.js --build",
13
+ "iterate": "node ./build.js --watch",
14
+ "npm-publish": "npm run build && publish-if-not-exists --access=public",
15
+ "lint": "eslint \"./src/**/*.{js,jsx,ts,tsx}\" --max-warnings 0",
16
+ "lint-fix": "eslint \"./src/**/*.{js,jsx,ts,tsx}\" --fix",
17
+ "test": "jest",
18
+ "test-iterate": "jest --watch"
19
+ },
20
+ "dependencies": {
21
+ "@mml-io/networked-dom-protocol": "^0.0.42"
22
+ },
23
+ "devDependencies": {
24
+ "jest-canvas-mock": "2.5.0",
25
+ "jest-environment-jsdom": "29.5.0",
26
+ "jest-expect-message": "1.1.3"
27
+ }
28
+ }
@@ -0,0 +1,69 @@
1
+ export class DOMSanitizer {
2
+ static sanitise(node: HTMLElement) {
3
+ if (node.getAttributeNames) {
4
+ for (const attr of node.getAttributeNames()) {
5
+ if (!DOMSanitizer.IsValidAttributeName(attr)) {
6
+ node.removeAttribute(attr);
7
+ }
8
+ }
9
+ }
10
+ if (node.nodeName === "SCRIPT") {
11
+ // set text to empty string
12
+ node.innerText = "";
13
+ } else {
14
+ if (node.getAttributeNames) {
15
+ for (const attr of node.getAttributeNames()) {
16
+ if (!DOMSanitizer.shouldAcceptAttribute(attr)) {
17
+ node.removeAttribute(attr);
18
+ }
19
+ }
20
+ }
21
+ for (let i = 0; i < node.childNodes.length; i++) {
22
+ DOMSanitizer.sanitise(node.childNodes[i] as HTMLElement);
23
+ }
24
+ }
25
+ }
26
+
27
+ static IsASCIIDigit(c: string): boolean {
28
+ return c >= "0" && c <= "9";
29
+ }
30
+
31
+ static IsASCIIAlpha(c: string) {
32
+ return c >= "a" && c <= "z";
33
+ }
34
+
35
+ static IsValidAttributeName(characters: string): boolean {
36
+ const c = characters[0];
37
+ if (!(DOMSanitizer.IsASCIIAlpha(c) || c === ":" || c === "_")) {
38
+ return false;
39
+ }
40
+
41
+ for (let i = 1; i < characters.length; i++) {
42
+ const c = characters[i];
43
+ if (
44
+ !(
45
+ DOMSanitizer.IsASCIIDigit(c) ||
46
+ DOMSanitizer.IsASCIIAlpha(c) ||
47
+ c === ":" ||
48
+ c === "_" ||
49
+ c === "-" ||
50
+ c === "."
51
+ )
52
+ ) {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ return true;
58
+ }
59
+
60
+ static shouldAcceptAttribute(attribute: string) {
61
+ if (!DOMSanitizer.IsValidAttributeName(attribute)) {
62
+ console.warn("Invalid attribute name", attribute);
63
+ return false;
64
+ }
65
+
66
+ // TODO - this might be overly restrictive - apologies to someone that finds this because you have a non-event attribute filtered by this
67
+ return !attribute.startsWith("on");
68
+ }
69
+ }
@@ -0,0 +1,433 @@
1
+ import {
2
+ AttributeChangedDiff,
3
+ ChildrenChangedDiff,
4
+ ClientMessage,
5
+ NodeDescription,
6
+ RemoteEvent,
7
+ ServerMessage,
8
+ TextChangedDiff,
9
+ } from "@mml-io/networked-dom-protocol";
10
+
11
+ import { DOMSanitizer } from "./DOMSanitizer";
12
+
13
+ const websocketProtocol = "networked-dom-v0.1";
14
+
15
+ const startingBackoffTimeMilliseconds = 100;
16
+ const maximumBackoffTimeMilliseconds = 10000;
17
+ const maximumWebsocketConnectionTimeout = 5000;
18
+
19
+ export type NetworkedDOMWebsocketFactory = (url: string) => WebSocket;
20
+
21
+ export enum NetworkedDOMWebsocketStatus {
22
+ Connecting,
23
+ Connected,
24
+ Reconnecting,
25
+ Disconnected,
26
+ }
27
+
28
+ export class NetworkedDOMWebsocket {
29
+ private idToElement = new Map<number, Node>();
30
+ private elementToId = new Map<Node, number>();
31
+ private websocket: WebSocket | null = null;
32
+ private currentRoot: HTMLElement | null = null;
33
+
34
+ private url: string;
35
+ private websocketFactory: NetworkedDOMWebsocketFactory;
36
+ private parentElement: HTMLElement;
37
+ private timeCallback: (time: number) => void;
38
+ private statusUpdateCallback: (status: NetworkedDOMWebsocketStatus) => void;
39
+ private stopped = false;
40
+ private backoffTime = startingBackoffTimeMilliseconds;
41
+ private status: NetworkedDOMWebsocketStatus | null = null;
42
+
43
+ public static createWebSocket(url: string): WebSocket {
44
+ return new WebSocket(url, [websocketProtocol]);
45
+ }
46
+
47
+ constructor(
48
+ url: string,
49
+ websocketFactory: NetworkedDOMWebsocketFactory,
50
+ parentElement: HTMLElement,
51
+ timeCallback?: (time: number) => void,
52
+ statusUpdateCallback?: (status: NetworkedDOMWebsocketStatus) => void,
53
+ ) {
54
+ this.url = url;
55
+ this.websocketFactory = websocketFactory;
56
+ this.parentElement = parentElement;
57
+ this.timeCallback =
58
+ timeCallback ||
59
+ (() => {
60
+ // no-op
61
+ });
62
+ this.statusUpdateCallback =
63
+ statusUpdateCallback ||
64
+ (() => {
65
+ // no-op
66
+ });
67
+ this.setStatus(NetworkedDOMWebsocketStatus.Connecting);
68
+ this.startWebSocketConnectionAttempt();
69
+ }
70
+
71
+ private setStatus(status: NetworkedDOMWebsocketStatus) {
72
+ if (this.status !== status) {
73
+ this.status = status;
74
+ this.statusUpdateCallback(status);
75
+ }
76
+ }
77
+
78
+ private createWebsocketWithTimeout(timeout: number): Promise<WebSocket> {
79
+ return new Promise((resolve, reject) => {
80
+ const timeoutId = setTimeout(() => {
81
+ reject(new Error("websocket connection timed out"));
82
+ }, timeout);
83
+ const websocket = this.websocketFactory(this.url);
84
+ websocket.addEventListener("open", () => {
85
+ clearTimeout(timeoutId);
86
+
87
+ this.websocket = websocket;
88
+
89
+ websocket.addEventListener("message", (event) => {
90
+ if (websocket !== this.websocket) {
91
+ console.log("Ignoring websocket message event because it is no longer current");
92
+ websocket.close();
93
+ return;
94
+ }
95
+ this.handleIncomingWebsocketMessage(event);
96
+ });
97
+
98
+ const onWebsocketClose = async () => {
99
+ const hadContents = this.currentRoot !== null;
100
+ this.clearContents();
101
+ if (this.stopped) {
102
+ // This closing is expected. The client closed the websocket.
103
+ this.setStatus(NetworkedDOMWebsocketStatus.Disconnected);
104
+ return;
105
+ }
106
+ if (!hadContents) {
107
+ // The websocket did not deliver any contents. It may have been successfully opened, but immediately closed. This client should back off to prevent this happening in a rapid loop.
108
+ await this.waitBackoffTime();
109
+ }
110
+ // The websocket closed unexpectedly. Try to reconnect.
111
+ this.setStatus(NetworkedDOMWebsocketStatus.Reconnecting);
112
+ this.startWebSocketConnectionAttempt();
113
+ };
114
+
115
+ websocket.addEventListener("close", (e) => {
116
+ if (websocket !== this.websocket) {
117
+ console.warn("Ignoring websocket close event because it is no longer current");
118
+ return;
119
+ }
120
+ console.log("NetworkedDOMWebsocket close", e);
121
+ onWebsocketClose();
122
+ });
123
+ websocket.addEventListener("error", (e) => {
124
+ if (websocket !== this.websocket) {
125
+ console.log("Ignoring websocket error event because it is no longer current");
126
+ return;
127
+ }
128
+ console.error("NetworkedDOMWebsocket error", e);
129
+ onWebsocketClose();
130
+ });
131
+
132
+ resolve(websocket);
133
+ });
134
+ websocket.addEventListener("error", (e) => {
135
+ clearTimeout(timeoutId);
136
+ reject(e);
137
+ });
138
+ });
139
+ }
140
+
141
+ private async waitBackoffTime(): Promise<void> {
142
+ console.warn(`Websocket connection to '${this.url}' failed: retrying in ${this.backoffTime}ms`);
143
+ await new Promise((resolve) => setTimeout(resolve, this.backoffTime));
144
+ this.backoffTime = Math.min(
145
+ // Introduce a small amount of randomness to prevent clients from retrying in lockstep
146
+ this.backoffTime * (1.5 + Math.random() * 0.5),
147
+ maximumBackoffTimeMilliseconds,
148
+ );
149
+ }
150
+
151
+ private async startWebSocketConnectionAttempt() {
152
+ if (this.stopped) {
153
+ return;
154
+ }
155
+ // eslint-disable-next-line no-constant-condition
156
+ while (true) {
157
+ if (this.stopped) {
158
+ return;
159
+ }
160
+ try {
161
+ await this.createWebsocketWithTimeout(maximumWebsocketConnectionTimeout);
162
+ break;
163
+ } catch (e) {
164
+ // Connection failed, retry with backoff
165
+ this.setStatus(NetworkedDOMWebsocketStatus.Reconnecting);
166
+ await this.waitBackoffTime();
167
+ }
168
+ }
169
+ }
170
+
171
+ private handleIncomingWebsocketMessage(event: MessageEvent) {
172
+ const messages = JSON.parse(event.data) as Array<ServerMessage>;
173
+ for (const message of messages) {
174
+ if (message.type === "error") {
175
+ console.error("Error from server", message);
176
+ } else if (message.type === "warning") {
177
+ console.warn("Warning from server", message);
178
+ } else {
179
+ if (message.documentTime) {
180
+ if (this.timeCallback) {
181
+ this.timeCallback(message.documentTime);
182
+ }
183
+ }
184
+ if (message.type === "snapshot") {
185
+ // This websocket is successfully connected. Reset the backoff time.
186
+ this.backoffTime = startingBackoffTimeMilliseconds;
187
+ this.setStatus(NetworkedDOMWebsocketStatus.Connected);
188
+
189
+ if (this.currentRoot) {
190
+ this.currentRoot.remove();
191
+ this.currentRoot = null;
192
+ this.elementToId.clear();
193
+ this.idToElement.clear();
194
+ }
195
+
196
+ // create a tree of DOM elements
197
+ // NOTE: the MLElement contructors are not executed during this stage
198
+ const element = this.handleNewElement(message.snapshot);
199
+ if (!element) {
200
+ throw new Error("Snapshot element not created");
201
+ }
202
+ if (!(element instanceof HTMLElement)) {
203
+ throw new Error("Snapshot element is not an HTMLElement");
204
+ }
205
+ this.currentRoot = element;
206
+ // appending to the tree causes MElements to be constructed
207
+ this.parentElement.append(element);
208
+ } else if (message.type === "attributeChange") {
209
+ this.handleAttributeChange(message);
210
+ } else if (message.type === "childrenChanged") {
211
+ this.handleChildrenChanged(message);
212
+ } else if (message.type === "textChanged") {
213
+ this.handleTextChanged(message);
214
+ } else if (message.type === "ping") {
215
+ this.send({
216
+ type: "pong",
217
+ pong: message.ping,
218
+ });
219
+ } else {
220
+ console.warn("unknown message type", message);
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ public stop() {
227
+ this.stopped = true;
228
+ if (this.websocket !== null) {
229
+ this.websocket.close();
230
+ this.websocket = null;
231
+ }
232
+ }
233
+
234
+ public handleEvent(element: HTMLElement, event: CustomEvent<{ element: HTMLElement }>) {
235
+ const nodeId = this.elementToId.get(element);
236
+ if (nodeId === undefined || nodeId === null) {
237
+ throw new Error("Element not found");
238
+ }
239
+
240
+ console.log(
241
+ `Sending event to websocket: "${event.type}" on node: ${nodeId} type: ${element.tagName}`,
242
+ );
243
+
244
+ const detailWithoutElement: Partial<typeof event.detail> = {
245
+ ...event.detail,
246
+ };
247
+ delete detailWithoutElement.element;
248
+
249
+ const remoteEvent: RemoteEvent = {
250
+ type: "event",
251
+ nodeId,
252
+ name: event.type,
253
+ bubbles: event.bubbles,
254
+ params: detailWithoutElement,
255
+ };
256
+
257
+ this.send(remoteEvent);
258
+ }
259
+
260
+ private send(fromClientMessage: ClientMessage) {
261
+ if (!this.websocket) {
262
+ throw new Error("No websocket created");
263
+ }
264
+ this.websocket.send(JSON.stringify(fromClientMessage));
265
+ }
266
+
267
+ private handleTextChanged(message: TextChangedDiff) {
268
+ const { nodeId, text } = message;
269
+
270
+ if (nodeId === undefined || nodeId === null) {
271
+ console.warn("No nodeId in textChanged message");
272
+ return;
273
+ }
274
+ const node = this.idToElement.get(nodeId);
275
+ if (!node) {
276
+ throw new Error("No node found for textChanged message");
277
+ }
278
+ if (!(node instanceof Text)) {
279
+ throw new Error("Node for textChanged message is not a Text node");
280
+ }
281
+ node.textContent = text;
282
+ }
283
+
284
+ private handleChildrenChanged(message: ChildrenChangedDiff) {
285
+ const { nodeId, addedNodes, removedNodes, previousNodeId } = message;
286
+ if (nodeId === undefined || nodeId === null) {
287
+ console.warn("No nodeId in childrenChanged message");
288
+ return;
289
+ }
290
+ const parent = this.idToElement.get(nodeId);
291
+ if (!parent) {
292
+ throw new Error("No parent found for childrenChanged message");
293
+ }
294
+ if (!parent.isConnected) {
295
+ console.error("Parent is not connected", parent);
296
+ }
297
+ if (!(parent instanceof HTMLElement)) {
298
+ throw new Error("Parent is not an HTMLElement (that supports children)");
299
+ }
300
+ let previousElement;
301
+ if (previousNodeId) {
302
+ previousElement = this.idToElement.get(previousNodeId);
303
+ if (!previousElement) {
304
+ throw new Error("No previous element found for childrenChanged message");
305
+ }
306
+ }
307
+
308
+ for (const addedNode of addedNodes) {
309
+ const childElement = this.handleNewElement(addedNode);
310
+ if (childElement) {
311
+ if (previousElement) {
312
+ const nextElement = previousElement.nextSibling;
313
+ if (nextElement) {
314
+ parent.insertBefore(childElement, nextElement);
315
+ } else {
316
+ parent.append(childElement);
317
+ }
318
+ } else {
319
+ parent.append(childElement);
320
+ }
321
+ }
322
+ }
323
+ for (const removedNode of removedNodes) {
324
+ const childElement = this.idToElement.get(removedNode);
325
+ if (!childElement) {
326
+ throw new Error(`Child element not found: ${removedNode}`);
327
+ }
328
+ this.elementToId.delete(childElement);
329
+ this.idToElement.delete(removedNode);
330
+ parent.removeChild(childElement);
331
+ if (childElement instanceof HTMLElement) {
332
+ // If child is capable of supporting children then remove any that exist
333
+ this.removeChildElementIds(childElement);
334
+ }
335
+ }
336
+ }
337
+
338
+ private removeChildElementIds(parent: HTMLElement) {
339
+ for (let i = 0; i < parent.children.length; i++) {
340
+ const child = parent.children[i];
341
+ const childId = this.elementToId.get(child as HTMLElement);
342
+ if (!childId) {
343
+ console.error("Inner child of removed element had no id", child);
344
+ } else {
345
+ this.elementToId.delete(child);
346
+ this.idToElement.delete(childId);
347
+ }
348
+ this.removeChildElementIds(child as HTMLElement);
349
+ }
350
+ }
351
+
352
+ private handleAttributeChange(message: AttributeChangedDiff) {
353
+ const { nodeId, attribute, newValue } = message;
354
+ if (nodeId === undefined || nodeId === null) {
355
+ console.warn("No nodeId in attributeChange message");
356
+ return;
357
+ }
358
+ const element = this.idToElement.get(nodeId);
359
+ if (element) {
360
+ if (element instanceof HTMLElement) {
361
+ if (newValue === null) {
362
+ element.removeAttribute(attribute);
363
+ } else {
364
+ if (DOMSanitizer.shouldAcceptAttribute(attribute)) {
365
+ element.setAttribute(attribute, newValue);
366
+ }
367
+ }
368
+ } else {
369
+ console.error("Element is not an HTMLElement and cannot support attributes", element);
370
+ }
371
+ } else {
372
+ console.error("No element found for attributeChange message");
373
+ }
374
+ }
375
+
376
+ private handleNewElement(message: NodeDescription): Node | null {
377
+ if (message.type === "text") {
378
+ const { nodeId, text } = message;
379
+ const textNode = document.createTextNode("");
380
+ textNode.textContent = text;
381
+ this.idToElement.set(nodeId, textNode);
382
+ this.elementToId.set(textNode, nodeId);
383
+ return textNode;
384
+ }
385
+ const { tag, nodeId, attributes, children, text } = message;
386
+ if (nodeId === undefined || nodeId === null) {
387
+ console.warn("No nodeId in handleNewElement message", message);
388
+ return null;
389
+ }
390
+ if (this.idToElement.has(nodeId)) {
391
+ console.error(
392
+ "Received nodeId to add that is already present",
393
+ nodeId,
394
+ this.idToElement.get(nodeId),
395
+ );
396
+ }
397
+ if (tag === "#text") {
398
+ const textNode = document.createTextNode("");
399
+ textNode.textContent = text || null;
400
+ this.idToElement.set(nodeId, textNode);
401
+ this.elementToId.set(textNode, nodeId);
402
+ return textNode;
403
+ }
404
+
405
+ const element = document.createElement(tag);
406
+ this.idToElement.set(nodeId, element);
407
+ this.elementToId.set(element, nodeId);
408
+ for (const key in attributes) {
409
+ if (DOMSanitizer.shouldAcceptAttribute(key)) {
410
+ const value = attributes[key];
411
+ element.setAttribute(key, value);
412
+ }
413
+ }
414
+ if (children) {
415
+ for (const child of children) {
416
+ const childElement = this.handleNewElement(child);
417
+ if (childElement) {
418
+ element.append(childElement);
419
+ }
420
+ }
421
+ }
422
+ return element;
423
+ }
424
+
425
+ private clearContents() {
426
+ this.idToElement.clear();
427
+ this.elementToId.clear();
428
+ if (this.currentRoot) {
429
+ this.currentRoot.remove();
430
+ this.currentRoot = null;
431
+ }
432
+ }
433
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./NetworkedDOMWebsocket";
2
+ export * from "./DOMSanitizer";