@rebasepro/client 0.4.0 → 0.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/src/websocket.ts CHANGED
@@ -14,14 +14,7 @@ import {
14
14
  } from "@rebasepro/types";
15
15
  import { rebaseReviver } from "./reviver";
16
16
 
17
- /**
18
- * Rehydrate all serialised types inside an Entity's `values`.
19
- * (Now obsolete as JSON.parse with rebaseReviver handles this globally,
20
- * kept as pass-through for API compatibility)
21
- */
22
- function rehydrateEntity<M extends Record<string, unknown>>(entity: Entity<M>): Entity<M> {
23
- return entity;
24
- }
17
+
25
18
 
26
19
  /**
27
20
  * Extract error message and code from a WebSocket message payload.
@@ -43,7 +36,7 @@ errorCode };
43
36
  export interface RebaseWebSocketConfig {
44
37
  websocketUrl: string;
45
38
  /** Optional auth token getter for WebSocket authentication */
46
- getAuthToken?: () => Promise<string>;
39
+ getAuthToken?: () => Promise<string | null>;
47
40
  /** Optional WebSocket constructor to override globalThis.WebSocket (e.g. for Node environments) */
48
41
  WebSocket?: typeof WebSocket;
49
42
  /** Callback to handle unauthorized requests or token expiration (refreshes auth session) */
@@ -67,7 +60,7 @@ export class ApiError extends Error {
67
60
  export class RebaseWebSocketClient {
68
61
  private websocketUrl: string;
69
62
  private ws: WebSocket | null = null;
70
- public getAuthToken?: () => Promise<string>;
63
+ public getAuthToken?: () => Promise<string | null>;
71
64
  private subscriptions = new Map<string, {
72
65
  onUpdate: (data: WebSocketMessage) => void,
73
66
  onError?: (error: Error) => void
@@ -128,6 +121,7 @@ export class RebaseWebSocketClient {
128
121
  private maxReconnectAttempts = 5;
129
122
  private isConnected = false;
130
123
  private messageQueue: Record<string, unknown>[] = [];
124
+ private requestTimeoutMs = 30000;
131
125
  private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
132
126
 
133
127
  private isAuthenticated = false;
@@ -191,7 +185,7 @@ export class RebaseWebSocketClient {
191
185
  /**
192
186
  * Set the auth token getter function
193
187
  */
194
- setAuthTokenGetter(getAuthToken: () => Promise<string>): void {
188
+ setAuthTokenGetter(getAuthToken: () => Promise<string | null>): void {
195
189
  this.getAuthToken = getAuthToken;
196
190
  // Auto-authenticate if we are already connected but didn't have the token getter yet
197
191
  if (this.isConnected && !this.isAuthenticated && !this.authPromise) {
@@ -233,6 +227,13 @@ export class RebaseWebSocketClient {
233
227
  if (!this.WebSocketConstructor) return;
234
228
  if (this.ws?.readyState === this.WebSocketConstructor.OPEN) return;
235
229
 
230
+ // Guard against race condition: if a previous socket is still connecting, tear it down
231
+ if (this.ws) {
232
+ this.ws.onclose = null;
233
+ this.ws.close();
234
+ this.ws = null;
235
+ }
236
+
236
237
  try {
237
238
  this.ws = new this.WebSocketConstructor(this.websocketUrl);
238
239
 
@@ -329,11 +330,11 @@ export class RebaseWebSocketClient {
329
330
  const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
330
331
 
331
332
  console.debug(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
332
-
333
+
333
334
  if (this.reconnectTimeout) {
334
335
  clearTimeout(this.reconnectTimeout);
335
336
  }
336
-
337
+
337
338
  this.reconnectTimeout = setTimeout(() => {
338
339
  this.reconnectTimeout = null;
339
340
  this.initWebSocket();
@@ -378,6 +379,56 @@ export class RebaseWebSocketClient {
378
379
  }
379
380
  }
380
381
 
382
+ /**
383
+ * Shared logic for re-subscribing a collection or entity subscription
384
+ * after an auth error is resolved by refreshing credentials.
385
+ */
386
+ private resubscribeAfterAuthRefresh(
387
+ message: WebSocketMessage,
388
+ subscription: {
389
+ backendSubscriptionId: string;
390
+ callbacks: Map<string, { onUpdate: (...args: never[]) => void; onError?: (error: Error) => void }>;
391
+ props: FetchCollectionProps | FetchEntityProps;
392
+ },
393
+ subscriptionKey: string,
394
+ idPrefix: "collection" | "entity",
395
+ backendKeyMap: Map<string, string>,
396
+ messageType: "subscribe_collection" | "subscribe_entity"
397
+ ): void {
398
+ this.handleAuthFailure().then(refreshed => {
399
+ if (refreshed) {
400
+ const oldBackendId = subscription.backendSubscriptionId;
401
+ const newBackendId = `${idPrefix}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
402
+ subscription.backendSubscriptionId = newBackendId;
403
+ backendKeyMap.delete(oldBackendId);
404
+ backendKeyMap.set(newBackendId, subscriptionKey);
405
+
406
+ this.sendMessage({
407
+ type: messageType,
408
+ payload: {
409
+ ...subscription.props,
410
+ subscriptionId: newBackendId
411
+ }
412
+ }).catch(error => {
413
+ console.error(`[WS] Failed to re-subscribe ${idPrefix} after auth refresh:`, subscriptionKey, error);
414
+ subscription.callbacks.forEach(callback => {
415
+ if (callback.onError) callback.onError(error);
416
+ });
417
+ });
418
+ } else {
419
+ const { errorMessage, errorCode } = extractMessageError(message);
420
+ const error = new ApiError(errorMessage, errorMessage, errorCode);
421
+ subscription.callbacks.forEach(callback => {
422
+ if (callback.onError) callback.onError(error);
423
+ });
424
+ }
425
+ }).catch(err => {
426
+ subscription.callbacks.forEach(callback => {
427
+ if (callback.onError) callback.onError(err);
428
+ });
429
+ });
430
+ }
431
+
381
432
  private handleWebSocketMessage(message: WebSocketMessage) {
382
433
  const {
383
434
  type,
@@ -419,7 +470,7 @@ export class RebaseWebSocketClient {
419
470
  if (subscriptionKey) {
420
471
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
421
472
  if (collectionSub) {
422
- const incomingEntities = (message.entities || []).map((e: Entity) => rehydrateEntity(e));
473
+ const incomingEntities = (message.entities || []) as Entity[];
423
474
 
424
475
  // Structural merge: preserve cached entity references for entities
425
476
  // whose values haven't changed. This prevents downstream React components
@@ -455,7 +506,7 @@ export class RebaseWebSocketClient {
455
506
  if (subscriptionKey) {
456
507
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
457
508
  if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
458
- const patchEntity = message.entity ? rehydrateEntity(message.entity) : message.entity;
509
+ const patchEntity = message.entity ?? null;
459
510
  const patchEntityId = (message as unknown as { entityId: string }).entityId;
460
511
  let updated: Entity[];
461
512
 
@@ -500,7 +551,7 @@ export class RebaseWebSocketClient {
500
551
  if (subscriptionKey) {
501
552
  const entitySub = this.entitySubscriptions.get(subscriptionKey);
502
553
  if (entitySub) {
503
- const entity = message.entity ? rehydrateEntity(message.entity) : null;
554
+ const entity = message.entity ?? null;
504
555
  // Cache the latest data with optimizations
505
556
  entitySub.latestData = entity;
506
557
  entitySub.lastUpdated = Date.now();
@@ -529,38 +580,14 @@ export class RebaseWebSocketClient {
529
580
  const collectionSub = this.collectionSubscriptions.get(collectionKey);
530
581
  if (collectionSub) {
531
582
  if (this.isAuthError(message)) {
532
- this.handleAuthFailure().then(refreshed => {
533
- if (refreshed) {
534
- const oldBackendId = collectionSub.backendSubscriptionId;
535
- const newBackendId = `collection_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
536
- collectionSub.backendSubscriptionId = newBackendId;
537
- this.backendToCollectionKey.delete(oldBackendId);
538
- this.backendToCollectionKey.set(newBackendId, collectionKey);
539
-
540
- this.sendMessage({
541
- type: "subscribe_collection",
542
- payload: {
543
- ...collectionSub.props,
544
- subscriptionId: newBackendId
545
- }
546
- }).catch(error => {
547
- console.error("[WS] Failed to re-subscribe collection after auth refresh:", collectionKey, error);
548
- collectionSub.callbacks.forEach(callback => {
549
- if (callback.onError) callback.onError(error);
550
- });
551
- });
552
- } else {
553
- const { errorMessage, errorCode } = extractMessageError(message);
554
- const error = new ApiError(errorMessage, errorMessage, errorCode);
555
- collectionSub.callbacks.forEach(callback => {
556
- if (callback.onError) callback.onError(error);
557
- });
558
- }
559
- }).catch(err => {
560
- collectionSub.callbacks.forEach(callback => {
561
- if (callback.onError) callback.onError(err);
562
- });
563
- });
583
+ this.resubscribeAfterAuthRefresh(
584
+ message,
585
+ collectionSub,
586
+ collectionKey,
587
+ "collection",
588
+ this.backendToCollectionKey,
589
+ "subscribe_collection"
590
+ );
564
591
  return;
565
592
  }
566
593
 
@@ -580,38 +607,14 @@ export class RebaseWebSocketClient {
580
607
  const entitySub = this.entitySubscriptions.get(entityKey);
581
608
  if (entitySub) {
582
609
  if (this.isAuthError(message)) {
583
- this.handleAuthFailure().then(refreshed => {
584
- if (refreshed) {
585
- const oldBackendId = entitySub.backendSubscriptionId;
586
- const newBackendId = `entity_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
587
- entitySub.backendSubscriptionId = newBackendId;
588
- this.backendToEntityKey.delete(oldBackendId);
589
- this.backendToEntityKey.set(newBackendId, entityKey);
590
-
591
- this.sendMessage({
592
- type: "subscribe_entity",
593
- payload: {
594
- ...entitySub.props,
595
- subscriptionId: newBackendId
596
- }
597
- }).catch(error => {
598
- console.error("[WS] Failed to re-subscribe entity after auth refresh:", entityKey, error);
599
- entitySub.callbacks.forEach(callback => {
600
- if (callback.onError) callback.onError(error);
601
- });
602
- });
603
- } else {
604
- const { errorMessage, errorCode } = extractMessageError(message);
605
- const error = new ApiError(errorMessage, errorMessage, errorCode);
606
- entitySub.callbacks.forEach(callback => {
607
- if (callback.onError) callback.onError(error);
608
- });
609
- }
610
- }).catch(err => {
611
- entitySub.callbacks.forEach(callback => {
612
- if (callback.onError) callback.onError(err);
613
- });
614
- });
610
+ this.resubscribeAfterAuthRefresh(
611
+ message,
612
+ entitySub,
613
+ entityKey,
614
+ "entity",
615
+ this.backendToEntityKey,
616
+ "subscribe_entity"
617
+ );
615
618
  return;
616
619
  }
617
620
 
@@ -700,15 +703,13 @@ export class RebaseWebSocketClient {
700
703
  throw lastError;
701
704
  }
702
705
 
703
- /**
704
- * Force re-authentication (call after token refresh)
705
- */
706
706
  async reauthenticate(): Promise<void> {
707
707
  if (!this.getAuthToken) return;
708
708
 
709
709
  this.isAuthenticated = false;
710
710
  try {
711
711
  const token = await this.getAuthToken();
712
+ if (!token) throw new Error("user not logged in");
712
713
  await this.authenticate(token);
713
714
  console.debug("WebSocket reauthenticated successfully");
714
715
  } catch (error) {
@@ -754,18 +755,48 @@ export class RebaseWebSocketClient {
754
755
  const requestId = (message.requestId as string) || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
755
756
  message.requestId = requestId;
756
757
 
757
- if (!this.pendingRequests.has(requestId)) {
758
+ const expectsResponse = ![
759
+ "subscribe_collection",
760
+ "subscribe_entity",
761
+ "unsubscribe",
762
+ "join_channel",
763
+ "leave_channel",
764
+ "broadcast",
765
+ "presence_track",
766
+ "presence_untrack",
767
+ "presence_state"
768
+ ].includes(message.type as string);
769
+
770
+ if (expectsResponse && !this.pendingRequests.has(requestId)) {
771
+ const timeoutHandle = setTimeout(() => {
772
+ if (this.pendingRequests.has(requestId)) {
773
+ this.pendingRequests.delete(requestId);
774
+ reject(new ApiError("Request timed out", "Request timed out"));
775
+ }
776
+ }, this.requestTimeoutMs);
777
+
758
778
  this.pendingRequests.set(requestId, {
759
- resolve,
760
- reject,
779
+ resolve: (value: unknown) => {
780
+ clearTimeout(timeoutHandle);
781
+ resolve(value);
782
+ },
783
+ reject: (error: Error) => {
784
+ clearTimeout(timeoutHandle);
785
+ reject(error);
786
+ },
761
787
  message: message as Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void }
762
788
  });
763
789
  }
764
790
 
765
791
  try {
766
792
  this.ws!.send(JSON.stringify(message));
793
+ if (!expectsResponse) {
794
+ resolve(undefined);
795
+ }
767
796
  } catch (error) {
768
- this.pendingRequests.delete(requestId);
797
+ if (expectsResponse) {
798
+ this.pendingRequests.delete(requestId);
799
+ }
769
800
  reject(new ApiError("Failed to send message", error instanceof Error ? error.message : "Unknown error"));
770
801
  }
771
802
  }
@@ -776,7 +807,7 @@ export class RebaseWebSocketClient {
776
807
  type: "FETCH_COLLECTION",
777
808
  payload: props
778
809
  }) as { entities?: Entity<M>[] };
779
- return (response.entities || []).map(e => rehydrateEntity(e));
810
+ return response.entities || [];
780
811
  }
781
812
 
782
813
  async fetchEntity<M extends Record<string, unknown>>(props: FetchEntityProps<M>): Promise<Entity<M> | undefined> {
@@ -784,7 +815,7 @@ export class RebaseWebSocketClient {
784
815
  type: "FETCH_ENTITY",
785
816
  payload: props
786
817
  }) as { entity?: Entity<M> };
787
- return response.entity ? rehydrateEntity(response.entity) : undefined;
818
+ return response.entity ?? undefined;
788
819
  }
789
820
 
790
821
  async saveEntity<M extends Record<string, unknown>>(props: SaveEntityProps<M>): Promise<Entity<M>> {
@@ -792,7 +823,7 @@ export class RebaseWebSocketClient {
792
823
  type: "SAVE_ENTITY",
793
824
  payload: props
794
825
  }) as { entity: Entity<M> };
795
- return rehydrateEntity(response.entity);
826
+ return response.entity;
796
827
  }
797
828
 
798
829
  async deleteEntity<M extends Record<string, unknown>>(props: DeleteEntityProps<M>): Promise<void> {