lightspeed.js
1 const DEFAULT_PROTOCOL = "lightspeed"; 2 const DEFAULT_VERSION = 1; 3 const PATCH_STREAM_TAG = "ps"; 4 const PATCH_STREAM_VERSION = 1; 5 const HOOK_SELECTOR = "[data-ls-hook]"; 6 const EVENT_SELECTOR = "[data-ls-event]"; 7 8 function encodeField(value) { 9 return String(value).replace(/\\/g, "\\\\").replace(/\|/g, "\\|"); 10 } 11 12 function splitFields(payload) { 13 const fields = []; 14 let current = ""; 15 let escaped = false; 16 17 for (let index = 0; index < payload.length; index += 1) { 18 const char = payload[index]; 19 20 if (escaped) { 21 current += char; 22 escaped = false; 23 continue; 24 } 25 26 if (char === "\\") { 27 escaped = true; 28 continue; 29 } 30 31 if (char === "|") { 32 fields.push(current); 33 current = ""; 34 continue; 35 } 36 37 current += char; 38 } 39 40 if (escaped) { 41 throw new Error("invalid_escape_sequence"); 42 } 43 44 fields.push(current); 45 return fields; 46 } 47 48 function joinFields(fields) { 49 return fields.map(encodeField).join("|"); 50 } 51 52 function encodeFrame(frame) { 53 switch (frame.tag) { 54 case "hello": 55 return joinFields([ 56 "hello", 57 frame.protocol, 58 String(frame.version), 59 ]); 60 case "event": 61 return joinFields([ 62 "event", 63 frame.ref, 64 frame.name, 65 frame.payload, 66 ]); 67 case "diff": { 68 const payload = frame.payload ?? frame.html ?? ""; 69 return joinFields(["diff", frame.ref, payload]); 70 } 71 case "ack": 72 return joinFields(["ack", frame.ref]); 73 case "failure": 74 return joinFields([ 75 "failure", 76 frame.ref, 77 frame.reason, 78 ]); 79 default: 80 throw new Error(`unsupported_frame_tag:${String(frame.tag)}`); 81 } 82 } 83 84 function decodeFrame(payload) { 85 if (payload === "") { 86 throw new Error("empty_frame"); 87 } 88 89 const fields = splitFields(payload); 90 const [tag] = fields; 91 92 switch (tag) { 93 case "hello": { 94 if (fields.length !== 3) { 95 throw new Error(`bad_field_count:hello:3:${fields.length}`); 96 } 97 const [, protocol, versionText] = fields; 98 const version = Number.parseInt(versionText, 10); 99 if (Number.isNaN(version)) { 100 throw new Error(`invalid_version:${versionText}`); 101 } 102 return { tag: "hello", protocol, version }; 103 } 104 105 case "event": { 106 if (fields.length !== 4) { 107 throw new Error(`bad_field_count:event:4:${fields.length}`); 108 } 109 const [, ref, name, payloadValue] = fields; 110 return { tag: "event", ref, name, payload: payloadValue }; 111 } 112 113 case "diff": { 114 if (fields.length !== 3) { 115 throw new Error(`bad_field_count:diff:3:${fields.length}`); 116 } 117 const [, ref, payload] = fields; 118 return { tag: "diff", ref, payload, html: payload }; 119 } 120 121 case "ack": { 122 if (fields.length !== 2) { 123 throw new Error(`bad_field_count:ack:2:${fields.length}`); 124 } 125 const [, ref] = fields; 126 return { tag: "ack", ref }; 127 } 128 129 case "failure": { 130 if (fields.length !== 3) { 131 throw new Error(`bad_field_count:failure:3:${fields.length}`); 132 } 133 const [, ref, reason] = fields; 134 return { tag: "failure", ref, reason }; 135 } 136 137 default: 138 throw new Error(`unknown_frame_tag:${tag}`); 139 } 140 } 141 142 function parseIntStrict(value) { 143 const parsed = Number.parseInt(value, 10); 144 if (Number.isNaN(parsed)) { 145 throw new Error(`invalid_integer:${value}`); 146 } 147 return parsed; 148 } 149 150 function escapeRegExp(value) { 151 return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 152 } 153 154 function escapeHtml(value) { 155 return String(value) 156 .replace(/&/g, "&") 157 .replace(/</g, "<") 158 .replace(/>/g, ">") 159 .replace(/"/g, """); 160 } 161 162 function dynamicSlot(name, value) { 163 return { name, value }; 164 } 165 166 function patchStringValues(patch) { 167 switch (patch.op) { 168 case "replace": 169 case "append": 170 case "prepend": 171 return [patch.target, patch.html]; 172 case "remove": 173 return [patch.target]; 174 case "replace_segments": { 175 const strings = [patch.target, patch.fingerprint, patch.staticHtml]; 176 for (const slot of patch.dynamicSlots) { 177 strings.push(slot.name, slot.value); 178 } 179 return strings; 180 } 181 case "update_segments": { 182 const strings = [patch.target, patch.fingerprint]; 183 for (const slot of patch.dynamicSlots) { 184 strings.push(slot.name, slot.value); 185 } 186 return strings; 187 } 188 case "upsert_keyed": 189 return [patch.target, patch.key, patch.html]; 190 case "remove_keyed": 191 return [patch.target, patch.key]; 192 default: 193 throw new Error(`unsupported_patch_op:${String(patch.op)}`); 194 } 195 } 196 197 function buildDictionary(patches) { 198 const dictionary = []; 199 const seen = new Set(); 200 201 for (const patch of patches) { 202 for (const value of patchStringValues(patch)) { 203 if (seen.has(value)) continue; 204 seen.add(value); 205 dictionary.push(value); 206 } 207 } 208 209 return dictionary; 210 } 211 212 function dictionaryIndex(dictionary, value) { 213 const index = dictionary.indexOf(value); 214 if (index < 0) { 215 throw new Error(`missing_dictionary_entry:${value}`); 216 } 217 return index; 218 } 219 220 function encodeDynamicSlotTokens(dictionary, dynamicSlots) { 221 const tokens = []; 222 223 for (const slot of dynamicSlots) { 224 tokens.push(String(dictionaryIndex(dictionary, slot.name))); 225 tokens.push(String(dictionaryIndex(dictionary, slot.value))); 226 } 227 228 return tokens; 229 } 230 231 function encodePatchOperation(dictionary, patch) { 232 switch (patch.op) { 233 case "replace": 234 return [ 235 "r", 236 String(dictionaryIndex(dictionary, patch.target)), 237 String(dictionaryIndex(dictionary, patch.html)), 238 ].join(","); 239 case "append": 240 return [ 241 "a", 242 String(dictionaryIndex(dictionary, patch.target)), 243 String(dictionaryIndex(dictionary, patch.html)), 244 ].join(","); 245 case "prepend": 246 return [ 247 "p", 248 String(dictionaryIndex(dictionary, patch.target)), 249 String(dictionaryIndex(dictionary, patch.html)), 250 ].join(","); 251 case "remove": 252 return ["x", String(dictionaryIndex(dictionary, patch.target))].join(","); 253 case "replace_segments": 254 return [ 255 "s", 256 String(dictionaryIndex(dictionary, patch.target)), 257 String(dictionaryIndex(dictionary, patch.fingerprint)), 258 String(dictionaryIndex(dictionary, patch.staticHtml)), 259 String(patch.dynamicSlots.length), 260 ...encodeDynamicSlotTokens(dictionary, patch.dynamicSlots), 261 ].join(","); 262 case "update_segments": 263 return [ 264 "u", 265 String(dictionaryIndex(dictionary, patch.target)), 266 String(dictionaryIndex(dictionary, patch.fingerprint)), 267 String(patch.dynamicSlots.length), 268 ...encodeDynamicSlotTokens(dictionary, patch.dynamicSlots), 269 ].join(","); 270 case "upsert_keyed": 271 return [ 272 "k", 273 String(dictionaryIndex(dictionary, patch.target)), 274 String(dictionaryIndex(dictionary, patch.key)), 275 String(dictionaryIndex(dictionary, patch.html)), 276 ].join(","); 277 case "remove_keyed": 278 return [ 279 "q", 280 String(dictionaryIndex(dictionary, patch.target)), 281 String(dictionaryIndex(dictionary, patch.key)), 282 ].join(","); 283 default: 284 throw new Error(`unsupported_patch_op:${String(patch.op)}`); 285 } 286 } 287 288 function encodePatchStream(patches, version = PATCH_STREAM_VERSION) { 289 const dictionary = buildDictionary(patches); 290 const operationFields = patches.map((patch) => encodePatchOperation(dictionary, patch)); 291 292 return joinFields([ 293 PATCH_STREAM_TAG, 294 String(version), 295 String(dictionary.length), 296 ...dictionary, 297 String(operationFields.length), 298 ...operationFields, 299 ]); 300 } 301 302 function dictionaryValue(dictionary, index) { 303 if (index < 0 || index >= dictionary.length) { 304 throw new Error(`missing_dictionary_entry:${String(index)}`); 305 } 306 return dictionary[index]; 307 } 308 309 function decodeDynamicSlots(tokens, dictionary, slotCount) { 310 if (tokens.length !== slotCount * 2) { 311 throw new Error(`bad_field_count:dynamic_slots:${slotCount * 2}:${tokens.length}`); 312 } 313 314 const slots = []; 315 for (let index = 0; index < tokens.length; index += 2) { 316 const nameIndex = parseIntStrict(tokens[index]); 317 const valueIndex = parseIntStrict(tokens[index + 1]); 318 slots.push(dynamicSlot( 319 dictionaryValue(dictionary, nameIndex), 320 dictionaryValue(dictionary, valueIndex), 321 )); 322 } 323 return slots; 324 } 325 326 function decodePatchOperation(operationField, dictionary) { 327 const tokens = operationField.split(","); 328 const [tag, ...rest] = tokens; 329 330 switch (tag) { 331 case "r": { 332 if (rest.length !== 2) { 333 throw new Error(`malformed_operation:${operationField}`); 334 } 335 const target = dictionaryValue(dictionary, parseIntStrict(rest[0])); 336 const html = dictionaryValue(dictionary, parseIntStrict(rest[1])); 337 return { op: "replace", target, html }; 338 } 339 340 case "a": { 341 if (rest.length !== 2) { 342 throw new Error(`malformed_operation:${operationField}`); 343 } 344 const target = dictionaryValue(dictionary, parseIntStrict(rest[0])); 345 const html = dictionaryValue(dictionary, parseIntStrict(rest[1])); 346 return { op: "append", target, html }; 347 } 348 349 case "p": { 350 if (rest.length !== 2) { 351 throw new Error(`malformed_operation:${operationField}`); 352 } 353 const target = dictionaryValue(dictionary, parseIntStrict(rest[0])); 354 const html = dictionaryValue(dictionary, parseIntStrict(rest[1])); 355 return { op: "prepend", target, html }; 356 } 357 358 case "x": { 359 if (rest.length !== 1) { 360 throw new Error(`malformed_operation:${operationField}`); 361 } 362 const target = dictionaryValue(dictionary, parseIntStrict(rest[0])); 363 return { op: "remove", target }; 364 } 365 366 case "s": { 367 if (rest.length < 4) { 368 throw new Error(`malformed_operation:${operationField}`); 369 } 370 371 const target = dictionaryValue(dictionary, parseIntStrict(rest[0])); 372 const fingerprint = dictionaryValue(dictionary, parseIntStrict(rest[1])); 373 const staticHtml = dictionaryValue(dictionary, parseIntStrict(rest[2])); 374 const slotCount = parseIntStrict(rest[3]); 375 const dynamicSlots = decodeDynamicSlots(rest.slice(4), dictionary, slotCount); 376 377 return { 378 op: "replace_segments", 379 target, 380 fingerprint, 381 staticHtml, 382 dynamicSlots, 383 }; 384 } 385 386 case "u": { 387 if (rest.length < 3) { 388 throw new Error(`malformed_operation:${operationField}`); 389 } 390 391 const target = dictionaryValue(dictionary, parseIntStrict(rest[0])); 392 const fingerprint = dictionaryValue(dictionary, parseIntStrict(rest[1])); 393 const slotCount = parseIntStrict(rest[2]); 394 const dynamicSlots = decodeDynamicSlots(rest.slice(3), dictionary, slotCount); 395 396 return { 397 op: "update_segments", 398 target, 399 fingerprint, 400 dynamicSlots, 401 }; 402 } 403 404 case "k": { 405 if (rest.length !== 3) { 406 throw new Error(`malformed_operation:${operationField}`); 407 } 408 409 const target = dictionaryValue(dictionary, parseIntStrict(rest[0])); 410 const key = dictionaryValue(dictionary, parseIntStrict(rest[1])); 411 const html = dictionaryValue(dictionary, parseIntStrict(rest[2])); 412 413 return { op: "upsert_keyed", target, key, html }; 414 } 415 416 case "q": { 417 if (rest.length !== 2) { 418 throw new Error(`malformed_operation:${operationField}`); 419 } 420 421 const target = dictionaryValue(dictionary, parseIntStrict(rest[0])); 422 const key = dictionaryValue(dictionary, parseIntStrict(rest[1])); 423 424 return { op: "remove_keyed", target, key }; 425 } 426 427 default: 428 throw new Error(`malformed_operation:${operationField}`); 429 } 430 } 431 432 function decodePatchStream(payload) { 433 const fields = splitFields(payload); 434 if (fields.length < 5) { 435 throw new Error(`bad_field_count:patch_stream:5:${fields.length}`); 436 } 437 438 const [tag, versionText, dictionaryCountText, ...rest] = fields; 439 if (tag !== PATCH_STREAM_TAG) { 440 throw new Error(`unknown_payload_tag:${tag}`); 441 } 442 443 const version = parseIntStrict(versionText); 444 if (version !== PATCH_STREAM_VERSION) { 445 throw new Error(`unsupported_version:${String(version)}`); 446 } 447 448 const dictionaryCount = parseIntStrict(dictionaryCountText); 449 if (dictionaryCount < 0 || rest.length < dictionaryCount + 1) { 450 throw new Error("malformed_operation:insufficient_fields"); 451 } 452 453 const dictionary = rest.slice(0, dictionaryCount); 454 const operationCount = parseIntStrict(rest[dictionaryCount]); 455 const operationFields = rest.slice(dictionaryCount + 1); 456 457 if (operationFields.length !== operationCount) { 458 throw new Error( 459 `bad_field_count:operation_fields:${operationCount}:${operationFields.length}`, 460 ); 461 } 462 463 return { 464 version, 465 patches: operationFields.map((field) => decodePatchOperation(field, dictionary)), 466 }; 467 } 468 469 function defaultSocketFactory(url) { 470 return new WebSocket(url); 471 } 472 473 function defaultTimer() { 474 return { 475 setTimeout: (fn, ms) => setTimeout(fn, ms), 476 clearTimeout: (handle) => clearTimeout(handle), 477 }; 478 } 479 480 function getDefaultLocation() { 481 if (typeof window !== "undefined" && window.location) { 482 return window.location; 483 } 484 485 if (typeof location !== "undefined") { 486 return location; 487 } 488 489 return null; 490 } 491 492 function resolveRoot(documentRef, explicitRoot) { 493 if (explicitRoot) return explicitRoot; 494 if (!documentRef || typeof documentRef.querySelector !== "function") return null; 495 return documentRef.querySelector("[data-ls-root]") ?? documentRef.querySelector("#app"); 496 } 497 498 function resolveUrl(explicitUrl, root, locationRef) { 499 if (explicitUrl) return explicitUrl; 500 if (!root || !root.dataset) return null; 501 502 const wsValue = root.dataset.lsWs; 503 if (!wsValue) return null; 504 505 if (wsValue.startsWith("ws://") || wsValue.startsWith("wss://")) { 506 return wsValue; 507 } 508 509 if (!locationRef) return wsValue; 510 511 const protocol = locationRef.protocol === "https:" ? "wss:" : "ws:"; 512 return `${protocol}//${locationRef.host}${wsValue}`; 513 } 514 515 function dispatchCustomEvent(root, type, detail) { 516 if (!root || typeof root.dispatchEvent !== "function") return; 517 518 if (typeof CustomEvent === "function") { 519 root.dispatchEvent(new CustomEvent(type, { detail })); 520 return; 521 } 522 523 root.dispatchEvent({ type, detail }); 524 } 525 526 class LightspeedClient { 527 constructor(options = {}) { 528 this.document = options.document ?? (typeof document !== "undefined" ? document : null); 529 this.root = resolveRoot(this.document, options.root ?? null); 530 this.location = options.location ?? getDefaultLocation(); 531 this.url = resolveUrl(options.url ?? null, this.root, this.location); 532 533 this.protocol = options.protocol ?? DEFAULT_PROTOCOL; 534 this.version = options.version ?? DEFAULT_VERSION; 535 536 this.socketFactory = options.socketFactory ?? defaultSocketFactory; 537 this.timer = options.timer ?? defaultTimer(); 538 this.hooks = options.hooks ?? {}; 539 540 this.reconnect = { 541 enabled: options.reconnect?.enabled ?? true, 542 baseDelayMs: options.reconnect?.baseDelayMs ?? 200, 543 maxDelayMs: options.reconnect?.maxDelayMs ?? 5000, 544 }; 545 546 this.state = "idle"; 547 this.socket = null; 548 this.closedManually = false; 549 this.reconnectAttempts = 0; 550 this.reconnectHandle = null; 551 this.nextEventRef = 1; 552 this.lastAppliedPatchRef = null; 553 this.hookInstances = new Map(); 554 this.segmentState = new Map(); 555 this.keyedState = new Map(); 556 557 this.boundHandleClick = (event) => this.handleClick(event); 558 this.boundHandleSubmit = (event) => this.handleSubmit(event); 559 this.delegationAttached = false; 560 } 561 562 connect() { 563 if (!this.url) { 564 this.setError("missing_url"); 565 return; 566 } 567 568 this.closedManually = false; 569 this.clearReconnectTimer(); 570 this.setState(this.socket ? "reconnecting" : "connecting"); 571 this.attachEventDelegation(); 572 this.openSocket(); 573 } 574 575 disconnect(reason = "client_disconnect") { 576 this.closedManually = true; 577 this.clearReconnectTimer(); 578 579 if (this.socket && this.socket.readyState === 1) { 580 this.sendFrame({ tag: "failure", ref: "", reason }); 581 } 582 583 if (this.socket && typeof this.socket.close === "function") { 584 this.socket.close(); 585 } 586 587 this.socket = null; 588 this.setState("closed"); 589 this.notifyHooks("disconnected"); 590 } 591 592 pushEvent(name, payload = "{}") { 593 const ref = String(this.nextEventRef); 594 this.nextEventRef += 1; 595 this.sendFrame({ tag: "event", ref, name, payload }); 596 return ref; 597 } 598 599 openSocket() { 600 const socket = this.socketFactory(this.url); 601 this.socket = socket; 602 603 socket.addEventListener("open", () => { 604 if (this.socket !== socket) return; 605 const wasReconnecting = this.state === "reconnecting"; 606 this.reconnectAttempts = 0; 607 this.setState("live"); 608 this.sendFrame({ 609 tag: "hello", 610 protocol: this.protocol, 611 version: this.version, 612 }); 613 if (wasReconnecting) { 614 this.notifyHooks("reconnected"); 615 } else { 616 this.syncHooks(); 617 } 618 }); 619 620 socket.addEventListener("message", (event) => { 621 if (this.socket !== socket) return; 622 this.receiveFrame(event.data); 623 }); 624 625 socket.addEventListener("error", () => { 626 if (this.socket !== socket) return; 627 this.setError("socket_error"); 628 }); 629 630 socket.addEventListener("close", () => { 631 if (this.socket !== socket) return; 632 this.socket = null; 633 if (this.closedManually) { 634 this.setState("closed"); 635 return; 636 } 637 this.notifyHooks("disconnected"); 638 this.scheduleReconnect(); 639 }); 640 } 641 642 receiveFrame(payload) { 643 let frame; 644 645 try { 646 frame = decodeFrame(payload); 647 } catch (error) { 648 const reason = `decode_failed:${error.message}`; 649 this.setError(reason); 650 this.sendFrame({ tag: "failure", ref: "", reason }); 651 return; 652 } 653 654 switch (frame.tag) { 655 case "hello": 656 this.handleHello(frame); 657 break; 658 case "diff": 659 this.handleDiff(frame); 660 break; 661 case "failure": 662 this.setError(`server_failure:${frame.reason}`); 663 break; 664 case "ack": 665 break; 666 case "event": 667 this.setError("unexpected_event_frame"); 668 break; 669 default: 670 this.setError(`unsupported_frame:${frame.tag}`); 671 } 672 } 673 674 handleHello(frame) { 675 if (frame.protocol !== this.protocol) { 676 this.setError(`unsupported_protocol:${frame.protocol}`); 677 return; 678 } 679 680 if (frame.version !== this.version) { 681 this.setError(`unsupported_version:${String(frame.version)}`); 682 } 683 } 684 685 handleDiff(frame) { 686 if (frame.ref === this.lastAppliedPatchRef) { 687 this.sendFrame({ tag: "ack", ref: frame.ref }); 688 return; 689 } 690 691 try { 692 this.applyPatchPayload(frame.payload ?? frame.html ?? ""); 693 this.lastAppliedPatchRef = frame.ref; 694 this.sendFrame({ tag: "ack", ref: frame.ref }); 695 this.setState("live"); 696 } catch (error) { 697 const reason = `patch_failed:${error.message}`; 698 this.setError(reason); 699 this.sendFrame({ tag: "failure", ref: frame.ref, reason }); 700 } 701 } 702 703 applyPatchPayload(payload) { 704 if (payload.startsWith(`${PATCH_STREAM_TAG}|`)) { 705 const stream = decodePatchStream(payload); 706 this.applyPatchOperations(stream.patches); 707 return; 708 } 709 710 this.applyPatch(payload); 711 } 712 713 applyPatch(html) { 714 if (!this.root) { 715 throw new Error("missing_root"); 716 } 717 718 this.segmentState.set("#app", null); 719 this.keyedState.set("#app", []); 720 this.teardownHooks(); 721 this.root.innerHTML = html; 722 this.syncHooks(); 723 } 724 725 applyPatchOperations(operations) { 726 for (const operation of operations) { 727 this.applyPatchOperation(operation); 728 } 729 } 730 731 applyPatchOperation(operation) { 732 switch (operation.op) { 733 case "replace": 734 this.segmentState.delete(operation.target); 735 this.keyedState.delete(operation.target); 736 this.setTargetHtml(operation.target, operation.html); 737 break; 738 739 case "append": { 740 this.segmentState.delete(operation.target); 741 this.keyedState.delete(operation.target); 742 const target = this.resolveTarget(operation.target); 743 if (!target) throw new Error(`missing_target:${operation.target}`); 744 this.setTargetHtml(operation.target, (target.innerHTML ?? "") + operation.html); 745 break; 746 } 747 748 case "prepend": { 749 this.segmentState.delete(operation.target); 750 this.keyedState.delete(operation.target); 751 const target = this.resolveTarget(operation.target); 752 if (!target) throw new Error(`missing_target:${operation.target}`); 753 this.setTargetHtml(operation.target, operation.html + (target.innerHTML ?? "")); 754 break; 755 } 756 757 case "remove": 758 this.segmentState.delete(operation.target); 759 this.keyedState.delete(operation.target); 760 this.setTargetHtml(operation.target, ""); 761 break; 762 763 case "replace_segments": { 764 const slots = new Map( 765 operation.dynamicSlots.map((slot) => [slot.name, slot.value]), 766 ); 767 this.segmentState.set(operation.target, { 768 fingerprint: operation.fingerprint, 769 staticHtml: operation.staticHtml, 770 slots, 771 }); 772 this.keyedState.delete(operation.target); 773 this.setTargetHtml( 774 operation.target, 775 this.renderSegmentHtml(operation.staticHtml, slots), 776 ); 777 break; 778 } 779 780 case "update_segments": { 781 const state = this.segmentState.get(operation.target); 782 if (!state) { 783 throw new Error(`fingerprint_missing:${operation.target}`); 784 } 785 if (state.fingerprint !== operation.fingerprint) { 786 throw new Error( 787 `fingerprint_mismatch:${operation.target}:${state.fingerprint}:${operation.fingerprint}`, 788 ); 789 } 790 791 for (const slot of operation.dynamicSlots) { 792 state.slots.set(slot.name, slot.value); 793 } 794 795 this.segmentState.set(operation.target, state); 796 this.setTargetHtml( 797 operation.target, 798 this.renderSegmentHtml(state.staticHtml, state.slots), 799 ); 800 break; 801 } 802 803 case "upsert_keyed": { 804 this.segmentState.delete(operation.target); 805 const current = this.keyedState.get(operation.target) ?? []; 806 const next = [...current]; 807 const index = next.findIndex((entry) => entry.key === operation.key); 808 const entry = { key: operation.key, html: operation.html }; 809 if (index >= 0) { 810 next[index] = entry; 811 } else { 812 next.push(entry); 813 } 814 this.keyedState.set(operation.target, next); 815 this.setTargetHtml( 816 operation.target, 817 next.map((node) => node.html).join(""), 818 ); 819 break; 820 } 821 822 case "remove_keyed": { 823 this.segmentState.delete(operation.target); 824 const current = this.keyedState.get(operation.target) ?? []; 825 const next = current.filter((entry) => entry.key !== operation.key); 826 this.keyedState.set(operation.target, next); 827 this.setTargetHtml( 828 operation.target, 829 next.map((node) => node.html).join(""), 830 ); 831 break; 832 } 833 834 default: 835 throw new Error(`unsupported_patch_op:${String(operation.op)}`); 836 } 837 } 838 839 resolveTarget(target) { 840 if (!this.root) return null; 841 if (target === "#app") return this.root; 842 if (target === "[data-ls-root]") return this.root; 843 844 if (typeof this.root.matches === "function" && this.root.matches(target)) { 845 return this.root; 846 } 847 848 if (typeof this.root.querySelector === "function") { 849 const nested = this.root.querySelector(target); 850 if (nested) return nested; 851 } 852 853 if (this.document && typeof this.document.querySelector === "function") { 854 return this.document.querySelector(target); 855 } 856 857 return null; 858 } 859 860 setTargetHtml(targetSelector, html) { 861 const target = this.resolveTarget(targetSelector); 862 if (!target) { 863 throw new Error(`missing_target:${targetSelector}`); 864 } 865 866 if (target === this.root) { 867 this.teardownHooks(); 868 target.innerHTML = html; 869 this.syncHooks(); 870 return; 871 } 872 873 target.innerHTML = html; 874 } 875 876 renderSegmentHtml(staticHtml, slots) { 877 let rendered = staticHtml; 878 879 for (const [name, value] of slots.entries()) { 880 const slotPattern = new RegExp( 881 `(<[^>]*data-ls-slot="${escapeRegExp(name)}"[^>]*>)([\\s\\S]*?)(<\\/[^>]+>)`, 882 "g", 883 ); 884 885 rendered = rendered.replace(slotPattern, `$1${escapeHtml(value)}$3`); 886 } 887 888 return rendered; 889 } 890 891 attachEventDelegation() { 892 if (!this.root || this.delegationAttached) return; 893 this.root.addEventListener("click", this.boundHandleClick); 894 this.root.addEventListener("submit", this.boundHandleSubmit); 895 this.delegationAttached = true; 896 } 897 898 handleClick(event) { 899 const element = event?.target?.closest?.(EVENT_SELECTOR); 900 if (!element || !element.dataset?.lsEvent) return; 901 if (typeof event.preventDefault === "function") { 902 event.preventDefault(); 903 } 904 const payload = element.dataset.lsPayload ?? "{}"; 905 this.pushEvent(element.dataset.lsEvent, payload); 906 } 907 908 handleSubmit(event) { 909 const element = event?.target?.closest?.(EVENT_SELECTOR); 910 if (!element || !element.dataset?.lsEvent) return; 911 if (typeof event.preventDefault === "function") { 912 event.preventDefault(); 913 } 914 const payload = this.serializeFormPayload(element); 915 this.pushEvent(element.dataset.lsEvent, payload); 916 } 917 918 serializeFormPayload(form) { 919 if (typeof form.dataset?.lsPayload === "string") { 920 return form.dataset.lsPayload; 921 } 922 923 if (Array.isArray(form.__lsFormEntries)) { 924 const object = {}; 925 for (const [key, value] of form.__lsFormEntries) { 926 object[key] = value; 927 } 928 return JSON.stringify(object); 929 } 930 931 if (typeof FormData === "function") { 932 try { 933 const object = {}; 934 const data = new FormData(form); 935 for (const [key, value] of data.entries()) { 936 object[key] = value; 937 } 938 return JSON.stringify(object); 939 } catch (_error) { 940 return "{}"; 941 } 942 } 943 944 return "{}"; 945 } 946 947 sendFrame(frame) { 948 if (!this.socket || this.socket.readyState !== 1) return; 949 this.socket.send(encodeFrame(frame)); 950 } 951 952 scheduleReconnect() { 953 if (!this.reconnect.enabled) { 954 this.setState("closed"); 955 return; 956 } 957 958 this.setState("reconnecting"); 959 960 const exponent = Math.max(this.reconnectAttempts, 0); 961 const delay = Math.min( 962 this.reconnect.maxDelayMs, 963 this.reconnect.baseDelayMs * 2 ** exponent, 964 ); 965 966 this.reconnectAttempts += 1; 967 this.reconnectHandle = this.timer.setTimeout(() => { 968 this.reconnectHandle = null; 969 this.openSocket(); 970 }, delay); 971 } 972 973 clearReconnectTimer() { 974 if (!this.reconnectHandle) return; 975 this.timer.clearTimeout(this.reconnectHandle); 976 this.reconnectHandle = null; 977 } 978 979 setState(state) { 980 this.state = state; 981 if (this.root?.dataset) { 982 this.root.dataset.lsClientState = state; 983 this.root.dataset.lsLoading = state === "connecting" || state === "reconnecting" 984 ? "true" 985 : "false"; 986 if (state !== "error") { 987 delete this.root.dataset.lsError; 988 } 989 } 990 dispatchCustomEvent(this.root, "lightspeed:state", { state }); 991 } 992 993 setError(reason) { 994 if (this.root?.dataset) { 995 this.root.dataset.lsError = reason; 996 } 997 this.setState("error"); 998 dispatchCustomEvent(this.root, "lightspeed:error", { reason }); 999 } 1000 1001 syncHooks() { 1002 if (!this.root) return; 1003 1004 const next = new Map(); 1005 const elements = this.collectHookElements(); 1006 1007 for (const element of elements) { 1008 const hookName = element?.dataset?.lsHook; 1009 if (!hookName) continue; 1010 const hook = this.hooks[hookName]; 1011 if (!hook) continue; 1012 1013 const existing = this.hookInstances.get(element); 1014 const context = this.buildHookContext(element, hookName); 1015 1016 if (existing && typeof hook.updated === "function") { 1017 hook.updated(context); 1018 } else if (typeof hook.mounted === "function") { 1019 hook.mounted(context); 1020 } 1021 1022 next.set(element, { hookName, hook }); 1023 } 1024 1025 for (const [element, entry] of this.hookInstances.entries()) { 1026 if (next.has(element)) continue; 1027 if (typeof entry.hook.destroyed === "function") { 1028 entry.hook.destroyed(this.buildHookContext(element, entry.hookName)); 1029 } 1030 } 1031 1032 this.hookInstances = next; 1033 } 1034 1035 teardownHooks() { 1036 for (const [element, entry] of this.hookInstances.entries()) { 1037 if (typeof entry.hook.destroyed === "function") { 1038 entry.hook.destroyed(this.buildHookContext(element, entry.hookName)); 1039 } 1040 } 1041 this.hookInstances = new Map(); 1042 } 1043 1044 notifyHooks(kind) { 1045 for (const [element, entry] of this.hookInstances.entries()) { 1046 const callback = entry.hook[kind]; 1047 if (typeof callback !== "function") continue; 1048 callback(this.buildHookContext(element, entry.hookName)); 1049 } 1050 } 1051 1052 collectHookElements() { 1053 const elements = []; 1054 if (this.root?.dataset?.lsHook) { 1055 elements.push(this.root); 1056 } 1057 1058 if (typeof this.root?.querySelectorAll === "function") { 1059 const list = this.root.querySelectorAll(HOOK_SELECTOR); 1060 for (const element of list) { 1061 elements.push(element); 1062 } 1063 } 1064 1065 return elements; 1066 } 1067 1068 buildHookContext(element, hookName) { 1069 return { 1070 client: this, 1071 element, 1072 hookName, 1073 pushEvent: (name, payload) => this.pushEvent(name, payload), 1074 state: this.state, 1075 }; 1076 } 1077 } 1078 1079 function mountLightspeed(options = {}) { 1080 const client = new LightspeedClient(options); 1081 client.connect(); 1082 return client; 1083 } 1084 1085 const LightspeedRuntime = { 1086 encodeFrame, 1087 decodeFrame, 1088 encodePatchStream, 1089 decodePatchStream, 1090 LightspeedClient, 1091 mountLightspeed, 1092 }; 1093 1094 if (typeof module !== "undefined" && module.exports) { 1095 module.exports = LightspeedRuntime; 1096 } 1097 1098 if (typeof globalThis !== "undefined") { 1099 globalThis.LightspeedRuntime = LightspeedRuntime; 1100 }