@spacelr/sdk 0.1.5 → 0.1.6

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.js CHANGED
@@ -102,6 +102,9 @@ var HttpClient = class {
102
102
  this.tokenManager = tokenManager;
103
103
  }
104
104
  async request(options) {
105
+ return this.requestWithRetry(options, false);
106
+ }
107
+ async requestWithRetry(options, isRetry) {
105
108
  const url = this.buildUrl(options.path, options.query);
106
109
  const headers = await this.buildHeaders(options);
107
110
  const timeout = this.config.timeout ?? 3e4;
@@ -118,6 +121,11 @@ var HttpClient = class {
118
121
  });
119
122
  const responseBody = await this.parseResponse(response);
120
123
  if (!response.ok) {
124
+ if (options.authenticated && response.status === 401) {
125
+ if (await this.recoverFromAuthFailure(isRetry)) {
126
+ return this.requestWithRetry(options, true);
127
+ }
128
+ }
121
129
  this.throwHttpError(response.status, responseBody);
122
130
  }
123
131
  return this.extractData(responseBody);
@@ -134,12 +142,45 @@ var HttpClient = class {
134
142
  }
135
143
  }
136
144
  async uploadForm(path, formData, onProgress) {
145
+ return this.uploadFormWithRetry(path, formData, onProgress, false);
146
+ }
147
+ async uploadFormWithRetry(path, formData, onProgress, isRetry) {
137
148
  const url = this.buildUrl(path);
138
149
  const headers = await this.buildFormHeaders();
139
150
  const timeoutMs = this.config.timeout ?? 3e4;
140
- if (onProgress) {
141
- return this.uploadFormWithProgress(url, headers, formData, onProgress, timeoutMs);
151
+ try {
152
+ if (onProgress) {
153
+ return await this.uploadFormWithProgress(url, headers, formData, onProgress, timeoutMs);
154
+ }
155
+ return await this.uploadFormOnce(url, headers, formData, timeoutMs);
156
+ } catch (error) {
157
+ if (error instanceof SpacelrAuthError && error.statusCode === 401) {
158
+ if (await this.recoverFromAuthFailure(isRetry)) {
159
+ return this.uploadFormWithRetry(path, formData, onProgress, true);
160
+ }
161
+ }
162
+ throw error;
163
+ }
164
+ }
165
+ /**
166
+ * Shared handler for a 401 on an authenticated request. Returns true if the
167
+ * caller should retry with a refreshed token. 403 is never routed here —
168
+ * it means "forbidden for this action" and must not trigger logout.
169
+ *
170
+ * `emitAuthLost('unauthorized')` is deduped inside TokenManager, so it's a
171
+ * no-op if `executeRefresh` already emitted 'refresh-failed'.
172
+ */
173
+ async recoverFromAuthFailure(isRetry) {
174
+ if (isRetry) {
175
+ this.tokenManager.emitAuthLost("unauthorized");
176
+ return false;
142
177
  }
178
+ const refreshed = await this.tokenManager.forceRefresh();
179
+ if (refreshed) return true;
180
+ this.tokenManager.emitAuthLost("unauthorized");
181
+ return false;
182
+ }
183
+ async uploadFormOnce(url, headers, formData, timeoutMs) {
143
184
  const controller = new AbortController();
144
185
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
145
186
  try {
@@ -185,6 +226,8 @@ var HttpClient = class {
185
226
  if (!contentType.includes("application/json")) {
186
227
  if (xhr.status >= 200 && xhr.status < 300) {
187
228
  resolve({ success: true, data: xhr.responseText });
229
+ } else if (xhr.status === 401 || xhr.status === 403) {
230
+ reject(new SpacelrAuthError(`HTTP ${xhr.status}`, xhr.status));
188
231
  } else {
189
232
  reject(new SpacelrNetworkError(`HTTP ${xhr.status}: ${xhr.responseText.slice(0, 200)}`));
190
233
  }
@@ -341,6 +384,12 @@ var TokenManager = class {
341
384
  constructor(storage, refreshBufferSeconds = 60) {
342
385
  this.refreshCallback = null;
343
386
  this.refreshPromise = null;
387
+ this.tokenRefreshedListeners = /* @__PURE__ */ new Set();
388
+ this.authLostListeners = /* @__PURE__ */ new Set();
389
+ // Guard so callbacks that run during auth-loss handling (e.g. a logout()
390
+ // that hits `/auth/logout` with a dead token) don't re-emit and loop.
391
+ // Cleared by setTokens() / clearTokens() to re-arm for the next session.
392
+ this.authLostEmitted = false;
344
393
  this.storage = storage ?? new MemoryTokenStorage();
345
394
  this.refreshBufferSeconds = refreshBufferSeconds;
346
395
  }
@@ -362,14 +411,53 @@ var TokenManager = class {
362
411
  }
363
412
  async setTokens(tokens) {
364
413
  await this.storage.setTokens(tokens);
414
+ this.authLostEmitted = false;
365
415
  }
366
416
  async clearTokens() {
367
417
  await this.storage.clearTokens();
368
418
  this.refreshPromise = null;
419
+ this.authLostEmitted = false;
369
420
  }
370
421
  async getStoredTokens() {
371
422
  return this.storage.getTokens();
372
423
  }
424
+ /**
425
+ * Force a refresh using the current stored refresh token.
426
+ * Returns the new access token, or null if refresh is not possible or fails.
427
+ * A rejecting custom `TokenStorage.getTokens()` is treated as "no tokens"
428
+ * so callers (notably `HttpClient`'s 401 retry path) see a clean `null`
429
+ * rather than an exception that would get wrapped as a network error.
430
+ */
431
+ async forceRefresh() {
432
+ if (this.authLostEmitted) return null;
433
+ let tokens;
434
+ try {
435
+ tokens = await this.storage.getTokens();
436
+ } catch {
437
+ return null;
438
+ }
439
+ if (!tokens) return null;
440
+ const refreshed = await this.tryRefresh(tokens);
441
+ return refreshed?.accessToken ?? null;
442
+ }
443
+ onTokenRefreshed(listener) {
444
+ this.tokenRefreshedListeners.add(listener);
445
+ return () => this.tokenRefreshedListeners.delete(listener);
446
+ }
447
+ onAuthLost(listener) {
448
+ this.authLostListeners.add(listener);
449
+ return () => this.authLostListeners.delete(listener);
450
+ }
451
+ emitAuthLost(reason) {
452
+ if (this.authLostEmitted) return;
453
+ this.authLostEmitted = true;
454
+ for (const listener of this.authLostListeners) {
455
+ try {
456
+ listener(reason);
457
+ } catch {
458
+ }
459
+ }
460
+ }
373
461
  isTokenExpired(tokens) {
374
462
  if (!tokens.expiresAt) return false;
375
463
  return Date.now() >= tokens.expiresAt * 1e3;
@@ -382,21 +470,40 @@ var TokenManager = class {
382
470
  async tryRefresh(tokens) {
383
471
  if (!tokens.refreshToken || !this.refreshCallback) return null;
384
472
  if (this.refreshPromise) {
385
- return this.refreshPromise;
473
+ try {
474
+ return await this.refreshPromise;
475
+ } catch {
476
+ return null;
477
+ }
386
478
  }
387
479
  this.refreshPromise = this.executeRefresh(tokens.refreshToken);
388
480
  try {
389
- const result = await this.refreshPromise;
390
- return result;
481
+ return await this.refreshPromise;
482
+ } catch {
483
+ return null;
391
484
  } finally {
392
485
  this.refreshPromise = null;
393
486
  }
394
487
  }
395
488
  async executeRefresh(refreshToken) {
396
489
  const callback = this.refreshCallback;
397
- const newTokens = await callback(refreshToken);
398
- await this.storage.setTokens(newTokens);
399
- return newTokens;
490
+ try {
491
+ const newTokens = await callback(refreshToken);
492
+ await this.storage.setTokens(newTokens);
493
+ this.emitTokenRefreshed(newTokens);
494
+ return newTokens;
495
+ } catch (error) {
496
+ this.emitAuthLost("refresh-failed");
497
+ throw error;
498
+ }
499
+ }
500
+ emitTokenRefreshed(tokens) {
501
+ for (const listener of this.tokenRefreshedListeners) {
502
+ try {
503
+ listener(tokens);
504
+ } catch {
505
+ }
506
+ }
400
507
  }
401
508
  };
402
509
 
@@ -467,6 +574,7 @@ async function generatePKCEChallenge() {
467
574
 
468
575
  // libs/sdk/src/core/realtime.ts
469
576
  var import_socket = require("socket.io-client");
577
+ var REBUILD_RETRY_DELAY_MS = 5e3;
470
578
  var RealtimeClient = class {
471
579
  constructor(config) {
472
580
  this.socket = null;
@@ -474,9 +582,26 @@ var RealtimeClient = class {
474
582
  this.connecting = null;
475
583
  // Store original where objects per room for reconnect resubscription
476
584
  this.roomWhereMap = /* @__PURE__ */ new Map();
585
+ this.unsubscribeFromTokenRefreshed = null;
586
+ // Wake-up listeners (browser only) — recover from long OS suspends where
587
+ // socket.io's internal reconnect loop may have already given up.
588
+ this.onVisibilityChange = null;
589
+ this.onOnline = null;
590
+ // Set by `disconnect()`; checked by async paths (rebuildSocket, deferred
591
+ // retries) to avoid reviving a client the consumer has torn down.
592
+ this.disposed = false;
593
+ // Scheduled retry after a failed `rebuildSocket()`; cleared when it fires
594
+ // or when `disconnect()` tears down.
595
+ this.rebuildRetryTimer = null;
596
+ this.connectionState = "disconnected";
597
+ this.connectionStateListeners = /* @__PURE__ */ new Set();
477
598
  this.config = config;
478
599
  }
479
600
  async subscribe(projectId, collectionName, callback, onError, where) {
601
+ this.ensureWakeListeners();
602
+ if (this.connectionState === "disconnected") {
603
+ this.setConnectionState("reconnecting");
604
+ }
480
605
  await this.ensureConnected();
481
606
  const room = this.buildRoomKey(projectId, collectionName, where);
482
607
  if (!this.subscriptions.has(room)) {
@@ -518,7 +643,24 @@ var RealtimeClient = class {
518
643
  }
519
644
  };
520
645
  }
646
+ /**
647
+ * Tear down the realtime client permanently. After this call the instance
648
+ * is disposed — subsequent `subscribe()` calls will not re-establish a
649
+ * connection. Create a new `RealtimeClient` (via `createClient`) if you
650
+ * need to reconnect after a logout.
651
+ */
521
652
  disconnect() {
653
+ this.setConnectionState("disconnected");
654
+ this.disposed = true;
655
+ if (this.rebuildRetryTimer) {
656
+ clearTimeout(this.rebuildRetryTimer);
657
+ this.rebuildRetryTimer = null;
658
+ }
659
+ if (this.unsubscribeFromTokenRefreshed) {
660
+ this.unsubscribeFromTokenRefreshed();
661
+ this.unsubscribeFromTokenRefreshed = null;
662
+ }
663
+ this.detachWakeListeners();
522
664
  if (this.socket) {
523
665
  this.socket.disconnect();
524
666
  this.socket = null;
@@ -527,6 +669,23 @@ var RealtimeClient = class {
527
669
  this.roomWhereMap.clear();
528
670
  this.connecting = null;
529
671
  }
672
+ getConnectionState() {
673
+ return this.connectionState;
674
+ }
675
+ onConnectionStateChanged(listener) {
676
+ this.connectionStateListeners.add(listener);
677
+ return () => this.connectionStateListeners.delete(listener);
678
+ }
679
+ setConnectionState(next) {
680
+ if (this.connectionState === next) return;
681
+ this.connectionState = next;
682
+ for (const listener of this.connectionStateListeners) {
683
+ try {
684
+ listener(next);
685
+ } catch {
686
+ }
687
+ }
688
+ }
530
689
  buildRoomKey(projectId, collectionName, where) {
531
690
  const base = `db:${projectId}:${collectionName}`;
532
691
  if (!where || Object.keys(where).length === 0) {
@@ -569,6 +728,8 @@ var RealtimeClient = class {
569
728
  // Disabled until first successful connect
570
729
  });
571
730
  this.socket.on("authenticated", () => {
731
+ if (this.disposed) return;
732
+ this.setConnectionState("connected");
572
733
  if (initialConnect) {
573
734
  initialConnect = false;
574
735
  if (this.socket) {
@@ -577,11 +738,15 @@ var RealtimeClient = class {
577
738
  this.socket.io.opts.reconnectionDelayMax = 5e3;
578
739
  this.socket.io.opts.reconnectionAttempts = 50;
579
740
  }
741
+ if (this.config.onTokenRefreshed && !this.unsubscribeFromTokenRefreshed) {
742
+ this.unsubscribeFromTokenRefreshed = this.config.onTokenRefreshed(
743
+ (accessToken) => {
744
+ this.socket?.emit("reauthenticate", { token: accessToken });
745
+ }
746
+ );
747
+ }
580
748
  resolve();
581
- }
582
- });
583
- this.socket.on("connect", () => {
584
- if (!initialConnect) {
749
+ } else {
585
750
  this.resubscribeAll();
586
751
  }
587
752
  });
@@ -590,6 +755,9 @@ var RealtimeClient = class {
590
755
  reject(new Error(`WebSocket connection failed: ${err.message}`));
591
756
  }
592
757
  });
758
+ this.socket.io.on("reconnect_failed", () => {
759
+ void this.rebuildSocket();
760
+ });
593
761
  this.socket.on("db:event", (event) => {
594
762
  const base = `db:${event.projectId}:${event.collectionName}`;
595
763
  const baseCallbacks = this.subscriptions.get(base);
@@ -613,6 +781,9 @@ var RealtimeClient = class {
613
781
  }
614
782
  });
615
783
  this.socket.on("disconnect", () => {
784
+ if (!this.disposed) {
785
+ this.setConnectionState("reconnecting");
786
+ }
616
787
  });
617
788
  });
618
789
  }
@@ -635,6 +806,70 @@ var RealtimeClient = class {
635
806
  }
636
807
  return true;
637
808
  }
809
+ /**
810
+ * Tear down the current socket and create a fresh one. Used by both
811
+ * `reconnect_failed` (socket.io gave up) and the visibility/online wake-up
812
+ * listeners. Preserves the `subscriptions` map so existing rooms can be
813
+ * replayed to the server on the new connection. If the rebuild itself
814
+ * fails it schedules one retry after 5 s — beyond that we rely on the
815
+ * next wake-up event (visibility/online) to try again.
816
+ */
817
+ async rebuildSocket() {
818
+ if (this.disposed || this.connecting) return;
819
+ const previous = this.socket;
820
+ this.socket = null;
821
+ if (previous) previous.disconnect();
822
+ this.setConnectionState("reconnecting");
823
+ try {
824
+ await this.ensureConnected();
825
+ this.handlePostRebuild();
826
+ } catch {
827
+ this.scheduleRebuildRetry();
828
+ }
829
+ }
830
+ handlePostRebuild() {
831
+ const newSocket = this.socket;
832
+ if (this.disposed) {
833
+ this.socket = null;
834
+ newSocket?.disconnect();
835
+ return;
836
+ }
837
+ if (this.subscriptions.size > 0) {
838
+ this.resubscribeAll();
839
+ }
840
+ }
841
+ scheduleRebuildRetry() {
842
+ if (this.disposed || this.rebuildRetryTimer) return;
843
+ this.rebuildRetryTimer = setTimeout(() => {
844
+ this.rebuildRetryTimer = null;
845
+ void this.rebuildSocket();
846
+ }, REBUILD_RETRY_DELAY_MS);
847
+ this.rebuildRetryTimer.unref?.();
848
+ }
849
+ ensureWakeListeners() {
850
+ if (typeof document === "undefined" || typeof window === "undefined") return;
851
+ if (this.onVisibilityChange !== null) return;
852
+ const wakeIfUnhealthy = () => {
853
+ if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
854
+ if (this.socket?.connected) return;
855
+ if (this.connecting) return;
856
+ void this.rebuildSocket();
857
+ };
858
+ this.onVisibilityChange = wakeIfUnhealthy;
859
+ this.onOnline = wakeIfUnhealthy;
860
+ document.addEventListener("visibilitychange", this.onVisibilityChange);
861
+ window.addEventListener("online", this.onOnline);
862
+ }
863
+ detachWakeListeners() {
864
+ if (typeof document !== "undefined" && this.onVisibilityChange) {
865
+ document.removeEventListener("visibilitychange", this.onVisibilityChange);
866
+ }
867
+ if (typeof window !== "undefined" && this.onOnline) {
868
+ window.removeEventListener("online", this.onOnline);
869
+ }
870
+ this.onVisibilityChange = null;
871
+ this.onOnline = null;
872
+ }
638
873
  resubscribeAll() {
639
874
  for (const [room] of this.subscriptions) {
640
875
  const queryIdx = room.indexOf("?");
@@ -714,11 +949,14 @@ var AuthModule = class {
714
949
  });
715
950
  }
716
951
  async logout() {
717
- await this.http.request({
718
- method: "POST",
719
- path: "/auth/logout",
720
- authenticated: true
721
- });
952
+ try {
953
+ await this.http.request({
954
+ method: "POST",
955
+ path: "/auth/logout",
956
+ authenticated: true
957
+ });
958
+ } catch {
959
+ }
722
960
  await this.tokenManager.clearTokens();
723
961
  }
724
962
  async verifyEmail(token) {
@@ -1156,11 +1394,11 @@ var CollectionRef = class {
1156
1394
  this.collectionName = collectionName;
1157
1395
  this.basePath = `/db/${collectionName}`;
1158
1396
  }
1159
- async insert(document) {
1397
+ async insert(document2) {
1160
1398
  return this.http.request({
1161
1399
  method: "POST",
1162
1400
  path: this.basePath,
1163
- body: { documents: [document] },
1401
+ body: { documents: [document2] },
1164
1402
  authenticated: true
1165
1403
  });
1166
1404
  }
@@ -1458,7 +1696,8 @@ function createClient(config) {
1458
1696
  const httpClient = new HttpClient(config, tokenManager);
1459
1697
  const realtime = new RealtimeClient({
1460
1698
  baseUrl: config.apiUrl,
1461
- getToken: () => tokenManager.getAccessToken()
1699
+ getToken: () => tokenManager.getAccessToken(),
1700
+ onTokenRefreshed: (listener) => tokenManager.onTokenRefreshed((tokens) => listener(tokens.accessToken))
1462
1701
  });
1463
1702
  const auth = new AuthModule(httpClient, tokenManager, config);
1464
1703
  const storage = new StorageModule(httpClient, tokenManager, config);
@@ -1471,8 +1710,26 @@ function createClient(config) {
1471
1710
  db,
1472
1711
  notifications,
1473
1712
  functions,
1713
+ setTokens(tokens) {
1714
+ return tokenManager.setTokens(tokens);
1715
+ },
1716
+ clearTokens() {
1717
+ return tokenManager.clearTokens();
1718
+ },
1474
1719
  disconnect() {
1475
1720
  realtime.disconnect();
1721
+ },
1722
+ onAuthLost(listener) {
1723
+ return tokenManager.onAuthLost(listener);
1724
+ },
1725
+ onTokenRefreshed(listener) {
1726
+ return tokenManager.onTokenRefreshed(listener);
1727
+ },
1728
+ onConnectionStateChanged(listener) {
1729
+ return realtime.onConnectionStateChanged(listener);
1730
+ },
1731
+ getConnectionState() {
1732
+ return realtime.getConnectionState();
1476
1733
  }
1477
1734
  };
1478
1735
  }