/ client / lightspeed.js
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, "&amp;")
 157      .replace(/</g, "&lt;")
 158      .replace(/>/g, "&gt;")
 159      .replace(/"/g, "&quot;");
 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  }