@unhingged/vizu-core 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -770,6 +770,11 @@ __export(live_collab_exports, {
770
770
  LiveCollab: () => LiveCollab,
771
771
  roomIdFor: () => roomIdFor
772
772
  });
773
+ function generateConnId() {
774
+ const a = Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
775
+ const b = Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
776
+ return `${a}${b}`;
777
+ }
773
778
  function fnv1a32Hex(input) {
774
779
  let h = 2166136261;
775
780
  for (let i = 0; i < input.length; i++) {
@@ -839,29 +844,27 @@ function labelForStatus(status) {
839
844
  return "";
840
845
  }
841
846
  }
842
- var import_client, MOUSEMOVE_THROTTLE_MS, SCROLL_THROTTLE_MS, INACTIVITY_FADE_MS, CURSOR_TRANSITION_MS, Z_INDEX3, FOLLOW_SCROLL_THRESHOLD_PX, LiveCollab, ConnectionIndicator, FollowPill;
847
+ var PUSH_INTERVAL_MS, PULL_INTERVAL_MS, INACTIVITY_FADE_MS, CURSOR_TRANSITION_MS, Z_INDEX3, FOLLOW_SCROLL_THRESHOLD_PX, RECONNECT_AFTER_ERRORS, DISCONNECT_AFTER_ERRORS, LiveCollab, ConnectionIndicator, FollowPill;
843
848
  var init_live_collab = __esm({
844
849
  "src/live-collab.ts"() {
845
850
  "use strict";
846
- import_client = require("@liveblocks/client");
847
851
  init_presence_stack();
848
852
  init_selection_overlay();
849
853
  init_cursor_colors();
850
- MOUSEMOVE_THROTTLE_MS = 50;
851
- SCROLL_THROTTLE_MS = 100;
854
+ PUSH_INTERVAL_MS = 100;
855
+ PULL_INTERVAL_MS = 200;
852
856
  INACTIVITY_FADE_MS = 5e3;
853
- CURSOR_TRANSITION_MS = 110;
857
+ CURSOR_TRANSITION_MS = 220;
854
858
  Z_INDEX3 = 2147483641;
855
859
  FOLLOW_SCROLL_THRESHOLD_PX = 8;
860
+ RECONNECT_AFTER_ERRORS = 2;
861
+ DISCONNECT_AFTER_ERRORS = 5;
856
862
  LiveCollab = class {
857
863
  constructor(opts) {
858
864
  this.opts = opts;
859
- this.client = null;
860
- this.room = null;
861
- this.leave = null;
862
865
  this.container = null;
863
866
  this.cursors = /* @__PURE__ */ new Map();
864
- // keyed by connectionId
867
+ // keyed by connId now (was numeric connectionId)
865
868
  this.presenceStack = null;
866
869
  this.followPill = null;
867
870
  this.selectionOverlay = null;
@@ -870,50 +873,36 @@ var init_live_collab = __esm({
870
873
  this.visibilityListener = null;
871
874
  this.keydownListener = null;
872
875
  this.statusIndicator = null;
873
- this.throttleTimer = null;
874
- this.scrollThrottleTimer = null;
875
- this.pendingCursor = null;
876
- this.lastBroadcast = 0;
877
- this.lastScrollBroadcast = 0;
878
- this.unsubscribers = [];
876
+ this.pushTimer = null;
877
+ this.pullTimer = null;
879
878
  this.destroyed = false;
879
+ /** Full room id, built once in start() from workspace + pageUrl. */
880
+ this.roomId = null;
881
+ /** Latest snapshot of other connections from /api/live/[room]/others. */
882
+ this.latestOthers = [];
883
+ /** Local presence — mouse + scroll + selection update this in place; pushTimer ships it. */
884
+ this.currentPresence = { cursor: null, scrollY: 0, selecting: null };
885
+ /** True when something changed since the last push — skip the POST otherwise. */
886
+ this.presenceDirty = false;
887
+ /** Connection-health tracker for the indicator. */
888
+ this.status = "initial";
889
+ this.consecutiveErrors = 0;
880
890
  // Follow mode state
881
891
  /** Clerk user id we're currently following, or null. */
882
892
  this.followingUserId = null;
883
893
  /** Last scrollY we received from the followee — for delta gating. */
884
894
  this.lastFolloweeScrollY = null;
895
+ this.connId = generateConnId();
896
+ if (typeof window !== "undefined") {
897
+ this.currentPresence.scrollY = window.scrollY;
898
+ }
885
899
  }
886
900
  async start() {
887
901
  if (typeof window === "undefined" || typeof document === "undefined") return;
888
902
  if (this.destroyed) return;
889
903
  try {
890
- void this.opts.publicKey;
891
- this.client = (0, import_client.createClient)({
892
- authEndpoint: async (room2) => {
893
- const res = await fetch(this.opts.authEndpoint, {
894
- method: "POST",
895
- headers: { "content-type": "application/json" },
896
- credentials: "include",
897
- body: JSON.stringify({ room: room2 })
898
- });
899
- if (!res.ok) {
900
- throw new Error(`vizu-liveblocks-auth: HTTP ${res.status}`);
901
- }
902
- return res.json();
903
- }
904
- });
905
904
  const pageUrl = this.opts.pageUrl ?? window.location.origin + window.location.pathname;
906
- const room = this.opts.workspaceSlug ? roomIdFor(this.opts.workspaceSlug, pageUrl) : null;
907
- if (!room) return;
908
- const result = this.client.enterRoom(room, {
909
- initialPresence: {
910
- cursor: null,
911
- scrollY: typeof window !== "undefined" ? window.scrollY : 0,
912
- selecting: null
913
- }
914
- });
915
- this.room = result.room;
916
- this.leave = result.leave;
905
+ this.roomId = roomIdFor(this.opts.workspaceSlug, pageUrl);
917
906
  this.mountContainer();
918
907
  this.presenceStack = new PresenceStack({
919
908
  onAvatarClick: (userId) => this.setFollowing(userId)
@@ -926,12 +915,16 @@ var init_live_collab = __esm({
926
915
  this.selectionOverlay.mount();
927
916
  this.statusIndicator = new ConnectionIndicator();
928
917
  this.statusIndicator.mount();
929
- this.subscribeOthers();
930
- this.subscribeStatus();
918
+ this.setStatus("connecting");
931
919
  this.attachMouseListener();
932
920
  this.attachScrollListener();
933
921
  this.attachVisibilityListener();
934
922
  this.attachKeyboardListener();
923
+ this.pushTimer = window.setInterval(() => void this.push(), PUSH_INTERVAL_MS);
924
+ this.pullTimer = window.setInterval(() => void this.pull(), PULL_INTERVAL_MS);
925
+ this.presenceDirty = true;
926
+ void this.push();
927
+ void this.pull();
935
928
  } catch (err) {
936
929
  if (typeof console !== "undefined") {
937
930
  console.warn("[vizu/live-collab] start failed; live cursors disabled:", err);
@@ -945,6 +938,7 @@ var init_live_collab = __esm({
945
938
  }
946
939
  /* ────────────────── private ────────────────── */
947
940
  cleanup() {
941
+ void this.clearRemotePresence();
948
942
  if (this.mouseListener) {
949
943
  window.removeEventListener("mousemove", this.mouseListener);
950
944
  this.mouseListener = null;
@@ -961,16 +955,14 @@ var init_live_collab = __esm({
961
955
  document.removeEventListener("keydown", this.keydownListener);
962
956
  this.keydownListener = null;
963
957
  }
964
- if (this.throttleTimer != null) {
965
- window.clearTimeout(this.throttleTimer);
966
- this.throttleTimer = null;
958
+ if (this.pushTimer != null) {
959
+ window.clearInterval(this.pushTimer);
960
+ this.pushTimer = null;
967
961
  }
968
- if (this.scrollThrottleTimer != null) {
969
- window.clearTimeout(this.scrollThrottleTimer);
970
- this.scrollThrottleTimer = null;
962
+ if (this.pullTimer != null) {
963
+ window.clearInterval(this.pullTimer);
964
+ this.pullTimer = null;
971
965
  }
972
- for (const unsub of this.unsubscribers) unsub();
973
- this.unsubscribers = [];
974
966
  for (const c of this.cursors.values()) {
975
967
  if (c.fadeTimer != null) window.clearTimeout(c.fadeTimer);
976
968
  c.el.remove();
@@ -988,10 +980,8 @@ var init_live_collab = __esm({
988
980
  this.lastFolloweeScrollY = null;
989
981
  this.container?.remove();
990
982
  this.container = null;
991
- this.leave?.();
992
- this.leave = null;
993
- this.room = null;
994
- this.client = null;
983
+ this.roomId = null;
984
+ this.latestOthers = [];
995
985
  }
996
986
  /**
997
987
  * Public API: broadcast the local user's currently-hovered element
@@ -1000,10 +990,8 @@ var init_live_collab = __esm({
1000
990
  * Idempotent on no-op transitions. Slice 5 of live-collab.md.
1001
991
  */
1002
992
  setLocalSelection(fingerprint2) {
1003
- if (!this.room) return;
1004
- this.room.updatePresence({
1005
- selecting: fingerprint2 ? { fingerprintJson: JSON.stringify(fingerprint2) } : null
1006
- });
993
+ this.currentPresence.selecting = fingerprint2 ? { fingerprintJson: JSON.stringify(fingerprint2) } : null;
994
+ this.presenceDirty = true;
1007
995
  }
1008
996
  /**
1009
997
  * Public API: start/stop following a user by Clerk id. Null clears.
@@ -1019,30 +1007,24 @@ var init_live_collab = __esm({
1019
1007
  if (userId) this.scrollToFolloweeIfKnown();
1020
1008
  }
1021
1009
  lookupUserForPill(userId) {
1022
- if (!this.room) return null;
1023
- const others = this.room.getOthers();
1024
- for (const o of others) {
1025
- const meta = o;
1026
- if ((meta.id ?? `c${o.connectionId}`) === userId) {
1010
+ for (const o of this.latestOthers) {
1011
+ if (o.user.userId === userId) {
1027
1012
  return {
1028
1013
  id: userId,
1029
- name: meta.info?.name ?? "Anonymous",
1014
+ name: o.user.name ?? "Anonymous",
1030
1015
  color: colorForUser(userId),
1031
- avatar: meta.info?.avatar
1016
+ avatar: o.user.avatar
1032
1017
  };
1033
1018
  }
1034
1019
  }
1035
1020
  return null;
1036
1021
  }
1037
1022
  scrollToFolloweeIfKnown() {
1038
- if (!this.followingUserId || !this.room) return;
1039
- const others = this.room.getOthers();
1040
- for (const o of others) {
1041
- const meta = o;
1042
- if ((meta.id ?? `c${o.connectionId}`) === this.followingUserId) {
1043
- const presence = o.presence;
1044
- if (typeof presence.scrollY === "number") {
1045
- this.followScrollTo(presence.scrollY);
1023
+ if (!this.followingUserId) return;
1024
+ for (const o of this.latestOthers) {
1025
+ if (o.user.userId === this.followingUserId) {
1026
+ if (typeof o.presence.scrollY === "number") {
1027
+ this.followScrollTo(o.presence.scrollY);
1046
1028
  }
1047
1029
  return;
1048
1030
  }
@@ -1068,39 +1050,27 @@ var init_live_collab = __esm({
1068
1050
  }
1069
1051
  attachMouseListener() {
1070
1052
  this.mouseListener = (e) => {
1071
- const xPct = e.clientX / window.innerWidth * 100;
1072
- const yPct = e.clientY / window.innerHeight * 100;
1073
- this.pendingCursor = { xPct, yPct };
1074
- this.flushThrottled();
1053
+ this.currentPresence.cursor = {
1054
+ xPct: e.clientX / window.innerWidth * 100,
1055
+ yPct: e.clientY / window.innerHeight * 100
1056
+ };
1057
+ this.presenceDirty = true;
1075
1058
  };
1076
1059
  window.addEventListener("mousemove", this.mouseListener, { passive: true });
1077
1060
  }
1078
1061
  attachScrollListener() {
1079
1062
  this.scrollListener = () => {
1080
- const now = performance.now();
1081
- const elapsed = now - this.lastScrollBroadcast;
1082
- if (elapsed >= SCROLL_THROTTLE_MS) {
1083
- this.flushScroll();
1084
- return;
1085
- }
1086
- if (this.scrollThrottleTimer == null) {
1087
- this.scrollThrottleTimer = window.setTimeout(() => {
1088
- this.scrollThrottleTimer = null;
1089
- this.flushScroll();
1090
- }, SCROLL_THROTTLE_MS - elapsed);
1091
- }
1063
+ this.currentPresence.scrollY = window.scrollY;
1064
+ this.presenceDirty = true;
1092
1065
  };
1093
1066
  window.addEventListener("scroll", this.scrollListener, { passive: true });
1094
1067
  }
1095
- flushScroll() {
1096
- if (!this.room) return;
1097
- this.room.updatePresence({ scrollY: window.scrollY });
1098
- this.lastScrollBroadcast = performance.now();
1099
- }
1100
1068
  attachVisibilityListener() {
1101
1069
  this.visibilityListener = () => {
1102
- if (document.hidden && this.room) {
1103
- this.room.updatePresence({ cursor: null });
1070
+ if (document.hidden) {
1071
+ void this.clearRemotePresence();
1072
+ } else {
1073
+ this.presenceDirty = true;
1104
1074
  }
1105
1075
  };
1106
1076
  document.addEventListener("visibilitychange", this.visibilityListener);
@@ -1113,90 +1083,187 @@ var init_live_collab = __esm({
1113
1083
  };
1114
1084
  document.addEventListener("keydown", this.keydownListener);
1115
1085
  }
1116
- subscribeStatus() {
1117
- if (!this.room) return;
1118
- this.statusIndicator?.setStatus(this.room.getStatus());
1119
- const unsub = this.room.subscribe("status", (status) => {
1120
- this.statusIndicator?.setStatus(status);
1121
- });
1122
- this.unsubscribers.push(unsub);
1086
+ setStatus(next) {
1087
+ if (this.status === next) return;
1088
+ this.status = next;
1089
+ this.statusIndicator?.setStatus(next);
1123
1090
  }
1124
- flushThrottled() {
1125
- const now = performance.now();
1126
- const elapsed = now - this.lastBroadcast;
1127
- if (elapsed >= MOUSEMOVE_THROTTLE_MS) {
1128
- this.flush();
1129
- return;
1130
- }
1131
- if (this.throttleTimer == null) {
1132
- this.throttleTimer = window.setTimeout(() => {
1133
- this.throttleTimer = null;
1134
- this.flush();
1135
- }, MOUSEMOVE_THROTTLE_MS - elapsed);
1091
+ /**
1092
+ * Push tick — POST current presence to /api/live/[room]/me. Skips
1093
+ * the request when nothing changed since last push, so an idle tab
1094
+ * generates near-zero traffic.
1095
+ */
1096
+ async push() {
1097
+ if (this.destroyed || !this.roomId) return;
1098
+ if (!this.presenceDirty) return;
1099
+ if (typeof document !== "undefined" && document.hidden) return;
1100
+ this.presenceDirty = false;
1101
+ try {
1102
+ const res = await this.fetchLive(`/api/live/${encodeURIComponent(this.roomId)}/me`, {
1103
+ method: "POST",
1104
+ body: JSON.stringify({
1105
+ connId: this.connId,
1106
+ presence: this.currentPresence
1107
+ })
1108
+ });
1109
+ if (!res.ok) {
1110
+ if (res.status === 402 || res.status === 401 || res.status === 503) {
1111
+ this.setStatus("disconnected");
1112
+ if (this.pushTimer != null) {
1113
+ window.clearInterval(this.pushTimer);
1114
+ this.pushTimer = null;
1115
+ }
1116
+ if (this.pullTimer != null) {
1117
+ window.clearInterval(this.pullTimer);
1118
+ this.pullTimer = null;
1119
+ }
1120
+ return;
1121
+ }
1122
+ throw new Error(`push HTTP ${res.status}`);
1123
+ }
1124
+ this.noteSuccess();
1125
+ } catch (err) {
1126
+ this.noteError(err);
1127
+ this.presenceDirty = true;
1136
1128
  }
1137
1129
  }
1138
- flush() {
1139
- if (!this.pendingCursor || !this.room) return;
1140
- this.room.updatePresence({ cursor: this.pendingCursor });
1141
- this.pendingCursor = null;
1142
- this.lastBroadcast = performance.now();
1143
- }
1144
- subscribeOthers() {
1145
- if (!this.room) return;
1146
- const unsub = this.room.subscribe("others", (others) => {
1147
- const seen = /* @__PURE__ */ new Set();
1148
- const stackUsers = /* @__PURE__ */ new Map();
1149
- const selections = [];
1150
- let followeeScrollY = null;
1151
- let followeeStillPresent = false;
1152
- for (const other of others) {
1153
- seen.add(other.connectionId);
1154
- const meta = other;
1155
- const userId = meta.id ?? `c${other.connectionId}`;
1156
- const name = meta.info?.name ?? "Anonymous";
1157
- const color = colorForUser(userId);
1158
- if (!stackUsers.has(userId)) {
1159
- stackUsers.set(userId, {
1160
- id: userId,
1161
- name,
1162
- color,
1163
- avatar: meta.info?.avatar
1164
- });
1165
- }
1166
- const presence = other.presence;
1167
- if (this.followingUserId === userId && followeeScrollY === null) {
1168
- followeeStillPresent = true;
1169
- if (typeof presence.scrollY === "number") {
1170
- followeeScrollY = presence.scrollY;
1130
+ /**
1131
+ * Pull tick — GET /api/live/[room]/others and drive cursor + stack +
1132
+ * selection + follow updates. Same handler shape as the Liveblocks-era
1133
+ * subscribeOthers callback; only the data source changed.
1134
+ */
1135
+ async pull() {
1136
+ if (this.destroyed || !this.roomId) return;
1137
+ try {
1138
+ const res = await this.fetchLive(
1139
+ `/api/live/${encodeURIComponent(this.roomId)}/others?me=${encodeURIComponent(this.connId)}`,
1140
+ { method: "GET" }
1141
+ );
1142
+ if (!res.ok) {
1143
+ if (res.status === 503 || res.status === 401) {
1144
+ this.setStatus("disconnected");
1145
+ if (this.pullTimer != null) {
1146
+ window.clearInterval(this.pullTimer);
1147
+ this.pullTimer = null;
1171
1148
  }
1149
+ return;
1172
1150
  }
1173
- if (presence.selecting && !selections.find((s) => s.id === userId)) {
1174
- try {
1175
- const fp = JSON.parse(presence.selecting.fingerprintJson);
1176
- selections.push({ id: userId, name, color, fingerprint: fp });
1177
- } catch {
1178
- }
1151
+ throw new Error(`pull HTTP ${res.status}`);
1152
+ }
1153
+ const body = await res.json();
1154
+ const others = (body.others ?? []).map((o) => ({
1155
+ connId: o.connId,
1156
+ presence: o.payload.presence,
1157
+ user: o.payload.user
1158
+ }));
1159
+ this.latestOthers = others;
1160
+ this.renderOthers(others);
1161
+ this.noteSuccess();
1162
+ } catch (err) {
1163
+ this.noteError(err);
1164
+ }
1165
+ }
1166
+ noteSuccess() {
1167
+ this.consecutiveErrors = 0;
1168
+ if (this.status !== "connected") this.setStatus("connected");
1169
+ }
1170
+ noteError(err) {
1171
+ this.consecutiveErrors++;
1172
+ if (this.consecutiveErrors >= DISCONNECT_AFTER_ERRORS) {
1173
+ this.setStatus("disconnected");
1174
+ } else if (this.consecutiveErrors >= RECONNECT_AFTER_ERRORS) {
1175
+ this.setStatus("reconnecting");
1176
+ }
1177
+ if (typeof console !== "undefined" && this.consecutiveErrors === 1) {
1178
+ console.warn("[vizu/live-collab] transport error", err);
1179
+ }
1180
+ }
1181
+ /**
1182
+ * Drive the cursor / stack / selection / follow layers from a fresh
1183
+ * `others` snapshot. Mirrors the body of the Liveblocks-era
1184
+ * subscribeOthers callback so DOM behavior is unchanged.
1185
+ */
1186
+ renderOthers(others) {
1187
+ const seen = /* @__PURE__ */ new Set();
1188
+ const stackUsers = /* @__PURE__ */ new Map();
1189
+ const selections = [];
1190
+ let followeeScrollY = null;
1191
+ let followeeStillPresent = false;
1192
+ for (const other of others) {
1193
+ seen.add(other.connId);
1194
+ const userId = other.user.userId;
1195
+ const name = other.user.name ?? "Anonymous";
1196
+ const color = colorForUser(userId);
1197
+ if (!stackUsers.has(userId)) {
1198
+ stackUsers.set(userId, { id: userId, name, color, avatar: other.user.avatar });
1199
+ }
1200
+ if (this.followingUserId === userId && followeeScrollY === null) {
1201
+ followeeStillPresent = true;
1202
+ if (typeof other.presence.scrollY === "number") {
1203
+ followeeScrollY = other.presence.scrollY;
1179
1204
  }
1180
- if (!presence.cursor) {
1181
- this.removeCursor(other.connectionId);
1182
- continue;
1205
+ }
1206
+ if (other.presence.selecting && !selections.find((s) => s.id === userId)) {
1207
+ try {
1208
+ const fp = JSON.parse(other.presence.selecting.fingerprintJson);
1209
+ selections.push({ id: userId, name, color, fingerprint: fp });
1210
+ } catch {
1183
1211
  }
1184
- this.upsertCursor(other.connectionId, userId, name, presence.cursor);
1185
1212
  }
1186
- for (const id of Array.from(this.cursors.keys())) {
1187
- if (!seen.has(id)) this.removeCursor(id);
1213
+ if (!other.presence.cursor) {
1214
+ this.removeCursor(other.connId);
1215
+ continue;
1188
1216
  }
1189
- this.presenceStack?.setOthers(Array.from(stackUsers.values()));
1190
- this.selectionOverlay?.setSelections(selections);
1191
- if (this.followingUserId) {
1192
- if (!followeeStillPresent) {
1193
- this.setFollowing(null);
1194
- } else if (followeeScrollY !== null) {
1195
- this.followScrollTo(followeeScrollY);
1196
- }
1217
+ this.upsertCursor(other.connId, userId, name, other.presence.cursor);
1218
+ }
1219
+ for (const id of Array.from(this.cursors.keys())) {
1220
+ if (!seen.has(id)) this.removeCursor(id);
1221
+ }
1222
+ this.presenceStack?.setOthers(Array.from(stackUsers.values()));
1223
+ this.selectionOverlay?.setSelections(selections);
1224
+ if (this.followingUserId) {
1225
+ if (!followeeStillPresent) {
1226
+ this.setFollowing(null);
1227
+ } else if (followeeScrollY !== null) {
1228
+ this.followScrollTo(followeeScrollY);
1197
1229
  }
1230
+ }
1231
+ }
1232
+ /**
1233
+ * Async wrapper around fetch that injects the optional auth header
1234
+ * resolved from opts.getAuthHeader. Same-origin requests rely on
1235
+ * cookies via `credentials: 'include'`; cross-origin gets the
1236
+ * bearer token returned by the host.
1237
+ */
1238
+ async fetchLive(path, init) {
1239
+ const headers = new Headers(init.headers);
1240
+ headers.set("content-type", "application/json");
1241
+ if (this.opts.getAuthHeader) {
1242
+ const extra = await this.opts.getAuthHeader();
1243
+ if (extra) {
1244
+ new Headers(extra).forEach((v, k) => headers.set(k, v));
1245
+ }
1246
+ }
1247
+ return fetch(this.opts.apiUrl + path, {
1248
+ ...init,
1249
+ headers,
1250
+ credentials: "include"
1198
1251
  });
1199
- this.unsubscribers.push(unsub);
1252
+ }
1253
+ /**
1254
+ * Fire-and-forget clearOnly POST so other clients see us drop within
1255
+ * one pull cycle (~200ms) instead of waiting for the 5s presence TTL.
1256
+ * Errors swallowed — the TTL is the safety net.
1257
+ */
1258
+ async clearRemotePresence() {
1259
+ if (!this.roomId) return;
1260
+ try {
1261
+ await this.fetchLive(`/api/live/${encodeURIComponent(this.roomId)}/me`, {
1262
+ method: "POST",
1263
+ body: JSON.stringify({ connId: this.connId, clearOnly: true })
1264
+ });
1265
+ } catch {
1266
+ }
1200
1267
  }
1201
1268
  upsertCursor(connectionId, userId, name, cursor) {
1202
1269
  if (!this.container) return;
@@ -1747,6 +1814,18 @@ var CloudStorageAdapter = class {
1747
1814
  async preflightAuth() {
1748
1815
  await this.resolveToken();
1749
1816
  }
1817
+ /**
1818
+ * Synchronous accessor for the currently cached bearer token, used
1819
+ * by sibling modules that need to authenticate against the same
1820
+ * backend (live-collab cross-origin polling). Returns null when no
1821
+ * valid token is cached — callers fall back to credentials:'include'
1822
+ * for same-origin or just disable themselves.
1823
+ */
1824
+ getCachedAuthToken() {
1825
+ if (!this.cachedToken) return null;
1826
+ if (this.isExpired(this.cachedToken)) return null;
1827
+ return this.cachedToken.token;
1828
+ }
1750
1829
  /* ─── StorageAdapter v2 ───────────────────────────────────────────── */
1751
1830
  async load(_namespace) {
1752
1831
  const out = [];
@@ -1919,7 +1998,7 @@ var CloudStorageAdapter = class {
1919
1998
  }
1920
1999
  }
1921
2000
  sessionKey() {
1922
- return `vizu:cloud-token:${this.workspace}`;
2001
+ return `vizu:cloud-token:v2:${this.workspace}`;
1923
2002
  }
1924
2003
  /**
1925
2004
  * Resolve a valid token, opening the popup if needed.
@@ -4233,14 +4312,27 @@ var Vizu = class {
4233
4312
  if (options.actions) this.actions = [...options.actions];
4234
4313
  if (options.onCommentAdded) this.bus.on("comment:added", (p) => options.onCommentAdded(p.comment));
4235
4314
  if (options.onCommentRemoved) this.bus.on("comment:removed", (p) => options.onCommentRemoved(p.id));
4236
- if (options.liveblocks && options.cloud) {
4237
- const live = options.liveblocks;
4315
+ if (options.cloud) {
4238
4316
  const cloud = options.cloud;
4317
+ const apiUrl = (cloud.apiUrl ?? "https://vizu.unhingged.com").replace(/\/$/, "");
4239
4318
  void Promise.resolve().then(() => (init_live_collab(), live_collab_exports)).then(({ LiveCollab: LiveCollab2 }) => {
4240
4319
  this.liveCollab = new LiveCollab2({
4241
- publicKey: live.publicKey,
4242
- authEndpoint: live.authEndpoint,
4243
- workspaceSlug: cloud.workspace
4320
+ workspaceSlug: cloud.workspace,
4321
+ apiUrl,
4322
+ getAuthHeader: async () => {
4323
+ if (typeof window === "undefined") return null;
4324
+ try {
4325
+ if (new URL(apiUrl).origin === window.location.origin) return null;
4326
+ } catch {
4327
+ return null;
4328
+ }
4329
+ const adapter = this.storage;
4330
+ if (adapter instanceof CloudStorageAdapter) {
4331
+ const token = adapter.getCachedAuthToken();
4332
+ if (token) return { Authorization: `Bearer ${token}` };
4333
+ }
4334
+ return null;
4335
+ }
4244
4336
  });
4245
4337
  void this.liveCollab.start();
4246
4338
  });