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