@unhingged/vizu-core 0.1.0 → 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/auto.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, init2) {
1239
+ const headers = new Headers(init2.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
+ ...init2,
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;
@@ -1714,10 +1781,40 @@ var CloudStorageAdapter = class {
1714
1781
  this.cachedToken = null;
1715
1782
  /** Single-flight: at most one popup at a time. Subsequent requests await this. */
1716
1783
  this.pendingAuth = null;
1784
+ /** Whether we've already fired onAuthChanged for the current token. */
1785
+ this.lastNotifiedTokenId = null;
1717
1786
  this.workspace = opts.workspace;
1718
1787
  this.apiUrl = (opts.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
1719
1788
  this.autoSignIn = opts.autoSignIn !== false;
1789
+ this.onAuthChanged = opts.onAuthChanged;
1720
1790
  this.cachedToken = this.readStoredToken();
1791
+ if (this.cachedToken && !this.isExpired(this.cachedToken)) {
1792
+ this.fireAuthChanged(this.cachedToken);
1793
+ }
1794
+ }
1795
+ /**
1796
+ * Open the sign-in popup right now (or no-op if a valid token is
1797
+ * cached). Vizu calls this on enable() so the user signs in BEFORE
1798
+ * trying to leave a comment — no surprise modal mid-comment.
1799
+ *
1800
+ * Throws auth_canceled if the user closes the popup. Caller can
1801
+ * decide whether to disable the surface or leave it in a read-only
1802
+ * state.
1803
+ */
1804
+ async preflightAuth() {
1805
+ await this.resolveToken();
1806
+ }
1807
+ /**
1808
+ * Synchronous accessor for the currently cached bearer token, used
1809
+ * by sibling modules that need to authenticate against the same
1810
+ * backend (live-collab cross-origin polling). Returns null when no
1811
+ * valid token is cached — callers fall back to credentials:'include'
1812
+ * for same-origin or just disable themselves.
1813
+ */
1814
+ getCachedAuthToken() {
1815
+ if (!this.cachedToken) return null;
1816
+ if (this.isExpired(this.cachedToken)) return null;
1817
+ return this.cachedToken.token;
1721
1818
  }
1722
1819
  /* ─── StorageAdapter v2 ───────────────────────────────────────────── */
1723
1820
  async load(_namespace) {
@@ -1891,7 +1988,7 @@ var CloudStorageAdapter = class {
1891
1988
  }
1892
1989
  }
1893
1990
  sessionKey() {
1894
- return `vizu:cloud-token:${this.workspace}`;
1991
+ return `vizu:cloud-token:v2:${this.workspace}`;
1895
1992
  }
1896
1993
  /**
1897
1994
  * Resolve a valid token, opening the popup if needed.
@@ -1906,6 +2003,7 @@ var CloudStorageAdapter = class {
1906
2003
  const fresh = this.readStoredToken();
1907
2004
  if (fresh) {
1908
2005
  this.cachedToken = fresh;
2006
+ this.fireAuthChanged(fresh);
1909
2007
  return fresh;
1910
2008
  }
1911
2009
  if (!this.autoSignIn) {
@@ -1914,6 +2012,7 @@ var CloudStorageAdapter = class {
1914
2012
  if (!this.pendingAuth) {
1915
2013
  this.pendingAuth = this.openSignInPopup().then((t) => {
1916
2014
  this.writeStoredToken(t);
2015
+ this.fireAuthChanged(t);
1917
2016
  return t;
1918
2017
  }).finally(() => {
1919
2018
  this.pendingAuth = null;
@@ -1921,6 +2020,22 @@ var CloudStorageAdapter = class {
1921
2020
  }
1922
2021
  return this.pendingAuth;
1923
2022
  }
2023
+ /**
2024
+ * Notify the host that the authenticated identity changed. De-duped
2025
+ * via the token string so multiple resolveToken() hits with the same
2026
+ * cached token don't re-call setUser on every fetch.
2027
+ */
2028
+ fireAuthChanged(token) {
2029
+ if (this.lastNotifiedTokenId === token.token) return;
2030
+ this.lastNotifiedTokenId = token.token;
2031
+ try {
2032
+ this.onAuthChanged?.({ userId: token.userId, user: token.user });
2033
+ } catch (err) {
2034
+ if (typeof console !== "undefined") {
2035
+ console.warn("[vizu/cloud] onAuthChanged callback threw", err);
2036
+ }
2037
+ }
2038
+ }
1924
2039
  openSignInPopup() {
1925
2040
  return new Promise((resolve, reject) => {
1926
2041
  if (typeof window === "undefined") {
@@ -1953,7 +2068,13 @@ var CloudStorageAdapter = class {
1953
2068
  if (typeof data.token !== "string" || typeof data.expiresAt !== "string" || typeof data.userId !== "string") return;
1954
2069
  resolved = true;
1955
2070
  cleanup();
1956
- resolve({ token: data.token, expiresAt: data.expiresAt, userId: data.userId });
2071
+ const userPayload = data.user;
2072
+ const user = userPayload && typeof userPayload.name === "string" ? {
2073
+ id: typeof userPayload.id === "string" ? userPayload.id : data.userId,
2074
+ name: userPayload.name,
2075
+ avatarUrl: typeof userPayload.avatarUrl === "string" ? userPayload.avatarUrl : void 0
2076
+ } : null;
2077
+ resolve({ token: data.token, expiresAt: data.expiresAt, userId: data.userId, user });
1957
2078
  };
1958
2079
  const onPoll = window.setInterval(() => {
1959
2080
  if (popup.closed) {
@@ -4054,7 +4175,7 @@ var DEFAULTS = {
4054
4175
  namespace: "default",
4055
4176
  accent: "#FF6647"
4056
4177
  };
4057
- function resolveStorage(storage, cloud, defaultMode) {
4178
+ function resolveStorage(storage, cloud, defaultMode, onAuthChanged) {
4058
4179
  if (cloud) {
4059
4180
  if (storage && typeof console !== "undefined") {
4060
4181
  console.warn("[vizu] Both `cloud` and `storage` set; `cloud` wins. Drop `storage` to silence this.");
@@ -4062,7 +4183,8 @@ function resolveStorage(storage, cloud, defaultMode) {
4062
4183
  return new CloudStorageAdapter({
4063
4184
  workspace: cloud.workspace,
4064
4185
  apiUrl: cloud.apiUrl,
4065
- autoSignIn: cloud.autoSignIn
4186
+ autoSignIn: cloud.autoSignIn,
4187
+ onAuthChanged
4066
4188
  });
4067
4189
  }
4068
4190
  if (!storage) return defaultMode === "local" ? new LocalStorageAdapter() : new InMemoryStorageAdapter();
@@ -4168,19 +4290,39 @@ var Vizu = class {
4168
4290
  this.opts.namespace = options.namespace ?? DEFAULTS.namespace;
4169
4291
  this.opts.accent = options.accent ?? DEFAULTS.accent;
4170
4292
  this.parsedShortcut = parseShortcut(this.opts.shortcut);
4171
- this.storage = resolveStorage(options.storage, options.cloud, _defaultStorage);
4293
+ this.storage = resolveStorage(
4294
+ options.storage,
4295
+ options.cloud,
4296
+ _defaultStorage,
4297
+ (info) => {
4298
+ if (info.user) this.setUser(info.user);
4299
+ }
4300
+ );
4172
4301
  this.user = options.user ?? null;
4173
4302
  if (options.actions) this.actions = [...options.actions];
4174
4303
  if (options.onCommentAdded) this.bus.on("comment:added", (p) => options.onCommentAdded(p.comment));
4175
4304
  if (options.onCommentRemoved) this.bus.on("comment:removed", (p) => options.onCommentRemoved(p.id));
4176
- if (options.liveblocks && options.cloud) {
4177
- const live = options.liveblocks;
4305
+ if (options.cloud) {
4178
4306
  const cloud = options.cloud;
4307
+ const apiUrl = (cloud.apiUrl ?? "https://vizu.unhingged.com").replace(/\/$/, "");
4179
4308
  void Promise.resolve().then(() => (init_live_collab(), live_collab_exports)).then(({ LiveCollab: LiveCollab2 }) => {
4180
4309
  this.liveCollab = new LiveCollab2({
4181
- publicKey: live.publicKey,
4182
- authEndpoint: live.authEndpoint,
4183
- workspaceSlug: cloud.workspace
4310
+ workspaceSlug: cloud.workspace,
4311
+ apiUrl,
4312
+ getAuthHeader: async () => {
4313
+ if (typeof window === "undefined") return null;
4314
+ try {
4315
+ if (new URL(apiUrl).origin === window.location.origin) return null;
4316
+ } catch {
4317
+ return null;
4318
+ }
4319
+ const adapter = this.storage;
4320
+ if (adapter instanceof CloudStorageAdapter) {
4321
+ const token = adapter.getCachedAuthToken();
4322
+ if (token) return { Authorization: `Bearer ${token}` };
4323
+ }
4324
+ return null;
4325
+ }
4184
4326
  });
4185
4327
  void this.liveCollab.start();
4186
4328
  });
@@ -4214,6 +4356,14 @@ var Vizu = class {
4214
4356
  this.enabled = true;
4215
4357
  this.bus.emit("enabled", {});
4216
4358
  this.deferred(() => this.mount());
4359
+ if (this.storage instanceof CloudStorageAdapter) {
4360
+ void this.storage.preflightAuth().catch((err) => {
4361
+ if (err?.code === "auth_canceled" || err?.code === "popup_blocked") return;
4362
+ if (typeof console !== "undefined") {
4363
+ console.warn("[vizu] preflight auth failed:", err);
4364
+ }
4365
+ });
4366
+ }
4217
4367
  }
4218
4368
  disable() {
4219
4369
  if (!this.enabled) return;