@spacelr/sdk 0.1.5 → 0.1.7

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
@@ -70,6 +70,9 @@ var HttpClient = class {
70
70
  this.tokenManager = tokenManager;
71
71
  }
72
72
  async request(options) {
73
+ return this.requestWithRetry(options, false);
74
+ }
75
+ async requestWithRetry(options, isRetry) {
73
76
  const url = this.buildUrl(options.path, options.query);
74
77
  const headers = await this.buildHeaders(options);
75
78
  const timeout = this.config.timeout ?? 3e4;
@@ -86,6 +89,11 @@ var HttpClient = class {
86
89
  });
87
90
  const responseBody = await this.parseResponse(response);
88
91
  if (!response.ok) {
92
+ if (options.authenticated && response.status === 401) {
93
+ if (await this.recoverFromAuthFailure(isRetry)) {
94
+ return this.requestWithRetry(options, true);
95
+ }
96
+ }
89
97
  this.throwHttpError(response.status, responseBody);
90
98
  }
91
99
  return this.extractData(responseBody);
@@ -102,12 +110,45 @@ var HttpClient = class {
102
110
  }
103
111
  }
104
112
  async uploadForm(path, formData, onProgress) {
113
+ return this.uploadFormWithRetry(path, formData, onProgress, false);
114
+ }
115
+ async uploadFormWithRetry(path, formData, onProgress, isRetry) {
105
116
  const url = this.buildUrl(path);
106
117
  const headers = await this.buildFormHeaders();
107
118
  const timeoutMs = this.config.timeout ?? 3e4;
108
- if (onProgress) {
109
- return this.uploadFormWithProgress(url, headers, formData, onProgress, timeoutMs);
119
+ try {
120
+ if (onProgress) {
121
+ return await this.uploadFormWithProgress(url, headers, formData, onProgress, timeoutMs);
122
+ }
123
+ return await this.uploadFormOnce(url, headers, formData, timeoutMs);
124
+ } catch (error) {
125
+ if (error instanceof SpacelrAuthError && error.statusCode === 401) {
126
+ if (await this.recoverFromAuthFailure(isRetry)) {
127
+ return this.uploadFormWithRetry(path, formData, onProgress, true);
128
+ }
129
+ }
130
+ throw error;
131
+ }
132
+ }
133
+ /**
134
+ * Shared handler for a 401 on an authenticated request. Returns true if the
135
+ * caller should retry with a refreshed token. 403 is never routed here —
136
+ * it means "forbidden for this action" and must not trigger logout.
137
+ *
138
+ * `emitAuthLost('unauthorized')` is deduped inside TokenManager, so it's a
139
+ * no-op if `executeRefresh` already emitted 'refresh-failed'.
140
+ */
141
+ async recoverFromAuthFailure(isRetry) {
142
+ if (isRetry) {
143
+ this.tokenManager.emitAuthLost("unauthorized");
144
+ return false;
110
145
  }
146
+ const refreshed = await this.tokenManager.forceRefresh();
147
+ if (refreshed) return true;
148
+ this.tokenManager.emitAuthLost("unauthorized");
149
+ return false;
150
+ }
151
+ async uploadFormOnce(url, headers, formData, timeoutMs) {
111
152
  const controller = new AbortController();
112
153
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
113
154
  try {
@@ -153,6 +194,8 @@ var HttpClient = class {
153
194
  if (!contentType.includes("application/json")) {
154
195
  if (xhr.status >= 200 && xhr.status < 300) {
155
196
  resolve({ success: true, data: xhr.responseText });
197
+ } else if (xhr.status === 401 || xhr.status === 403) {
198
+ reject(new SpacelrAuthError(`HTTP ${xhr.status}`, xhr.status));
156
199
  } else {
157
200
  reject(new SpacelrNetworkError(`HTTP ${xhr.status}: ${xhr.responseText.slice(0, 200)}`));
158
201
  }
@@ -309,6 +352,12 @@ var TokenManager = class {
309
352
  constructor(storage, refreshBufferSeconds = 60) {
310
353
  this.refreshCallback = null;
311
354
  this.refreshPromise = null;
355
+ this.tokenRefreshedListeners = /* @__PURE__ */ new Set();
356
+ this.authLostListeners = /* @__PURE__ */ new Set();
357
+ // Guard so callbacks that run during auth-loss handling (e.g. a logout()
358
+ // that hits `/auth/logout` with a dead token) don't re-emit and loop.
359
+ // Cleared by setTokens() / clearTokens() to re-arm for the next session.
360
+ this.authLostEmitted = false;
312
361
  this.storage = storage ?? new MemoryTokenStorage();
313
362
  this.refreshBufferSeconds = refreshBufferSeconds;
314
363
  }
@@ -330,14 +379,53 @@ var TokenManager = class {
330
379
  }
331
380
  async setTokens(tokens) {
332
381
  await this.storage.setTokens(tokens);
382
+ this.authLostEmitted = false;
333
383
  }
334
384
  async clearTokens() {
335
385
  await this.storage.clearTokens();
336
386
  this.refreshPromise = null;
387
+ this.authLostEmitted = false;
337
388
  }
338
389
  async getStoredTokens() {
339
390
  return this.storage.getTokens();
340
391
  }
392
+ /**
393
+ * Force a refresh using the current stored refresh token.
394
+ * Returns the new access token, or null if refresh is not possible or fails.
395
+ * A rejecting custom `TokenStorage.getTokens()` is treated as "no tokens"
396
+ * so callers (notably `HttpClient`'s 401 retry path) see a clean `null`
397
+ * rather than an exception that would get wrapped as a network error.
398
+ */
399
+ async forceRefresh() {
400
+ if (this.authLostEmitted) return null;
401
+ let tokens;
402
+ try {
403
+ tokens = await this.storage.getTokens();
404
+ } catch {
405
+ return null;
406
+ }
407
+ if (!tokens) return null;
408
+ const refreshed = await this.tryRefresh(tokens);
409
+ return refreshed?.accessToken ?? null;
410
+ }
411
+ onTokenRefreshed(listener) {
412
+ this.tokenRefreshedListeners.add(listener);
413
+ return () => this.tokenRefreshedListeners.delete(listener);
414
+ }
415
+ onAuthLost(listener) {
416
+ this.authLostListeners.add(listener);
417
+ return () => this.authLostListeners.delete(listener);
418
+ }
419
+ emitAuthLost(reason) {
420
+ if (this.authLostEmitted) return;
421
+ this.authLostEmitted = true;
422
+ for (const listener of this.authLostListeners) {
423
+ try {
424
+ listener(reason);
425
+ } catch {
426
+ }
427
+ }
428
+ }
341
429
  isTokenExpired(tokens) {
342
430
  if (!tokens.expiresAt) return false;
343
431
  return Date.now() >= tokens.expiresAt * 1e3;
@@ -350,21 +438,40 @@ var TokenManager = class {
350
438
  async tryRefresh(tokens) {
351
439
  if (!tokens.refreshToken || !this.refreshCallback) return null;
352
440
  if (this.refreshPromise) {
353
- return this.refreshPromise;
441
+ try {
442
+ return await this.refreshPromise;
443
+ } catch {
444
+ return null;
445
+ }
354
446
  }
355
447
  this.refreshPromise = this.executeRefresh(tokens.refreshToken);
356
448
  try {
357
- const result = await this.refreshPromise;
358
- return result;
449
+ return await this.refreshPromise;
450
+ } catch {
451
+ return null;
359
452
  } finally {
360
453
  this.refreshPromise = null;
361
454
  }
362
455
  }
363
456
  async executeRefresh(refreshToken) {
364
457
  const callback = this.refreshCallback;
365
- const newTokens = await callback(refreshToken);
366
- await this.storage.setTokens(newTokens);
367
- return newTokens;
458
+ try {
459
+ const newTokens = await callback(refreshToken);
460
+ await this.storage.setTokens(newTokens);
461
+ this.emitTokenRefreshed(newTokens);
462
+ return newTokens;
463
+ } catch (error) {
464
+ this.emitAuthLost("refresh-failed");
465
+ throw error;
466
+ }
467
+ }
468
+ emitTokenRefreshed(tokens) {
469
+ for (const listener of this.tokenRefreshedListeners) {
470
+ try {
471
+ listener(tokens);
472
+ } catch {
473
+ }
474
+ }
368
475
  }
369
476
  };
370
477
 
@@ -435,6 +542,7 @@ async function generatePKCEChallenge() {
435
542
 
436
543
  // libs/sdk/src/core/realtime.ts
437
544
  import { io } from "socket.io-client";
545
+ var REBUILD_RETRY_DELAY_MS = 5e3;
438
546
  var RealtimeClient = class {
439
547
  constructor(config) {
440
548
  this.socket = null;
@@ -442,9 +550,26 @@ var RealtimeClient = class {
442
550
  this.connecting = null;
443
551
  // Store original where objects per room for reconnect resubscription
444
552
  this.roomWhereMap = /* @__PURE__ */ new Map();
553
+ this.unsubscribeFromTokenRefreshed = null;
554
+ // Wake-up listeners (browser only) — recover from long OS suspends where
555
+ // socket.io's internal reconnect loop may have already given up.
556
+ this.onVisibilityChange = null;
557
+ this.onOnline = null;
558
+ // Set by `disconnect()`; checked by async paths (rebuildSocket, deferred
559
+ // retries) to avoid reviving a client the consumer has torn down.
560
+ this.disposed = false;
561
+ // Scheduled retry after a failed `rebuildSocket()`; cleared when it fires
562
+ // or when `disconnect()` tears down.
563
+ this.rebuildRetryTimer = null;
564
+ this.connectionState = "disconnected";
565
+ this.connectionStateListeners = /* @__PURE__ */ new Set();
445
566
  this.config = config;
446
567
  }
447
568
  async subscribe(projectId, collectionName, callback, onError, where) {
569
+ this.ensureWakeListeners();
570
+ if (this.connectionState === "disconnected") {
571
+ this.setConnectionState("reconnecting");
572
+ }
448
573
  await this.ensureConnected();
449
574
  const room = this.buildRoomKey(projectId, collectionName, where);
450
575
  if (!this.subscriptions.has(room)) {
@@ -486,7 +611,24 @@ var RealtimeClient = class {
486
611
  }
487
612
  };
488
613
  }
614
+ /**
615
+ * Tear down the realtime client permanently. After this call the instance
616
+ * is disposed — subsequent `subscribe()` calls will not re-establish a
617
+ * connection. Create a new `RealtimeClient` (via `createClient`) if you
618
+ * need to reconnect after a logout.
619
+ */
489
620
  disconnect() {
621
+ this.setConnectionState("disconnected");
622
+ this.disposed = true;
623
+ if (this.rebuildRetryTimer) {
624
+ clearTimeout(this.rebuildRetryTimer);
625
+ this.rebuildRetryTimer = null;
626
+ }
627
+ if (this.unsubscribeFromTokenRefreshed) {
628
+ this.unsubscribeFromTokenRefreshed();
629
+ this.unsubscribeFromTokenRefreshed = null;
630
+ }
631
+ this.detachWakeListeners();
490
632
  if (this.socket) {
491
633
  this.socket.disconnect();
492
634
  this.socket = null;
@@ -495,6 +637,23 @@ var RealtimeClient = class {
495
637
  this.roomWhereMap.clear();
496
638
  this.connecting = null;
497
639
  }
640
+ getConnectionState() {
641
+ return this.connectionState;
642
+ }
643
+ onConnectionStateChanged(listener) {
644
+ this.connectionStateListeners.add(listener);
645
+ return () => this.connectionStateListeners.delete(listener);
646
+ }
647
+ setConnectionState(next) {
648
+ if (this.connectionState === next) return;
649
+ this.connectionState = next;
650
+ for (const listener of this.connectionStateListeners) {
651
+ try {
652
+ listener(next);
653
+ } catch {
654
+ }
655
+ }
656
+ }
498
657
  buildRoomKey(projectId, collectionName, where) {
499
658
  const base = `db:${projectId}:${collectionName}`;
500
659
  if (!where || Object.keys(where).length === 0) {
@@ -537,6 +696,8 @@ var RealtimeClient = class {
537
696
  // Disabled until first successful connect
538
697
  });
539
698
  this.socket.on("authenticated", () => {
699
+ if (this.disposed) return;
700
+ this.setConnectionState("connected");
540
701
  if (initialConnect) {
541
702
  initialConnect = false;
542
703
  if (this.socket) {
@@ -545,11 +706,15 @@ var RealtimeClient = class {
545
706
  this.socket.io.opts.reconnectionDelayMax = 5e3;
546
707
  this.socket.io.opts.reconnectionAttempts = 50;
547
708
  }
709
+ if (this.config.onTokenRefreshed && !this.unsubscribeFromTokenRefreshed) {
710
+ this.unsubscribeFromTokenRefreshed = this.config.onTokenRefreshed(
711
+ (accessToken) => {
712
+ this.socket?.emit("reauthenticate", { token: accessToken });
713
+ }
714
+ );
715
+ }
548
716
  resolve();
549
- }
550
- });
551
- this.socket.on("connect", () => {
552
- if (!initialConnect) {
717
+ } else {
553
718
  this.resubscribeAll();
554
719
  }
555
720
  });
@@ -558,6 +723,9 @@ var RealtimeClient = class {
558
723
  reject(new Error(`WebSocket connection failed: ${err.message}`));
559
724
  }
560
725
  });
726
+ this.socket.io.on("reconnect_failed", () => {
727
+ void this.rebuildSocket();
728
+ });
561
729
  this.socket.on("db:event", (event) => {
562
730
  const base = `db:${event.projectId}:${event.collectionName}`;
563
731
  const baseCallbacks = this.subscriptions.get(base);
@@ -581,6 +749,9 @@ var RealtimeClient = class {
581
749
  }
582
750
  });
583
751
  this.socket.on("disconnect", () => {
752
+ if (!this.disposed) {
753
+ this.setConnectionState("reconnecting");
754
+ }
584
755
  });
585
756
  });
586
757
  }
@@ -603,6 +774,70 @@ var RealtimeClient = class {
603
774
  }
604
775
  return true;
605
776
  }
777
+ /**
778
+ * Tear down the current socket and create a fresh one. Used by both
779
+ * `reconnect_failed` (socket.io gave up) and the visibility/online wake-up
780
+ * listeners. Preserves the `subscriptions` map so existing rooms can be
781
+ * replayed to the server on the new connection. If the rebuild itself
782
+ * fails it schedules one retry after 5 s — beyond that we rely on the
783
+ * next wake-up event (visibility/online) to try again.
784
+ */
785
+ async rebuildSocket() {
786
+ if (this.disposed || this.connecting) return;
787
+ const previous = this.socket;
788
+ this.socket = null;
789
+ if (previous) previous.disconnect();
790
+ this.setConnectionState("reconnecting");
791
+ try {
792
+ await this.ensureConnected();
793
+ this.handlePostRebuild();
794
+ } catch {
795
+ this.scheduleRebuildRetry();
796
+ }
797
+ }
798
+ handlePostRebuild() {
799
+ const newSocket = this.socket;
800
+ if (this.disposed) {
801
+ this.socket = null;
802
+ newSocket?.disconnect();
803
+ return;
804
+ }
805
+ if (this.subscriptions.size > 0) {
806
+ this.resubscribeAll();
807
+ }
808
+ }
809
+ scheduleRebuildRetry() {
810
+ if (this.disposed || this.rebuildRetryTimer) return;
811
+ this.rebuildRetryTimer = setTimeout(() => {
812
+ this.rebuildRetryTimer = null;
813
+ void this.rebuildSocket();
814
+ }, REBUILD_RETRY_DELAY_MS);
815
+ this.rebuildRetryTimer.unref?.();
816
+ }
817
+ ensureWakeListeners() {
818
+ if (typeof document === "undefined" || typeof window === "undefined") return;
819
+ if (this.onVisibilityChange !== null) return;
820
+ const wakeIfUnhealthy = () => {
821
+ if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
822
+ if (this.socket?.connected) return;
823
+ if (this.connecting) return;
824
+ void this.rebuildSocket();
825
+ };
826
+ this.onVisibilityChange = wakeIfUnhealthy;
827
+ this.onOnline = wakeIfUnhealthy;
828
+ document.addEventListener("visibilitychange", this.onVisibilityChange);
829
+ window.addEventListener("online", this.onOnline);
830
+ }
831
+ detachWakeListeners() {
832
+ if (typeof document !== "undefined" && this.onVisibilityChange) {
833
+ document.removeEventListener("visibilitychange", this.onVisibilityChange);
834
+ }
835
+ if (typeof window !== "undefined" && this.onOnline) {
836
+ window.removeEventListener("online", this.onOnline);
837
+ }
838
+ this.onVisibilityChange = null;
839
+ this.onOnline = null;
840
+ }
606
841
  resubscribeAll() {
607
842
  for (const [room] of this.subscriptions) {
608
843
  const queryIdx = room.indexOf("?");
@@ -682,11 +917,14 @@ var AuthModule = class {
682
917
  });
683
918
  }
684
919
  async logout() {
685
- await this.http.request({
686
- method: "POST",
687
- path: "/auth/logout",
688
- authenticated: true
689
- });
920
+ try {
921
+ await this.http.request({
922
+ method: "POST",
923
+ path: "/auth/logout",
924
+ authenticated: true
925
+ });
926
+ } catch {
927
+ }
690
928
  await this.tokenManager.clearTokens();
691
929
  }
692
930
  async verifyEmail(token) {
@@ -1124,11 +1362,11 @@ var CollectionRef = class {
1124
1362
  this.collectionName = collectionName;
1125
1363
  this.basePath = `/db/${collectionName}`;
1126
1364
  }
1127
- async insert(document) {
1365
+ async insert(document2) {
1128
1366
  return this.http.request({
1129
1367
  method: "POST",
1130
1368
  path: this.basePath,
1131
- body: { documents: [document] },
1369
+ body: { documents: [document2] },
1132
1370
  authenticated: true
1133
1371
  });
1134
1372
  }
@@ -1426,7 +1664,8 @@ function createClient(config) {
1426
1664
  const httpClient = new HttpClient(config, tokenManager);
1427
1665
  const realtime = new RealtimeClient({
1428
1666
  baseUrl: config.apiUrl,
1429
- getToken: () => tokenManager.getAccessToken()
1667
+ getToken: () => tokenManager.getAccessToken(),
1668
+ onTokenRefreshed: (listener) => tokenManager.onTokenRefreshed((tokens) => listener(tokens.accessToken))
1430
1669
  });
1431
1670
  const auth = new AuthModule(httpClient, tokenManager, config);
1432
1671
  const storage = new StorageModule(httpClient, tokenManager, config);
@@ -1439,8 +1678,26 @@ function createClient(config) {
1439
1678
  db,
1440
1679
  notifications,
1441
1680
  functions,
1681
+ setTokens(tokens) {
1682
+ return tokenManager.setTokens(tokens);
1683
+ },
1684
+ clearTokens() {
1685
+ return tokenManager.clearTokens();
1686
+ },
1442
1687
  disconnect() {
1443
1688
  realtime.disconnect();
1689
+ },
1690
+ onAuthLost(listener) {
1691
+ return tokenManager.onAuthLost(listener);
1692
+ },
1693
+ onTokenRefreshed(listener) {
1694
+ return tokenManager.onTokenRefreshed(listener);
1695
+ },
1696
+ onConnectionStateChanged(listener) {
1697
+ return realtime.onConnectionStateChanged(listener);
1698
+ },
1699
+ getConnectionState() {
1700
+ return realtime.getConnectionState();
1444
1701
  }
1445
1702
  };
1446
1703
  }