@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/README.md +164 -0
- package/dist/admin.d.ts +2 -10
- package/dist/auth.d.ts +2 -1
- package/dist/index.d.ts +25 -17
- package/dist/index.es.js +2079 -2378
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +2160 -2426
- package/dist/index.umd.js.map +1 -1
- package/dist/websocket.d.ts +9 -6
- package/package.json +13 -13
- package/src/admin.ts +3 -10
- package/src/auth.ts +9 -4
- package/src/collection.test.ts +11 -6
- package/src/functions.ts +2 -2
- package/src/index.ts +34 -22
- package/src/websocket.ts +121 -90
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 || [])
|
|
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
|
|
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
|
|
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.
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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.
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
826
|
+
return response.entity;
|
|
796
827
|
}
|
|
797
828
|
|
|
798
829
|
async deleteEntity<M extends Record<string, unknown>>(props: DeleteEntityProps<M>): Promise<void> {
|