@auxilium/datalynk-client 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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
  }
@@ -3225,7 +3230,7 @@ class Superuser {
3225
3230
  } });
3226
3231
  }
3227
3232
  }
3228
- const version = "1.5.0";
3233
+ const version = "1.5.2";
3229
3234
  class WebRtc {
3230
3235
  constructor(api) {
3231
3236
  __publicField(this, "ice");
@@ -3772,7 +3777,14 @@ const _Api = class _Api {
3772
3777
  timeout: 6e4
3773
3778
  });
3774
3779
  __publicField(this, "authenticationInvalid", false);
3780
+ /** Token reached exp while the client could not reach Datalynk; keep the offline session alive until reconnect. */
3781
+ __publicField(this, "offlineAuthenticationExpired", false);
3775
3782
  __publicField(this, "tokenExpiryTimeout", null);
3783
+ __publicField(this, "unauthorizedLogout", null);
3784
+ __publicField(this, "replayingPending", false);
3785
+ /** If replay is requested while a replay pass is already active, run another pass when it finishes. */
3786
+ __publicField(this, "pendingReplayRequested", false);
3787
+ __publicField(this, "offlineSessionOwner", null);
3776
3788
  /** Request-specific recovery after a server response proves an API call is broken. */
3777
3789
  __publicField(this, "recovery", null);
3778
3790
  /** Retry the failed request twice immediately, then this long after each failed recovery response. */
@@ -3780,6 +3792,9 @@ const _Api = class _Api {
3780
3792
  __publicField(this, "recoveryImmediateRetries", 2);
3781
3793
  /** LocalStorage key for persisting logins */
3782
3794
  __publicField(this, "localStorageKey", "datalynk-token");
3795
+ /** Persisted owner of an offline queue so another login cannot inherit queued writes. */
3796
+ __publicField(this, "offlineSessionOwnerKey", "datalynk-offline-owner");
3797
+ __publicField(this, "tokenStorageListener", null);
3783
3798
  /** Pending requests cache */
3784
3799
  __publicField(this, "pending", {});
3785
3800
  /** Helpers */
@@ -3829,12 +3844,15 @@ const _Api = class _Api {
3829
3844
  var _a, _b;
3830
3845
  this.origin = origin;
3831
3846
  this.url = `${new URL(origin).origin}/api/`;
3847
+ const development = this.isDevelopmentEnvironment(origin);
3832
3848
  this.options = {
3833
3849
  manifest: {},
3834
3850
  name: typeof document != "undefined" ? document.title : "Datalynk",
3835
3851
  offline: [],
3836
3852
  origin: typeof location !== "undefined" ? location.host : "Unknown",
3837
3853
  saveSession: true,
3854
+ replayOfflineQueue: true,
3855
+ watchTokenExpiry: true,
3838
3856
  serviceWorker: "/service.worker.mjs",
3839
3857
  ...options,
3840
3858
  webrtc: {
@@ -3844,14 +3862,28 @@ const _Api = class _Api {
3844
3862
  ...options.webrtc || {}
3845
3863
  }
3846
3864
  };
3865
+ if (development) this.options.watchTokenExpiry = false;
3847
3866
  if (this.options.saveSession && typeof localStorage != "undefined") {
3848
3867
  this.token = localStorage.getItem(this.localStorageKey) || null;
3849
3868
  this.token$.pipe(distinctUntilChanged()).subscribe((token) => {
3850
3869
  if (token) localStorage.setItem(this.localStorageKey, token);
3851
3870
  else localStorage.removeItem(this.localStorageKey);
3852
3871
  });
3872
+ if (typeof window != "undefined") {
3873
+ this.tokenStorageListener = (event) => {
3874
+ if (event.storageArea === localStorage && event.key == null) {
3875
+ this.token = null;
3876
+ return;
3877
+ }
3878
+ if (event.key !== this.localStorageKey || event.newValue === this.token) return;
3879
+ if (event.newValue && this.canAdoptToken(event.newValue, this.token)) this.token = event.newValue;
3880
+ else if (event.newValue == null) this.token = null;
3881
+ };
3882
+ window.addEventListener("storage", this.tokenStorageListener);
3883
+ }
3853
3884
  }
3854
- this.token$.pipe(distinctUntilChanged()).subscribe((token) => this.scheduleTokenExpiry(token));
3885
+ if (this.options.watchTokenExpiry)
3886
+ this.token$.pipe(distinctUntilChanged()).subscribe((token) => this.scheduleTokenExpiry(token));
3855
3887
  this.socket = new Socket(this, { url: options.socket });
3856
3888
  this.gps = new Gps(this, options.gps);
3857
3889
  this.auth = new Auth(this);
@@ -3862,21 +3894,13 @@ const _Api = class _Api {
3862
3894
  this.webrtc = new WebRtc(this);
3863
3895
  if (typeof indexedDB != "undefined") {
3864
3896
  this.database = new Database("datalynk", ["pending", ...this.options.offline || []]);
3865
- this.online$.subscribe(async (online) => {
3866
- var _a2;
3867
- if (!online) return;
3868
- const table = (_a2 = this.database) == null ? void 0 : _a2.table("pending");
3869
- const keys = await (table == null ? void 0 : table.getAllKeys());
3870
- await Promise.allSettled(keys.map(async (k) => {
3871
- const r = await (table == null ? void 0 : table.get(k));
3872
- await this.request(r).then(() => table == null ? void 0 : table.delete(k));
3873
- }));
3897
+ if (this.options.replayOfflineQueue !== false) this.online$.subscribe((online) => {
3898
+ if (online) void this.replayPendingQueue();
3874
3899
  });
3875
3900
  }
3876
3901
  if (typeof window !== "undefined") {
3877
3902
  window.addEventListener("online", () => this.checkConnection());
3878
3903
  window.addEventListener("offline", () => this.setConnectionStatus("offline"));
3879
- this.online$.subscribe(() => this.offlineBanner());
3880
3904
  this.startHeartbeat();
3881
3905
  }
3882
3906
  if ((_a = this.options.offline) == null ? void 0 : _a.length) {
@@ -3907,6 +3931,10 @@ const _Api = class _Api {
3907
3931
  var _a;
3908
3932
  return (((_a = this.jwtPayload) == null ? void 0 : _a.exp) ?? Infinity) * 1e3 <= Date.now();
3909
3933
  }
3934
+ /** Whether authentication expired while offline and must be renewed before replaying queued work. */
3935
+ get reauthenticationPending() {
3936
+ return this.offlineAuthenticationExpired;
3937
+ }
3910
3938
  /** Get session info from JWT payload */
3911
3939
  get jwtPayload() {
3912
3940
  if (!this.token) return null;
@@ -3965,19 +3993,26 @@ const _Api = class _Api {
3965
3993
  return this.token$.getValue();
3966
3994
  }
3967
3995
  set token(token) {
3996
+ var _a;
3968
3997
  if (this.recovery && token !== this.recovery.token)
3969
3998
  this.cancelRecovery(errorFromCode(401, "Session changed during API recovery"));
3970
3999
  if (token && this.isTokenExpired(token)) {
3971
- this.authenticationInvalid = true;
3972
- this.token$.next(null);
3973
- this.setConnectionStatus("unauthorized");
4000
+ if (this.adoptStoredToken(token)) return;
4001
+ if (this.shouldDeferLocalExpiry()) {
4002
+ this.deferOfflineExpiry(token);
4003
+ return;
4004
+ }
4005
+ this.markUnauthorized(void 0, this.hasOwnedOfflineSession(token));
3974
4006
  return;
3975
4007
  }
3976
4008
  this.authenticationInvalid = false;
4009
+ this.offlineAuthenticationExpired = false;
3977
4010
  this.token$.next(token);
3978
4011
  if (token && !this.online && !this.recovery) {
3979
- this.setConnectionStatus(typeof navigator == "undefined" || navigator.onLine ? "online" : "offline");
4012
+ const browserOnline = typeof navigator == "undefined" || typeof navigator.onLine == "undefined" || navigator.onLine;
4013
+ this.setConnectionStatus(browserOnline ? "online" : "offline");
3980
4014
  }
4015
+ if (token && ((_a = this.options) == null ? void 0 : _a.replayOfflineQueue) !== false) void this.replayPendingQueue();
3981
4016
  }
3982
4017
  async _request(req, options = {}) {
3983
4018
  if (this.recovery) throw errorFromCode(503, "Datalynk is unavailable");
@@ -3996,7 +4031,7 @@ const _Api = class _Api {
3996
4031
  async _requestOnce(req, options = {}, recoveryAttempt = false) {
3997
4032
  const token = options.token || this.token;
3998
4033
  if (token && this.isTokenExpired(token)) {
3999
- this.markUnauthorized(token);
4034
+ if (token === this.token) this.expireToken(token);
4000
4035
  throw errorFromCode(401, "Session token expired");
4001
4036
  }
4002
4037
  let resp;
@@ -4032,7 +4067,7 @@ const _Api = class _Api {
4032
4067
  if (!resp.ok || (data == null ? void 0 : data.error)) {
4033
4068
  const error = Object.assign(errorFromCode(resp.status, data == null ? void 0 : data.error), data);
4034
4069
  Object.defineProperty(error, "response", { value: resp, configurable: true });
4035
- if (!recoveryAttempt && resp.status < 500 && resp.status !== 401 && !this.isMysqlError(error))
4070
+ if (!recoveryAttempt && resp.status !== 401 && !this.isSqlStateError(error))
4036
4071
  this.setConnectionStatus("online");
4037
4072
  throw error;
4038
4073
  }
@@ -4040,17 +4075,22 @@ const _Api = class _Api {
4040
4075
  if (!recoveryAttempt) this.setConnectionStatus("online");
4041
4076
  return data;
4042
4077
  }
4043
- /** Only server-response failures own global recovery; auth and ordinary 4xx errors do not. */
4078
+ /**
4079
+ * Only failures which indicate Datalynk as a service is unavailable own global
4080
+ * recovery. A request-specific application failure must not take the whole
4081
+ * client offline: another unrelated API request may still be perfectly healthy.
4082
+ */
4044
4083
  isRecoverableResponseError(error) {
4045
4084
  var _a;
4046
- if (error instanceof UnexpectedApiResponseError) return true;
4047
4085
  if ((error == null ? void 0 : error.code) === 401) return false;
4048
4086
  const status = ((_a = error == null ? void 0 : error.response) == null ? void 0 : _a.status) ?? (error == null ? void 0 : error.code);
4049
- return status >= 500 || this.isMysqlError(error);
4087
+ if (this.isGatewayUnavailableStatus(status)) return true;
4088
+ return this.isSqlStateError(error);
4050
4089
  }
4051
- isMysqlError(error) {
4052
- const message = String((error == null ? void 0 : error.error) ?? (error == null ? void 0 : error.message) ?? "");
4053
- return /\bSQLSTATE\[[A-Z0-9]+\]/i.test(message);
4090
+ /** Any API error returned in PDO SQLSTATE form uses the offline/recovery path. */
4091
+ isSqlStateError(error) {
4092
+ const message = String((error == null ? void 0 : error.error) ?? (error == null ? void 0 : error.message) ?? "").trim();
4093
+ return /^SQLSTATE\[/i.test(message);
4054
4094
  }
4055
4095
  /**
4056
4096
  * A returned server failure immediately makes the client unavailable. The exact
@@ -4143,8 +4183,22 @@ const _Api = class _Api {
4143
4183
  this.setConnectionStatus("unauthorized");
4144
4184
  return;
4145
4185
  }
4186
+ if (this.offlineAuthenticationExpired) {
4187
+ const controller2 = new AbortController();
4188
+ const timeout2 = setTimeout(() => controller2.abort(), this.heartbeat.timeout);
4189
+ try {
4190
+ const response = await fetch(this.url + this.heartbeat.target, { signal: controller2.signal });
4191
+ if (this.isGatewayUnavailableStatus(response.status)) this.setConnectionStatus("unavailable");
4192
+ else this.markUnauthorized(this.token, true);
4193
+ } catch {
4194
+ this.setConnectionStatus("offline");
4195
+ } finally {
4196
+ clearTimeout(timeout2);
4197
+ }
4198
+ return;
4199
+ }
4146
4200
  if (this.token && this.isTokenExpired(this.token)) {
4147
- this.markUnauthorized(this.token);
4201
+ this.expireToken(this.token);
4148
4202
  return;
4149
4203
  }
4150
4204
  const controller = new AbortController();
@@ -4152,13 +4206,16 @@ const _Api = class _Api {
4152
4206
  try {
4153
4207
  const response = await fetch(this.url + this.heartbeat.target, { signal: controller.signal });
4154
4208
  if (this.recovery) return;
4155
- this.setConnectionStatus(response.ok ? "online" : "unavailable");
4209
+ this.setConnectionStatus(this.isGatewayUnavailableStatus(response.status) ? "unavailable" : "online");
4156
4210
  } catch (error) {
4157
4211
  if (!this.recovery) this.setConnectionStatus("offline");
4158
4212
  } finally {
4159
4213
  clearTimeout(timeout);
4160
4214
  }
4161
4215
  }
4216
+ isGatewayUnavailableStatus(status) {
4217
+ return status === 502 || status === 503 || status === 504;
4218
+ }
4162
4219
  isTokenExpired(token) {
4163
4220
  var _a;
4164
4221
  try {
@@ -4167,6 +4224,38 @@ const _Api = class _Api {
4167
4224
  return true;
4168
4225
  }
4169
4226
  }
4227
+ isDevelopmentEnvironment(origin) {
4228
+ var _a;
4229
+ const isDevHost = (hostname) => {
4230
+ hostname = hostname.toLowerCase();
4231
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname.endsWith(".datalynk") || hostname.endsWith(".test");
4232
+ };
4233
+ const configuredDev = typeof window != "undefined" && ((_a = window.dojoConfig) == null ? void 0 : _a.isDev) === true;
4234
+ const pageHost = typeof location == "undefined" ? "" : location.hostname;
4235
+ return configuredDev || isDevHost(new URL(origin).hostname) || !!pageHost && isDevHost(pageHost);
4236
+ }
4237
+ canAdoptToken(candidate, rejected) {
4238
+ if (!candidate || candidate === rejected || this.isTokenExpired(candidate)) return false;
4239
+ try {
4240
+ const next = decodeJwt(candidate);
4241
+ const previous = rejected ? decodeJwt(rejected) : null;
4242
+ if ((previous == null ? void 0 : previous.realm) && (next == null ? void 0 : next.realm) && previous.realm !== next.realm) return false;
4243
+ const previousUser = (previous == null ? void 0 : previous.uid) ?? (previous == null ? void 0 : previous.sub);
4244
+ const nextUser = (next == null ? void 0 : next.uid) ?? (next == null ? void 0 : next.sub);
4245
+ return previousUser == null || String(previousUser) === String(nextUser);
4246
+ } catch {
4247
+ return false;
4248
+ }
4249
+ }
4250
+ /** Adopt a newer canonical token written by another auth path before expiring this session. */
4251
+ adoptStoredToken(rejected) {
4252
+ var _a;
4253
+ if (!((_a = this.options) == null ? void 0 : _a.saveSession) || typeof localStorage == "undefined") return false;
4254
+ const stored = localStorage.getItem(this.localStorageKey);
4255
+ if (!stored || !this.canAdoptToken(stored, rejected)) return false;
4256
+ this.token = stored;
4257
+ return true;
4258
+ }
4170
4259
  scheduleTokenExpiry(token) {
4171
4260
  var _a, _b, _c;
4172
4261
  if (this.tokenExpiryTimeout) clearTimeout(this.tokenExpiryTimeout);
@@ -4176,40 +4265,241 @@ const _Api = class _Api {
4176
4265
  try {
4177
4266
  expiresAt = (((_a = decodeJwt(token)) == null ? void 0 : _a.exp) ?? 0) * 1e3;
4178
4267
  } catch {
4179
- return this.markUnauthorized(token);
4268
+ if (!this.adoptStoredToken(token)) this.expireToken(token);
4269
+ return;
4180
4270
  }
4181
4271
  const delay = expiresAt - Date.now();
4182
- if (delay <= 0) return this.markUnauthorized(token);
4272
+ if (delay <= 0) {
4273
+ if (!this.adoptStoredToken(token)) this.expireToken(token);
4274
+ return;
4275
+ }
4183
4276
  this.tokenExpiryTimeout = setTimeout(() => {
4184
4277
  if (delay > 2147e6) this.scheduleTokenExpiry(token);
4185
- else this.markUnauthorized(token);
4278
+ else if (!this.adoptStoredToken(token)) this.expireToken(token);
4186
4279
  }, Math.min(delay, 2147e6));
4187
4280
  (_c = (_b = this.tokenExpiryTimeout) == null ? void 0 : _b.unref) == null ? void 0 : _c.call(_b);
4188
4281
  }
4189
- markUnauthorized(token) {
4282
+ /** Handle local JWT expiry without treating an offline device like a rejected online session. */
4283
+ expireToken(token) {
4284
+ if (token !== this.token) return;
4285
+ if (this.shouldDeferLocalExpiry()) {
4286
+ this.deferOfflineExpiry(token);
4287
+ return;
4288
+ }
4289
+ this.markUnauthorized(token, this.hasOwnedOfflineSession(token));
4290
+ }
4291
+ shouldDeferLocalExpiry() {
4292
+ if (this.authenticationInvalid) return false;
4293
+ if (typeof navigator != "undefined" && typeof navigator.onLine != "undefined" && !navigator.onLine) return true;
4294
+ return this.status === "offline" || this.status === "unavailable";
4295
+ }
4296
+ deferOfflineExpiry(token) {
4297
+ this.authenticationInvalid = false;
4298
+ this.offlineAuthenticationExpired = true;
4299
+ this.cancelRecovery(errorFromCode(401, "Session expired while offline"));
4300
+ this.rememberOfflineSessionOwner(token);
4301
+ if (this.token !== token) this.token$.next(token);
4302
+ if (this.status === "online") this.setConnectionStatus("offline");
4303
+ }
4304
+ markUnauthorized(token, preservePending = this.hasOwnedOfflineSession(token || this.token)) {
4190
4305
  if (token && token !== this.token) return;
4306
+ if (this.adoptStoredToken(token)) return;
4307
+ if (this.authenticationInvalid && this.unauthorizedLogout) return;
4191
4308
  this.authenticationInvalid = true;
4309
+ this.offlineAuthenticationExpired = false;
4192
4310
  this.cancelRecovery(errorFromCode(401, "Session expired"));
4193
- if (this.token != null) this.token$.next(null);
4311
+ if (this.token !== null) this.token$.next(null);
4194
4312
  this.setConnectionStatus("unauthorized");
4313
+ this.unauthorizedLogout ?? (this.unauthorizedLogout = Promise.resolve().then(() => this.finishUnauthorizedLogout(preservePending)).finally(() => {
4314
+ this.unauthorizedLogout = null;
4315
+ }));
4316
+ }
4317
+ async finishUnauthorizedLogout(preservePending = false) {
4318
+ var _a;
4319
+ if (typeof localStorage != "undefined") {
4320
+ localStorage.removeItem(this.localStorageKey);
4321
+ localStorage.removeItem("datalynk-user");
4322
+ if (!preservePending) localStorage.removeItem(this.offlineSessionOwnerKey);
4323
+ }
4324
+ if (!preservePending) this.offlineSessionOwner = null;
4325
+ for (const slice of this.sliceCache.values()) {
4326
+ try {
4327
+ slice.sync(false);
4328
+ } catch {
4329
+ }
4330
+ }
4331
+ await this.clearOfflineSessionCache(preservePending);
4332
+ if (typeof caches != "undefined") {
4333
+ try {
4334
+ await Promise.all([caches.delete("datalynk"), caches.delete("api-settings")]);
4335
+ } catch (error) {
4336
+ console.error("Unable to clear Datalynk service worker cache", error);
4337
+ }
4338
+ }
4339
+ if (typeof navigator != "undefined" && ((_a = navigator.serviceWorker) == null ? void 0 : _a.controller)) {
4340
+ navigator.serviceWorker.controller.postMessage({ token: null, clearCache: true });
4341
+ }
4342
+ if (typeof location != "undefined" && typeof location.reload == "function")
4343
+ location.reload();
4344
+ }
4345
+ async clearOfflineSessionCache(preservePending = false) {
4346
+ if (typeof indexedDB == "undefined") return;
4347
+ try {
4348
+ if (this.database) {
4349
+ const db = await this.database.connection;
4350
+ const stores = Array.from(db.objectStoreNames).filter((name) => !(preservePending && name === "pending"));
4351
+ if (!stores.length) return;
4352
+ await new Promise((resolve, reject) => {
4353
+ const tx = db.transaction(stores, "readwrite");
4354
+ stores.forEach((name) => tx.objectStore(name).clear());
4355
+ tx.oncomplete = () => resolve();
4356
+ tx.onerror = () => reject(tx.error);
4357
+ tx.onabort = () => reject(tx.error);
4358
+ });
4359
+ return;
4360
+ }
4361
+ await new Promise((resolve, reject) => {
4362
+ const request = indexedDB.open("datalynk");
4363
+ request.onerror = () => reject(request.error);
4364
+ request.onsuccess = () => {
4365
+ const db = request.result;
4366
+ const stores = Array.from(db.objectStoreNames).filter((name) => !(preservePending && name === "pending"));
4367
+ if (!stores.length) {
4368
+ db.close();
4369
+ resolve();
4370
+ return;
4371
+ }
4372
+ const tx = db.transaction(stores, "readwrite");
4373
+ stores.forEach((name) => tx.objectStore(name).clear());
4374
+ tx.oncomplete = () => {
4375
+ db.close();
4376
+ resolve();
4377
+ };
4378
+ tx.onerror = () => {
4379
+ const error = tx.error;
4380
+ db.close();
4381
+ reject(error);
4382
+ };
4383
+ tx.onabort = () => {
4384
+ const error = tx.error;
4385
+ db.close();
4386
+ reject(error);
4387
+ };
4388
+ };
4389
+ });
4390
+ } catch (error) {
4391
+ console.error("Unable to clear Datalynk offline session cache", error);
4392
+ }
4393
+ }
4394
+ tokenIdentity(token) {
4395
+ if (!token) return null;
4396
+ try {
4397
+ const payload = decodeJwt(token);
4398
+ const user = (payload == null ? void 0 : payload.uid) ?? (payload == null ? void 0 : payload.sub);
4399
+ return {
4400
+ realm: (payload == null ? void 0 : payload.realm) == null ? void 0 : String(payload.realm),
4401
+ user: user == null ? void 0 : String(user)
4402
+ };
4403
+ } catch {
4404
+ return null;
4405
+ }
4406
+ }
4407
+ readOfflineSessionOwner() {
4408
+ if (this.offlineSessionOwner) return this.offlineSessionOwner;
4409
+ if (typeof localStorage == "undefined") return null;
4410
+ try {
4411
+ const value = localStorage.getItem(this.offlineSessionOwnerKey);
4412
+ if (!value) return null;
4413
+ const owner = JSON.parse(value);
4414
+ if (!owner || typeof owner !== "object") return null;
4415
+ return this.offlineSessionOwner = owner;
4416
+ } catch {
4417
+ return null;
4418
+ }
4419
+ }
4420
+ ownersMatch(owner, identity2) {
4421
+ if (!identity2) return false;
4422
+ if (owner.realm != null && owner.realm !== identity2.realm) return false;
4423
+ if (owner.user != null && owner.user !== identity2.user) return false;
4424
+ return true;
4425
+ }
4426
+ rememberOfflineSessionOwner(token) {
4427
+ const identity2 = this.tokenIdentity(token);
4428
+ if (!identity2) return;
4429
+ const existing = this.readOfflineSessionOwner();
4430
+ if (existing && !this.ownersMatch(existing, identity2)) return;
4431
+ this.offlineSessionOwner = identity2;
4432
+ if (typeof localStorage != "undefined") {
4433
+ try {
4434
+ localStorage.setItem(this.offlineSessionOwnerKey, JSON.stringify(identity2));
4435
+ } catch {
4436
+ }
4437
+ }
4438
+ }
4439
+ ensureOfflineSessionOwner(token) {
4440
+ const existing = this.readOfflineSessionOwner();
4441
+ const identity2 = this.tokenIdentity(token);
4442
+ if (existing) return this.ownersMatch(existing, identity2);
4443
+ if (!identity2) return true;
4444
+ this.rememberOfflineSessionOwner(token);
4445
+ return true;
4446
+ }
4447
+ hasOwnedOfflineSession(token) {
4448
+ const owner = this.readOfflineSessionOwner();
4449
+ return !!owner && this.ownersMatch(owner, this.tokenIdentity(token));
4450
+ }
4451
+ clearOfflineSessionOwner() {
4452
+ this.offlineSessionOwner = null;
4453
+ if (typeof localStorage != "undefined") {
4454
+ try {
4455
+ localStorage.removeItem(this.offlineSessionOwnerKey);
4456
+ } catch {
4457
+ }
4458
+ }
4459
+ }
4460
+ /** Replay queued writes only after a matching authenticated session is online. */
4461
+ async replayPendingQueue() {
4462
+ if (!this.database || !this.online || this.authenticationInvalid || this.offlineAuthenticationExpired) return;
4463
+ if (this.replayingPending) {
4464
+ this.pendingReplayRequested = true;
4465
+ return;
4466
+ }
4467
+ const table = this.database.table("pending");
4468
+ const owner = this.readOfflineSessionOwner();
4469
+ if (owner && !this.ownersMatch(owner, this.tokenIdentity(this.token))) {
4470
+ if (this.token) console.warn("Datalynk offline queue is quarantined because it belongs to another authenticated user");
4471
+ return;
4472
+ }
4473
+ this.replayingPending = true;
4474
+ try {
4475
+ const keys = await table.getAllKeys();
4476
+ for (const key of keys) {
4477
+ if (!this.online || this.authenticationInvalid || this.offlineAuthenticationExpired) break;
4478
+ if (owner && !this.ownersMatch(owner, this.tokenIdentity(this.token))) break;
4479
+ const request = await table.get(key);
4480
+ if (request == null) continue;
4481
+ try {
4482
+ await this.request(request);
4483
+ await table.delete(key);
4484
+ } catch (error) {
4485
+ break;
4486
+ }
4487
+ }
4488
+ if (await table.count() === 0) this.clearOfflineSessionOwner();
4489
+ } finally {
4490
+ this.replayingPending = false;
4491
+ if (this.pendingReplayRequested) {
4492
+ this.pendingReplayRequested = false;
4493
+ if (this.online && !this.authenticationInvalid && !this.offlineAuthenticationExpired)
4494
+ void this.replayPendingQueue();
4495
+ }
4496
+ }
4195
4497
  }
4196
4498
  setConnectionStatus(status) {
4197
4499
  if (this.status !== status) this.status$.next(status);
4198
4500
  const online = status === "online";
4199
4501
  if (this.online !== online) this.online$.next(online);
4200
4502
  }
4201
- offlineBanner() {
4202
- if (this.options.offlineBanner === false || typeof document == "undefined") return;
4203
- if (this.status === "online") {
4204
- removeBanner("datalynk-offline-banner");
4205
- } else {
4206
- 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";
4207
- createBanner(message, {
4208
- id: "datalynk-offline-banner",
4209
- position: this.options.offlineBanner === "top" ? "top" : "bottom"
4210
- });
4211
- }
4212
- }
4213
4503
  startHeartbeat() {
4214
4504
  this.stopHeartbeat();
4215
4505
  this.heartbeat.interval = setInterval(() => this.checkConnection(), this.heartbeat.timeout / 2);
@@ -4319,6 +4609,8 @@ const _Api = class _Api {
4319
4609
  if (this.offline && typeof navigator != "undefined") {
4320
4610
  if (this.status === "unauthorized") return Promise.reject(errorFromCode(401, "Session expired"));
4321
4611
  if (options.offline) {
4612
+ if (!this.ensureOfflineSessionOwner(this.token))
4613
+ return Promise.reject(errorFromCode(409, "Offline queue belongs to another authenticated user"));
4322
4614
  (_b = (_a = this.database) == null ? void 0 : _a.table("pending")) == null ? void 0 : _b.add(data, key);
4323
4615
  return Promise.resolve();
4324
4616
  }
@@ -48,7 +48,7 @@ self.addEventListener('activate', (event) => {
48
48
 
49
49
  const settings = await getSettings();
50
50
  if(settings?.url) {
51
- api = new Api(settings.url, { ...(settings.options || {}), serviceWorker: false });
51
+ api = new Api(settings.url, { ...(settings.options || {}), serviceWorker: false, replayOfflineQueue: false });
52
52
  if(settings.token) api.token = settings.token;
53
53
  }
54
54
  })());
@@ -117,12 +117,17 @@ self.addEventListener('fetch', (event) => {
117
117
 
118
118
  self.addEventListener('message', async (event) => {
119
119
  if(event.data?.options) { // 🔧 Update settings
120
- api = new Api(event.data.options.url, { ...event.data.options, serviceWorker: false });
120
+ api = new Api(event.data.options.url, { ...event.data.options, serviceWorker: false, replayOfflineQueue: false });
121
121
  await setSettings(event.data.options);
122
- } else if(event.data?.token) { // 🔑 Update token
123
- if(api) api.token = event.data.token;
124
- await setSettings({token: event.data.token});
125
- } else if(event.data?.clearCache) { // 🧹 Clear cache (debugging)
122
+ }
123
+ // `null` is an intentional token update during logout/expiry, so check for the
124
+ // property rather than truthiness.
125
+ if(event.data && Object.prototype.hasOwnProperty.call(event.data, 'token')) { // 🔑 Update token
126
+ const token = event.data.token ?? null;
127
+ if(api) api.token = token;
128
+ await setSettings({token});
129
+ }
130
+ if(event.data?.clearCache) { // 🧹 Clear cache
126
131
  await caches.delete(CACHE_NAME);
127
132
  }
128
133
  });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@auxilium/datalynk-client",
3
3
  "description": "Datalynk client library",
4
4
  "repository": "https://gitlab.auxiliumgroup.com/auxilium/datalynk/datalynk-client",
5
- "version": "1.5.0",
5
+ "version": "1.5.2",
6
6
  "author": "Auxilium Group <devops@auxiliumgroup.com>",
7
7
  "private": false,
8
8
  "main": "./dist/index.cjs",