@auxilium/datalynk-client 1.5.1 → 1.6.0

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
@@ -1553,7 +1553,10 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1553
1553
  }
1554
1554
  });
1555
1555
  if ((_a = this.api.options.offline) == null ? void 0 : _a.length)
1556
- this.user$.pipe(filter((u) => u !== void 0)).subscribe((u) => localStorage.setItem("datalynk-user", JSON.stringify(u)));
1556
+ this.user$.pipe(filter((u) => u !== void 0)).subscribe((u) => {
1557
+ if (u == null) localStorage.removeItem("datalynk-user");
1558
+ else localStorage.setItem("datalynk-user", JSON.stringify(u));
1559
+ });
1557
1560
  }
1558
1561
  /** Current user */
1559
1562
  get user() {
@@ -1589,7 +1592,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1589
1592
  * @return {Promise<void>} Login complete
1590
1593
  */
1591
1594
  async handleLogin(spoke, options) {
1592
- var _a, _b;
1595
+ var _a, _b, _c;
1593
1596
  if (this.onlinePrompt) window.removeEventListener("online", this.onlinePrompt);
1594
1597
  if ((_a = this.api.options.offline) == null ? void 0 : _a.length) window.removeEventListener("online", this.onlinePrompt = () => this.handleLogin(spoke, options));
1595
1598
  const urlParams = new URLSearchParams(location.search);
@@ -1598,8 +1601,10 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1598
1601
  this.api.token = urlToken;
1599
1602
  urlParams.delete("datalynkToken");
1600
1603
  location.href = location.pathname + (urlParams.toString() ? "?" + urlParams.toString() : "") + location.hash;
1604
+ } else if (this.api.reauthenticationPending && !this.api.online && ((_b = this.api.jwtPayload) == null ? void 0 : _b.realm) == spoke) {
1605
+ return;
1601
1606
  } else if (this.api.token && !this.api.expired) {
1602
- if (((_b = this.api.jwtPayload) == null ? void 0 : _b.realm) != spoke) {
1607
+ if (((_c = this.api.jwtPayload) == null ? void 0 : _c.realm) != spoke) {
1603
1608
  this.api.token = null;
1604
1609
  location.reload();
1605
1610
  }
@@ -3101,6 +3106,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3101
3106
  // [ Callback, Re-subscribe ]
3102
3107
  __publicField(this, "retry");
3103
3108
  __publicField(this, "socket");
3109
+ __publicField(this, "connectGeneration", 0);
3104
3110
  __publicField(this, "open", false);
3105
3111
  this.api = api;
3106
3112
  this.options = options;
@@ -3109,7 +3115,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3109
3115
  this.options.url = origin.replace("http", "ws").replace(/:\d+/g, "") + `/s/`;
3110
3116
  }
3111
3117
  if (this.options.url !== false)
3112
- api.token$.pipe(filter((u) => u !== void 0), distinctUntilChanged()).subscribe(() => this.connect());
3118
+ api.token$.pipe(filter((u) => u !== void 0), distinctUntilChanged()).subscribe(() => void this.connect());
3113
3119
  }
3114
3120
  /**
3115
3121
  * Add listener for all socket events
@@ -3131,6 +3137,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3131
3137
  */
3132
3138
  close() {
3133
3139
  var _a;
3140
+ if (this.options.authentication === "ticket") this.connectGeneration++;
3134
3141
  if (this.open) console.debug("Datalynk socket: disconnected");
3135
3142
  this.open = false;
3136
3143
  (_a = this.socket) == null ? void 0 : _a.close();
@@ -3143,6 +3150,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3143
3150
  * @param {number} timeout Retry to connect every x seconds
3144
3151
  */
3145
3152
  connect(timeout = 30) {
3153
+ if (this.options.authentication === "ticket") return this.connectWithTicket(timeout);
3146
3154
  if (this.options.url === false) return console.warn("Datalynk socket disabled");
3147
3155
  if (this.open) this.close();
3148
3156
  this.retry = setTimeout(() => {
@@ -3172,6 +3180,80 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3172
3180
  };
3173
3181
  }
3174
3182
  }
3183
+ /**
3184
+ * Ticket-authenticated connection path, used only once a chat-capable host switches
3185
+ * this instance to `authentication: 'ticket'` (see Api.openChat). Entirely separate
3186
+ * from the token path above so existing non-chat consumers are never affected.
3187
+ */
3188
+ async connectWithTicket(timeout = 30) {
3189
+ var _a;
3190
+ if (this.options.url === false) return console.warn("Datalynk socket disabled");
3191
+ const generation = ++this.connectGeneration;
3192
+ if (this.open || this.socket) {
3193
+ this.open = false;
3194
+ (_a = this.socket) == null ? void 0 : _a.close();
3195
+ this.socket = void 0;
3196
+ }
3197
+ if (this.retry) clearTimeout(this.retry);
3198
+ this.retry = setTimeout(() => {
3199
+ if (this.open || generation !== this.connectGeneration) return;
3200
+ void this.connect(timeout);
3201
+ }, timeout * 1e3);
3202
+ if (!this.api.online || !this.api.token) return;
3203
+ let socketUrl = this.normalizeUrl(String(this.options.url || ""));
3204
+ try {
3205
+ const response = await this.api.request({ "$/chat/socketTicket": {} }, { noOptimize: true });
3206
+ if (generation !== this.connectGeneration) return;
3207
+ if (response == null ? void 0 : response.ticket) socketUrl += `${socketUrl.includes("?") ? "&" : "?"}ticket=${encodeURIComponent(response.ticket)}`;
3208
+ else throw new Error("Missing socket ticket");
3209
+ } catch (_) {
3210
+ if (generation !== this.connectGeneration) return;
3211
+ this.scheduleReconnect(generation, timeout);
3212
+ return;
3213
+ }
3214
+ if (generation !== this.connectGeneration) return;
3215
+ this.socket = new WebSocket(socketUrl);
3216
+ this.socket.onopen = () => void 0;
3217
+ this.socket.onclose = () => {
3218
+ this.open = false;
3219
+ this.socket = void 0;
3220
+ this.scheduleReconnect(generation, timeout);
3221
+ };
3222
+ this.socket.onerror = () => {
3223
+ };
3224
+ this.socket.onmessage = (message) => {
3225
+ var _a2;
3226
+ let payload;
3227
+ try {
3228
+ payload = JSON.parse(message.data);
3229
+ } catch {
3230
+ return;
3231
+ }
3232
+ if (payload.connected != void 0) {
3233
+ if (payload.connected) {
3234
+ this.open = true;
3235
+ if (this.retry) clearTimeout(this.retry);
3236
+ this.retry = null;
3237
+ console.debug("Datalynk socket: connected");
3238
+ this.listeners.forEach((l) => l[1]());
3239
+ } else {
3240
+ this.open = false;
3241
+ console.warn(`Datalynk socket failed: ${payload.error || "authentication failed"}`);
3242
+ (_a2 = this.socket) == null ? void 0 : _a2.close();
3243
+ }
3244
+ } else {
3245
+ this.listeners.forEach((l) => l[0](payload));
3246
+ }
3247
+ };
3248
+ }
3249
+ scheduleReconnect(generation, timeout) {
3250
+ if (generation !== this.connectGeneration || this.options.url === false) return;
3251
+ if (this.retry) clearTimeout(this.retry);
3252
+ this.retry = setTimeout(() => {
3253
+ if (generation !== this.connectGeneration) return;
3254
+ void this.connect(timeout);
3255
+ }, timeout * 1e3);
3256
+ }
3175
3257
  /**
3176
3258
  * Send data to socket server
3177
3259
  *
@@ -3198,6 +3280,17 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3198
3280
  unsubscribe();
3199
3281
  };
3200
3282
  }
3283
+ normalizeUrl(value) {
3284
+ let url = value.replace(/^http:\/\//i, "ws://").replace(/^https:\/\//i, "wss://");
3285
+ try {
3286
+ const parsed = new URL(url);
3287
+ if (parsed.pathname === "/" || parsed.pathname === "") parsed.pathname = "/s/";
3288
+ else if (parsed.pathname === "/s") parsed.pathname = "/s/";
3289
+ url = parsed.toString();
3290
+ } catch {
3291
+ }
3292
+ return url;
3293
+ }
3201
3294
  }
3202
3295
  class Superuser {
3203
3296
  constructor(api) {
@@ -3229,7 +3322,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3229
3322
  } });
3230
3323
  }
3231
3324
  }
3232
- const version = "1.5.1";
3325
+ const version = "1.6.0";
3233
3326
  class WebRtc {
3234
3327
  constructor(api) {
3235
3328
  __publicField(this, "ice");
@@ -3776,7 +3869,14 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3776
3869
  timeout: 6e4
3777
3870
  });
3778
3871
  __publicField(this, "authenticationInvalid", false);
3872
+ /** Token reached exp while the client could not reach Datalynk; keep the offline session alive until reconnect. */
3873
+ __publicField(this, "offlineAuthenticationExpired", false);
3779
3874
  __publicField(this, "tokenExpiryTimeout", null);
3875
+ __publicField(this, "unauthorizedLogout", null);
3876
+ __publicField(this, "replayingPending", false);
3877
+ /** If replay is requested while a replay pass is already active, run another pass when it finishes. */
3878
+ __publicField(this, "pendingReplayRequested", false);
3879
+ __publicField(this, "offlineSessionOwner", null);
3780
3880
  /** Request-specific recovery after a server response proves an API call is broken. */
3781
3881
  __publicField(this, "recovery", null);
3782
3882
  /** Retry the failed request twice immediately, then this long after each failed recovery response. */
@@ -3784,9 +3884,15 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3784
3884
  __publicField(this, "recoveryImmediateRetries", 2);
3785
3885
  /** LocalStorage key for persisting logins */
3786
3886
  __publicField(this, "localStorageKey", "datalynk-token");
3887
+ /** Persisted owner of an offline queue so another login cannot inherit queued writes. */
3888
+ __publicField(this, "offlineSessionOwnerKey", "datalynk-offline-owner");
3787
3889
  __publicField(this, "tokenStorageListener", null);
3788
3890
  /** Pending requests cache */
3789
3891
  __publicField(this, "pending", {});
3892
+ __publicField(this, "chatModulePromise", null);
3893
+ __publicField(this, "chatInstance", null);
3894
+ __publicField(this, "chatLauncherHost", null);
3895
+ __publicField(this, "chatLauncherButton", null);
3790
3896
  /** Helpers */
3791
3897
  /** Authentication */
3792
3898
  __publicField(this, "auth");
@@ -3841,6 +3947,9 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3841
3947
  offline: [],
3842
3948
  origin: typeof location !== "undefined" ? location.host : "Unknown",
3843
3949
  saveSession: true,
3950
+ manageSession: true,
3951
+ chat: false,
3952
+ replayOfflineQueue: true,
3844
3953
  watchTokenExpiry: true,
3845
3954
  serviceWorker: "/service.worker.mjs",
3846
3955
  ...options,
@@ -3871,9 +3980,9 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3871
3980
  window.addEventListener("storage", this.tokenStorageListener);
3872
3981
  }
3873
3982
  }
3874
- if (this.options.watchTokenExpiry)
3983
+ if (this.options.manageSession !== false && this.options.watchTokenExpiry)
3875
3984
  this.token$.pipe(distinctUntilChanged()).subscribe((token) => this.scheduleTokenExpiry(token));
3876
- this.socket = new Socket(this, { url: options.socket });
3985
+ this.socket = new Socket(this, { url: options.socket, authentication: options.socketAuthentication, realm: options.socketRealm });
3877
3986
  this.gps = new Gps(this, options.gps);
3878
3987
  this.auth = new Auth(this);
3879
3988
  this.files = new Files(this);
@@ -3883,21 +3992,13 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3883
3992
  this.webrtc = new WebRtc(this);
3884
3993
  if (typeof indexedDB != "undefined") {
3885
3994
  this.database = new Database("datalynk", ["pending", ...this.options.offline || []]);
3886
- this.online$.subscribe(async (online) => {
3887
- var _a2;
3888
- if (!online) return;
3889
- const table = (_a2 = this.database) == null ? void 0 : _a2.table("pending");
3890
- const keys = await (table == null ? void 0 : table.getAllKeys());
3891
- await Promise.allSettled(keys.map(async (k) => {
3892
- const r = await (table == null ? void 0 : table.get(k));
3893
- await this.request(r).then(() => table == null ? void 0 : table.delete(k));
3894
- }));
3995
+ if (this.options.replayOfflineQueue !== false) this.online$.subscribe((online) => {
3996
+ if (online) void this.replayPendingQueue();
3895
3997
  });
3896
3998
  }
3897
3999
  if (typeof window !== "undefined") {
3898
4000
  window.addEventListener("online", () => this.checkConnection());
3899
4001
  window.addEventListener("offline", () => this.setConnectionStatus("offline"));
3900
- this.online$.subscribe(() => this.offlineBanner());
3901
4002
  this.startHeartbeat();
3902
4003
  }
3903
4004
  if ((_a = this.options.offline) == null ? void 0 : _a.length) {
@@ -3922,12 +4023,17 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3922
4023
  })();
3923
4024
  }
3924
4025
  }
4026
+ this.setupChatLauncher();
3925
4027
  }
3926
4028
  /** Is token expired */
3927
4029
  get expired() {
3928
4030
  var _a;
3929
4031
  return (((_a = this.jwtPayload) == null ? void 0 : _a.exp) ?? Infinity) * 1e3 <= Date.now();
3930
4032
  }
4033
+ /** Whether authentication expired while offline and must be renewed before replaying queued work. */
4034
+ get reauthenticationPending() {
4035
+ return this.offlineAuthenticationExpired;
4036
+ }
3931
4037
  /** Get session info from JWT payload */
3932
4038
  get jwtPayload() {
3933
4039
  if (!this.token) return null;
@@ -3986,21 +4092,158 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3986
4092
  return this.token$.getValue();
3987
4093
  }
3988
4094
  set token(token) {
4095
+ var _a;
3989
4096
  if (this.recovery && token !== this.recovery.token)
3990
4097
  this.cancelRecovery(errorFromCode(401, "Session changed during API recovery"));
3991
- if (token && this.isTokenExpired(token)) {
4098
+ if (this.options.manageSession !== false && token && this.isTokenExpired(token)) {
3992
4099
  if (this.adoptStoredToken(token)) return;
3993
- this.authenticationInvalid = true;
3994
- this.token$.next(null);
3995
- this.setConnectionStatus("unauthorized");
4100
+ if (this.shouldDeferLocalExpiry()) {
4101
+ this.deferOfflineExpiry(token);
4102
+ return;
4103
+ }
4104
+ this.markUnauthorized(void 0, this.hasOwnedOfflineSession(token));
3996
4105
  return;
3997
4106
  }
3998
4107
  this.authenticationInvalid = false;
4108
+ this.offlineAuthenticationExpired = false;
3999
4109
  this.token$.next(token);
4000
4110
  if (token && !this.online && !this.recovery) {
4001
4111
  const browserOnline = typeof navigator == "undefined" || typeof navigator.onLine == "undefined" || navigator.onLine;
4002
4112
  this.setConnectionStatus(browserOnline ? "online" : "offline");
4003
4113
  }
4114
+ if (token && ((_a = this.options) == null ? void 0 : _a.replayOfflineQueue) !== false) void this.replayPendingQueue();
4115
+ }
4116
+ /** Mount the optional launcher without loading or initializing the chat bundle. */
4117
+ setupChatLauncher() {
4118
+ if (!this.options.chat || typeof document === "undefined") return;
4119
+ const mount = () => {
4120
+ if (this.chatLauncherHost || !document.body || document.querySelector("[data-datalynk-chat-launcher]")) return;
4121
+ const host = document.createElement("div");
4122
+ host.setAttribute("data-datalynk-chat-launcher", "");
4123
+ const root = host.attachShadow({ mode: "open" });
4124
+ root.innerHTML = `
4125
+ <style>
4126
+ :host {
4127
+ all: initial;
4128
+ position: fixed;
4129
+ right: 20px;
4130
+ bottom: 20px;
4131
+ z-index: 2147482999;
4132
+ pointer-events: none;
4133
+ }
4134
+ button {
4135
+ display: grid;
4136
+ place-items: center;
4137
+ width: 52px;
4138
+ height: 52px;
4139
+ padding: 0;
4140
+ border: 1px solid #2f596d;
4141
+ border-radius: 50%;
4142
+ background: #3f6f85;
4143
+ color: #fff;
4144
+ box-shadow: 0 4px 14px rgb(15 42 54 / 24%);
4145
+ cursor: pointer;
4146
+ pointer-events: auto;
4147
+ transition: background-color 120ms ease-out, box-shadow 120ms ease-out, transform 120ms ease-out;
4148
+ }
4149
+ button:hover { background: #315d71; box-shadow: 0 6px 18px rgb(15 42 54 / 30%); }
4150
+ button:active { transform: translateY(1px); }
4151
+ button:focus-visible { outline: 3px solid #f2c94c; outline-offset: 3px; }
4152
+ button:disabled { cursor: wait; opacity: .72; }
4153
+ .status {
4154
+ position: absolute;
4155
+ right: 62px;
4156
+ bottom: 7px;
4157
+ width: max-content;
4158
+ max-width: min(260px, calc(100vw - 94px));
4159
+ padding: 9px 11px;
4160
+ border: 1px solid #c6d3d9;
4161
+ border-radius: 6px;
4162
+ background: #fff;
4163
+ box-shadow: 0 3px 12px rgb(15 42 54 / 16%);
4164
+ color: #263942;
4165
+ font: 600 13px/1.35 "Segoe UI", Arial, sans-serif;
4166
+ pointer-events: none;
4167
+ }
4168
+ .status:empty { display: none; }
4169
+ svg { width: 25px; height: 25px; }
4170
+ @media (max-width: 600px) { :host { right: 12px; bottom: 12px; } }
4171
+ @media (prefers-reduced-motion: reduce) { button { transition: none; } }
4172
+ </style>
4173
+ <button type="button" aria-label="Open Datalynk Chat" title="Chat">
4174
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
4175
+ <path fill="currentColor" d="M4 4h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-5 3v-3a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm2.5 8.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm5.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm5.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z"/>
4176
+ </svg>
4177
+ </button>
4178
+ <span class="status" role="status" aria-live="polite"></span>`;
4179
+ const button = root.querySelector("button");
4180
+ button.addEventListener("click", () => void this.openChatFromLauncher());
4181
+ document.body.appendChild(host);
4182
+ this.chatLauncherHost = host;
4183
+ this.chatLauncherButton = button;
4184
+ };
4185
+ if (document.body) mount();
4186
+ else document.addEventListener("DOMContentLoaded", mount, { once: true });
4187
+ }
4188
+ async openChatFromLauncher() {
4189
+ const button = this.chatLauncherButton;
4190
+ if (!button) return;
4191
+ const status = button.getRootNode().querySelector(".status");
4192
+ button.disabled = true;
4193
+ button.setAttribute("aria-busy", "true");
4194
+ status.textContent = "Opening chat…";
4195
+ try {
4196
+ await this.openChat();
4197
+ status.textContent = "";
4198
+ } catch (error) {
4199
+ button.disabled = false;
4200
+ button.removeAttribute("aria-busy");
4201
+ status.textContent = "Chat is unavailable for this session.";
4202
+ console.error("Unable to open Datalynk Chat.", error);
4203
+ }
4204
+ }
4205
+ restoreChatLauncher() {
4206
+ if (!this.chatLauncherHost || !this.chatLauncherButton) return;
4207
+ this.chatLauncherHost.hidden = false;
4208
+ this.chatLauncherButton.disabled = false;
4209
+ this.chatLauncherButton.removeAttribute("aria-busy");
4210
+ this.chatLauncherButton.focus();
4211
+ }
4212
+ /** Lazily load and construct chat. No chat code or requests run before this call. */
4213
+ async chat() {
4214
+ if (this.chatInstance) return this.chatInstance;
4215
+ const client = await this.loadChatModule();
4216
+ return this.chatInstance || (this.chatInstance = new client.Chat(this));
4217
+ }
4218
+ /** Lazily load, authorize, and open the reusable chat window. */
4219
+ async openChat(options = {}) {
4220
+ const client = await this.loadChatModule();
4221
+ const chat = await this.chat();
4222
+ if (!await chat.available()) throw new Error("Chat is disabled or this session is not authorized.");
4223
+ if (this.socket.options.authentication !== "ticket") {
4224
+ this.socket.options.authentication = "ticket";
4225
+ void this.socket.connect();
4226
+ }
4227
+ const onClose = options.onClose;
4228
+ const chatWindow = client.openChat(chat, {
4229
+ ...options,
4230
+ onClose: () => {
4231
+ this.restoreChatLauncher();
4232
+ onClose == null ? void 0 : onClose();
4233
+ }
4234
+ });
4235
+ if (this.chatLauncherHost) this.chatLauncherHost.hidden = true;
4236
+ return chatWindow;
4237
+ }
4238
+ loadChatModule() {
4239
+ if (!this.chatModulePromise) {
4240
+ const moduleUrl = this.options.chatModule || "./chat.mjs";
4241
+ this.chatModulePromise = import(
4242
+ /* @vite-ignore */
4243
+ moduleUrl
4244
+ );
4245
+ }
4246
+ return this.chatModulePromise;
4004
4247
  }
4005
4248
  async _request(req, options = {}) {
4006
4249
  if (this.recovery) throw errorFromCode(503, "Datalynk is unavailable");
@@ -4018,8 +4261,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4018
4261
  /** Execute exactly one HTTP API attempt. Recovery uses this directly to avoid recursive retry loops. */
4019
4262
  async _requestOnce(req, options = {}, recoveryAttempt = false) {
4020
4263
  const token = options.token || this.token;
4021
- if (token && this.isTokenExpired(token)) {
4022
- this.markUnauthorized(token);
4264
+ if (this.options.manageSession !== false && token && this.isTokenExpired(token)) {
4265
+ if (token === this.token) this.expireToken(token);
4023
4266
  throw errorFromCode(401, "Session token expired");
4024
4267
  }
4025
4268
  let resp;
@@ -4055,7 +4298,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4055
4298
  if (!resp.ok || (data == null ? void 0 : data.error)) {
4056
4299
  const error = Object.assign(errorFromCode(resp.status, data == null ? void 0 : data.error), data);
4057
4300
  Object.defineProperty(error, "response", { value: resp, configurable: true });
4058
- if (!recoveryAttempt && resp.status < 500 && resp.status !== 401 && !this.isMysqlError(error))
4301
+ if (!recoveryAttempt && resp.status !== 401 && !this.isSqlStateError(error))
4059
4302
  this.setConnectionStatus("online");
4060
4303
  throw error;
4061
4304
  }
@@ -4063,17 +4306,22 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4063
4306
  if (!recoveryAttempt) this.setConnectionStatus("online");
4064
4307
  return data;
4065
4308
  }
4066
- /** Only server-response failures own global recovery; auth and ordinary 4xx errors do not. */
4309
+ /**
4310
+ * Only failures which indicate Datalynk as a service is unavailable own global
4311
+ * recovery. A request-specific application failure must not take the whole
4312
+ * client offline: another unrelated API request may still be perfectly healthy.
4313
+ */
4067
4314
  isRecoverableResponseError(error) {
4068
4315
  var _a;
4069
- if (error instanceof UnexpectedApiResponseError) return true;
4070
4316
  if ((error == null ? void 0 : error.code) === 401) return false;
4071
4317
  const status = ((_a = error == null ? void 0 : error.response) == null ? void 0 : _a.status) ?? (error == null ? void 0 : error.code);
4072
- return status >= 500 || this.isMysqlError(error);
4318
+ if (this.isGatewayUnavailableStatus(status)) return true;
4319
+ return this.isSqlStateError(error);
4073
4320
  }
4074
- isMysqlError(error) {
4075
- const message = String((error == null ? void 0 : error.error) ?? (error == null ? void 0 : error.message) ?? "");
4076
- return /\bSQLSTATE\[[A-Z0-9]+\]/i.test(message);
4321
+ /** Any API error returned in PDO SQLSTATE form uses the offline/recovery path. */
4322
+ isSqlStateError(error) {
4323
+ const message = String((error == null ? void 0 : error.error) ?? (error == null ? void 0 : error.message) ?? "").trim();
4324
+ return /^SQLSTATE\[/i.test(message);
4077
4325
  }
4078
4326
  /**
4079
4327
  * A returned server failure immediately makes the client unavailable. The exact
@@ -4166,8 +4414,22 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4166
4414
  this.setConnectionStatus("unauthorized");
4167
4415
  return;
4168
4416
  }
4169
- if (this.token && this.isTokenExpired(this.token)) {
4170
- this.markUnauthorized(this.token);
4417
+ if (this.offlineAuthenticationExpired) {
4418
+ const controller2 = new AbortController();
4419
+ const timeout2 = setTimeout(() => controller2.abort(), this.heartbeat.timeout);
4420
+ try {
4421
+ const response = await fetch(this.url + this.heartbeat.target, { signal: controller2.signal });
4422
+ if (this.isGatewayUnavailableStatus(response.status)) this.setConnectionStatus("unavailable");
4423
+ else this.markUnauthorized(this.token, true);
4424
+ } catch {
4425
+ this.setConnectionStatus("offline");
4426
+ } finally {
4427
+ clearTimeout(timeout2);
4428
+ }
4429
+ return;
4430
+ }
4431
+ if (this.options.manageSession !== false && this.token && this.isTokenExpired(this.token)) {
4432
+ this.expireToken(this.token);
4171
4433
  return;
4172
4434
  }
4173
4435
  const controller = new AbortController();
@@ -4175,13 +4437,16 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4175
4437
  try {
4176
4438
  const response = await fetch(this.url + this.heartbeat.target, { signal: controller.signal });
4177
4439
  if (this.recovery) return;
4178
- this.setConnectionStatus(response.ok ? "online" : "unavailable");
4440
+ this.setConnectionStatus(this.isGatewayUnavailableStatus(response.status) ? "unavailable" : "online");
4179
4441
  } catch (error) {
4180
4442
  if (!this.recovery) this.setConnectionStatus("offline");
4181
4443
  } finally {
4182
4444
  clearTimeout(timeout);
4183
4445
  }
4184
4446
  }
4447
+ isGatewayUnavailableStatus(status) {
4448
+ return status === 502 || status === 503 || status === 504;
4449
+ }
4185
4450
  isTokenExpired(token) {
4186
4451
  var _a;
4187
4452
  try {
@@ -4231,45 +4496,242 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4231
4496
  try {
4232
4497
  expiresAt = (((_a = decodeJwt(token)) == null ? void 0 : _a.exp) ?? 0) * 1e3;
4233
4498
  } catch {
4234
- if (!this.adoptStoredToken(token)) this.markUnauthorized(token);
4499
+ if (!this.adoptStoredToken(token)) this.expireToken(token);
4235
4500
  return;
4236
4501
  }
4237
4502
  const delay = expiresAt - Date.now();
4238
4503
  if (delay <= 0) {
4239
- if (!this.adoptStoredToken(token)) this.markUnauthorized(token);
4504
+ if (!this.adoptStoredToken(token)) this.expireToken(token);
4240
4505
  return;
4241
4506
  }
4242
4507
  this.tokenExpiryTimeout = setTimeout(() => {
4243
4508
  if (delay > 2147e6) this.scheduleTokenExpiry(token);
4244
- else if (!this.adoptStoredToken(token)) this.markUnauthorized(token);
4509
+ else if (!this.adoptStoredToken(token)) this.expireToken(token);
4245
4510
  }, Math.min(delay, 2147e6));
4246
4511
  (_c = (_b = this.tokenExpiryTimeout) == null ? void 0 : _b.unref) == null ? void 0 : _c.call(_b);
4247
4512
  }
4248
- markUnauthorized(token) {
4513
+ /** Handle local JWT expiry without treating an offline device like a rejected online session. */
4514
+ expireToken(token) {
4515
+ if (token !== this.token) return;
4516
+ if (this.shouldDeferLocalExpiry()) {
4517
+ this.deferOfflineExpiry(token);
4518
+ return;
4519
+ }
4520
+ this.markUnauthorized(token, this.hasOwnedOfflineSession(token));
4521
+ }
4522
+ shouldDeferLocalExpiry() {
4523
+ if (this.authenticationInvalid) return false;
4524
+ if (typeof navigator != "undefined" && typeof navigator.onLine != "undefined" && !navigator.onLine) return true;
4525
+ return this.status === "offline" || this.status === "unavailable";
4526
+ }
4527
+ deferOfflineExpiry(token) {
4528
+ this.authenticationInvalid = false;
4529
+ this.offlineAuthenticationExpired = true;
4530
+ this.cancelRecovery(errorFromCode(401, "Session expired while offline"));
4531
+ this.rememberOfflineSessionOwner(token);
4532
+ if (this.token !== token) this.token$.next(token);
4533
+ if (this.status === "online") this.setConnectionStatus("offline");
4534
+ }
4535
+ markUnauthorized(token, preservePending = this.hasOwnedOfflineSession(token || this.token)) {
4249
4536
  if (token && token !== this.token) return;
4250
4537
  if (this.adoptStoredToken(token)) return;
4538
+ if (this.authenticationInvalid && this.unauthorizedLogout) return;
4251
4539
  this.authenticationInvalid = true;
4540
+ this.offlineAuthenticationExpired = false;
4252
4541
  this.cancelRecovery(errorFromCode(401, "Session expired"));
4253
- if (this.token != null) this.token$.next(null);
4542
+ if (this.token !== null) this.token$.next(null);
4254
4543
  this.setConnectionStatus("unauthorized");
4544
+ this.unauthorizedLogout ?? (this.unauthorizedLogout = Promise.resolve().then(() => this.finishUnauthorizedLogout(preservePending)).finally(() => {
4545
+ this.unauthorizedLogout = null;
4546
+ }));
4547
+ }
4548
+ async finishUnauthorizedLogout(preservePending = false) {
4549
+ var _a;
4550
+ if (this.options.manageSession === false) return;
4551
+ if (typeof localStorage != "undefined") {
4552
+ localStorage.removeItem(this.localStorageKey);
4553
+ localStorage.removeItem("datalynk-user");
4554
+ if (!preservePending) localStorage.removeItem(this.offlineSessionOwnerKey);
4555
+ }
4556
+ if (!preservePending) this.offlineSessionOwner = null;
4557
+ for (const slice of this.sliceCache.values()) {
4558
+ try {
4559
+ slice.sync(false);
4560
+ } catch {
4561
+ }
4562
+ }
4563
+ await this.clearOfflineSessionCache(preservePending);
4564
+ if (typeof caches != "undefined") {
4565
+ try {
4566
+ await Promise.all([caches.delete("datalynk"), caches.delete("api-settings")]);
4567
+ } catch (error) {
4568
+ console.error("Unable to clear Datalynk service worker cache", error);
4569
+ }
4570
+ }
4571
+ if (typeof navigator != "undefined" && ((_a = navigator.serviceWorker) == null ? void 0 : _a.controller)) {
4572
+ navigator.serviceWorker.controller.postMessage({ token: null, clearCache: true });
4573
+ }
4574
+ if (typeof location != "undefined" && typeof location.reload == "function")
4575
+ location.reload();
4576
+ }
4577
+ async clearOfflineSessionCache(preservePending = false) {
4578
+ if (typeof indexedDB == "undefined") return;
4579
+ try {
4580
+ if (this.database) {
4581
+ const db = await this.database.connection;
4582
+ const stores = Array.from(db.objectStoreNames).filter((name) => !(preservePending && name === "pending"));
4583
+ if (!stores.length) return;
4584
+ await new Promise((resolve, reject) => {
4585
+ const tx = db.transaction(stores, "readwrite");
4586
+ stores.forEach((name) => tx.objectStore(name).clear());
4587
+ tx.oncomplete = () => resolve();
4588
+ tx.onerror = () => reject(tx.error);
4589
+ tx.onabort = () => reject(tx.error);
4590
+ });
4591
+ return;
4592
+ }
4593
+ await new Promise((resolve, reject) => {
4594
+ const request = indexedDB.open("datalynk");
4595
+ request.onerror = () => reject(request.error);
4596
+ request.onsuccess = () => {
4597
+ const db = request.result;
4598
+ const stores = Array.from(db.objectStoreNames).filter((name) => !(preservePending && name === "pending"));
4599
+ if (!stores.length) {
4600
+ db.close();
4601
+ resolve();
4602
+ return;
4603
+ }
4604
+ const tx = db.transaction(stores, "readwrite");
4605
+ stores.forEach((name) => tx.objectStore(name).clear());
4606
+ tx.oncomplete = () => {
4607
+ db.close();
4608
+ resolve();
4609
+ };
4610
+ tx.onerror = () => {
4611
+ const error = tx.error;
4612
+ db.close();
4613
+ reject(error);
4614
+ };
4615
+ tx.onabort = () => {
4616
+ const error = tx.error;
4617
+ db.close();
4618
+ reject(error);
4619
+ };
4620
+ };
4621
+ });
4622
+ } catch (error) {
4623
+ console.error("Unable to clear Datalynk offline session cache", error);
4624
+ }
4625
+ }
4626
+ tokenIdentity(token) {
4627
+ if (!token) return null;
4628
+ try {
4629
+ const payload = decodeJwt(token);
4630
+ const user = (payload == null ? void 0 : payload.uid) ?? (payload == null ? void 0 : payload.sub);
4631
+ return {
4632
+ realm: (payload == null ? void 0 : payload.realm) == null ? void 0 : String(payload.realm),
4633
+ user: user == null ? void 0 : String(user)
4634
+ };
4635
+ } catch {
4636
+ return null;
4637
+ }
4638
+ }
4639
+ readOfflineSessionOwner() {
4640
+ if (this.offlineSessionOwner) return this.offlineSessionOwner;
4641
+ if (typeof localStorage == "undefined") return null;
4642
+ try {
4643
+ const value = localStorage.getItem(this.offlineSessionOwnerKey);
4644
+ if (!value) return null;
4645
+ const owner = JSON.parse(value);
4646
+ if (!owner || typeof owner !== "object") return null;
4647
+ return this.offlineSessionOwner = owner;
4648
+ } catch {
4649
+ return null;
4650
+ }
4651
+ }
4652
+ ownersMatch(owner, identity2) {
4653
+ if (!identity2) return false;
4654
+ if (owner.realm != null && owner.realm !== identity2.realm) return false;
4655
+ if (owner.user != null && owner.user !== identity2.user) return false;
4656
+ return true;
4657
+ }
4658
+ rememberOfflineSessionOwner(token) {
4659
+ const identity2 = this.tokenIdentity(token);
4660
+ if (!identity2) return;
4661
+ const existing = this.readOfflineSessionOwner();
4662
+ if (existing && !this.ownersMatch(existing, identity2)) return;
4663
+ this.offlineSessionOwner = identity2;
4664
+ if (typeof localStorage != "undefined") {
4665
+ try {
4666
+ localStorage.setItem(this.offlineSessionOwnerKey, JSON.stringify(identity2));
4667
+ } catch {
4668
+ }
4669
+ }
4670
+ }
4671
+ ensureOfflineSessionOwner(token) {
4672
+ const existing = this.readOfflineSessionOwner();
4673
+ const identity2 = this.tokenIdentity(token);
4674
+ if (existing) return this.ownersMatch(existing, identity2);
4675
+ if (!identity2) return true;
4676
+ this.rememberOfflineSessionOwner(token);
4677
+ return true;
4678
+ }
4679
+ hasOwnedOfflineSession(token) {
4680
+ const owner = this.readOfflineSessionOwner();
4681
+ return !!owner && this.ownersMatch(owner, this.tokenIdentity(token));
4682
+ }
4683
+ clearOfflineSessionOwner() {
4684
+ this.offlineSessionOwner = null;
4685
+ if (typeof localStorage != "undefined") {
4686
+ try {
4687
+ localStorage.removeItem(this.offlineSessionOwnerKey);
4688
+ } catch {
4689
+ }
4690
+ }
4691
+ }
4692
+ /** Replay queued writes only after a matching authenticated session is online. */
4693
+ async replayPendingQueue() {
4694
+ if (!this.database || !this.online || this.authenticationInvalid || this.offlineAuthenticationExpired) return;
4695
+ if (this.replayingPending) {
4696
+ this.pendingReplayRequested = true;
4697
+ return;
4698
+ }
4699
+ const table = this.database.table("pending");
4700
+ const owner = this.readOfflineSessionOwner();
4701
+ if (owner && !this.ownersMatch(owner, this.tokenIdentity(this.token))) {
4702
+ if (this.token) console.warn("Datalynk offline queue is quarantined because it belongs to another authenticated user");
4703
+ return;
4704
+ }
4705
+ this.replayingPending = true;
4706
+ try {
4707
+ const keys = await table.getAllKeys();
4708
+ for (const key of keys) {
4709
+ if (!this.online || this.authenticationInvalid || this.offlineAuthenticationExpired) break;
4710
+ if (owner && !this.ownersMatch(owner, this.tokenIdentity(this.token))) break;
4711
+ const request = await table.get(key);
4712
+ if (request == null) continue;
4713
+ try {
4714
+ await this.request(request);
4715
+ await table.delete(key);
4716
+ } catch (error) {
4717
+ break;
4718
+ }
4719
+ }
4720
+ if (await table.count() === 0) this.clearOfflineSessionOwner();
4721
+ } finally {
4722
+ this.replayingPending = false;
4723
+ if (this.pendingReplayRequested) {
4724
+ this.pendingReplayRequested = false;
4725
+ if (this.online && !this.authenticationInvalid && !this.offlineAuthenticationExpired)
4726
+ void this.replayPendingQueue();
4727
+ }
4728
+ }
4255
4729
  }
4256
4730
  setConnectionStatus(status) {
4257
4731
  if (this.status !== status) this.status$.next(status);
4258
4732
  const online = status === "online";
4259
4733
  if (this.online !== online) this.online$.next(online);
4260
4734
  }
4261
- offlineBanner() {
4262
- if (this.options.offlineBanner === false || typeof document == "undefined") return;
4263
- if (this.status === "online") {
4264
- removeBanner("datalynk-offline-banner");
4265
- } else {
4266
- const message = this.status === "unauthorized" ? "⚠️ Your session expired, please sign in again" : this.status === "unavailable" ? "⚠️ Datalynk is currently unavailable" : "⚠️ You are offline, please reconnect to sync changes";
4267
- createBanner(message, {
4268
- id: "datalynk-offline-banner",
4269
- position: this.options.offlineBanner === "top" ? "top" : "bottom"
4270
- });
4271
- }
4272
- }
4273
4735
  startHeartbeat() {
4274
4736
  this.stopHeartbeat();
4275
4737
  this.heartbeat.interval = setInterval(() => this.checkConnection(), this.heartbeat.timeout / 2);
@@ -4379,6 +4841,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4379
4841
  if (this.offline && typeof navigator != "undefined") {
4380
4842
  if (this.status === "unauthorized") return Promise.reject(errorFromCode(401, "Session expired"));
4381
4843
  if (options.offline) {
4844
+ if (!this.ensureOfflineSessionOwner(this.token))
4845
+ return Promise.reject(errorFromCode(409, "Offline queue belongs to another authenticated user"));
4382
4846
  (_b = (_a = this.database) == null ? void 0 : _a.table("pending")) == null ? void 0 : _b.add(data, key);
4383
4847
  return Promise.resolve();
4384
4848
  }